Skip to content

Commit e022219

Browse files
committed
feat(ecash): add testnet support
1 parent 9a7a0fd commit e022219

12 files changed

Lines changed: 161 additions & 105 deletions

File tree

packages/extension/src/libs/activity-state/wrap-activity-handler.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ export default (activityHandler: ActivityHandlerType): ActivityHandlerType => {
1818
};
1919

2020
// Use shorter cache TTL for eCash due to faster finality
21-
const cacheTTL = network.name === 'XEC' ? ECASH_CACHE_TTL : CACHE_TTL;
21+
const isECash = network.name === 'XEC' || network.name === 'XECTest';
22+
const cacheTTL = isECash ? ECASH_CACHE_TTL : CACHE_TTL;
2223

2324
const [activities, cacheTime] = await Promise.all([
2425
activityState.getAllActivities(options),

packages/extension/src/providers/ecash/libs/activity-handlers.ts

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export const chronikHandler: ActivityHandlerType = async (
1818
address,
1919
): Promise<Activity[]> => {
2020
try {
21+
const cashAddrPrefix = (network as any).cashAddrPrefix ?? 'ecash';
2122
const normalizedAddress = getAddressWithoutPrefix(address);
2223

2324
const api = (await network.api()) as unknown as ChronikAPI;
@@ -44,24 +45,37 @@ export const chronikHandler: ActivityHandlerType = async (
4445
for (const tx of txHistory) {
4546
try {
4647
const isReceive = tx.outputs.some((output: any) => {
47-
const outputAddress = scriptToAddress(output.outputScript);
48+
const outputAddress = scriptToAddress(
49+
output.outputScript,
50+
cashAddrPrefix,
51+
);
4852
return outputAddress === normalizedAddress;
4953
});
5054

5155
const isSend = tx.inputs.some((input: any) => {
52-
const inputAddress = scriptToAddress(input.outputScript);
56+
const inputAddress = scriptToAddress(
57+
input.outputScript ?? '',
58+
cashAddrPrefix,
59+
);
5360
return inputAddress === normalizedAddress;
5461
});
5562

56-
const value = isReceive
57-
? calculateTransactionValue(tx.outputs, normalizedAddress, true)
58-
: calculateTransactionValue(tx.outputs, normalizedAddress, false);
63+
const value =
64+
isReceive || isSend
65+
? calculateTransactionValue(
66+
tx.outputs,
67+
normalizedAddress,
68+
isReceive,
69+
cashAddrPrefix,
70+
)
71+
: '0';
5972

6073
const { fromAddress, toAddress } = getTransactionAddresses(
6174
tx,
6275
normalizedAddress,
6376
isReceive,
6477
isSend,
78+
cashAddrPrefix,
6579
);
6680

6781
const fee = isSend ? calculateOnchainTxFee(tx) : 0;
@@ -77,13 +91,13 @@ export const chronikHandler: ActivityHandlerType = async (
7791
blockNumber: tx.block?.height || 0,
7892
fee,
7993
transactionHash: tx.txid,
80-
timestamp: tx.block?.timestamp || Math.floor(timestamp / 1000),
94+
timestamp,
8195
inputs: tx.inputs.map((input: any) => ({
82-
address: scriptToAddress(input.outputScript),
96+
address: scriptToAddress(input.outputScript ?? '', cashAddrPrefix),
8397
value: Number(extractSats(input)),
8498
})),
8599
outputs: tx.outputs.map((output: any) => ({
86-
address: scriptToAddress(output.outputScript),
100+
address: scriptToAddress(output.outputScript, cashAddrPrefix),
87101
value: Number(extractSats(output)),
88102
pkscript: output.outputScript || '',
89103
})),
@@ -106,7 +120,7 @@ export const chronikHandler: ActivityHandlerType = async (
106120
type: ActivityType.transaction,
107121
value,
108122
transactionHash: tx.txid,
109-
timestamp,
123+
timestamp: timestamp * 1000,
110124
token: tokenInfo,
111125
rawInfo,
112126
};

packages/extension/src/providers/ecash/libs/api-chronik.ts

Lines changed: 26 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { ProviderAPIInterface } from '@/types/provider';
22
import { BTCRawInfo } from '@/types/activity';
33
import { ChronikClient } from 'chronik-client';
4+
import { WatchOnlyWallet } from 'ecash-wallet';
45
import { getAddress } from '../types/ecash-network';
56
import { ECashNetworkInfo, ChronikTx } from '../types/ecash-chronik';
67
import { Script, Address } from 'ecash-lib';
@@ -32,7 +33,7 @@ export class ChronikAPI extends ProviderAPIInterface {
3233
return this.withErrorHandling(
3334
'init',
3435
async () => {
35-
await this.chronik.blockchainInfo();
36+
await this.chronik.chronikInfo();
3637
},
3738
() => {
3839
throw new Error('Failed to initialize Chronik API');
@@ -41,7 +42,7 @@ export class ChronikAPI extends ProviderAPIInterface {
4142
}
4243

4344
private ensurePrefix(address: string): string {
44-
if (address.startsWith('ecash:') || address.startsWith('ectest:')) {
45+
if (address.includes(':')) {
4546
return address;
4647
}
4748
return `${this.networkInfo.cashAddrPrefix}:${address}`;
@@ -50,40 +51,25 @@ export class ChronikAPI extends ProviderAPIInterface {
5051
private async withErrorHandling<T>(
5152
method: string,
5253
operation: () => Promise<T>,
53-
fallback: () => T,
54+
fallback?: () => T | Promise<T>,
5455
): Promise<T> {
5556
try {
5657
return await operation();
5758
} catch (error) {
58-
console.error(`❌ [${method}] Error:`, error);
59-
return fallback();
59+
console.error(`[ChronikAPI:${method}]`, error);
60+
if (fallback) return await fallback();
61+
throw error;
6062
}
6163
}
6264

63-
private calculateUTXOBalance(utxos: any[]): bigint {
64-
return utxos.reduce((total, utxo) => {
65-
if (!utxo.token) {
66-
const value = BigInt((utxo as any).sats || utxo.value || 0);
67-
return total + value;
68-
}
69-
return total;
70-
}, BigInt(0));
71-
}
72-
7365
async getBalance(pubkey: string): Promise<string> {
7466
return this.withErrorHandling(
7567
'getBalance',
7668
async () => {
7769
const address = getAddress(pubkey);
78-
79-
const addressWithPrefix = this.ensurePrefix(address);
80-
const utxoResponse = await this.chronik
81-
.address(addressWithPrefix)
82-
.utxos();
83-
84-
const totalSatoshis = this.calculateUTXOBalance(utxoResponse.utxos);
85-
86-
return totalSatoshis.toString();
70+
const wallet = WatchOnlyWallet.fromAddress(address, this.chronik);
71+
await wallet.sync();
72+
return wallet.balanceSats.toString();
8773
},
8874
() => '0',
8975
);
@@ -103,7 +89,7 @@ export class ChronikAPI extends ProviderAPIInterface {
10389
);
10490
}
10591

106-
async getTransactionHistory(address: string): Promise<any[]> {
92+
async getTransactionHistory(address: string): Promise<ChronikTx[]> {
10793
return this.withErrorHandling(
10894
'getTransactionHistory',
10995
async () => {
@@ -122,23 +108,19 @@ export class ChronikAPI extends ProviderAPIInterface {
122108
async () => {
123109
const tx = await this.chronik.tx(hash);
124110

125-
if (!tx.block) {
126-
return null; // Transaction is in mempool
127-
}
128-
129111
const rawInfo: BTCRawInfo = {
130-
blockNumber: tx.block.height,
112+
blockNumber: tx.block?.height ?? 0,
131113
fee: this.calculateFee(tx as any),
132114
transactionHash: tx.txid,
133-
timestamp: tx.block.timestamp,
134-
inputs: tx.inputs.map((input: any) => ({
135-
address: this.scriptToAddress(input.outputScript || ''),
136-
value: input.value || '0',
137-
pkscript: input.outputScript || '',
115+
timestamp: tx.block?.timestamp ?? Math.floor(Date.now() / 1000),
116+
inputs: tx.inputs.map(input => ({
117+
address: this.scriptToAddress(input.outputScript ?? ''),
118+
value: Number(input.sats),
119+
pkscript: input.outputScript ?? '',
138120
})),
139-
outputs: tx.outputs.map((output: any) => ({
121+
outputs: tx.outputs.map(output => ({
140122
address: this.scriptToAddress(output.outputScript),
141-
value: output.value,
123+
value: Number(output.sats),
142124
pkscript: output.outputScript,
143125
})),
144126
};
@@ -151,11 +133,11 @@ export class ChronikAPI extends ProviderAPIInterface {
151133

152134
private calculateFee(tx: ChronikTx): number {
153135
const inputSum = tx.inputs.reduce(
154-
(sum, input) => sum + BigInt(input.value || 0),
136+
(sum, input) => sum + input.sats,
155137
BigInt(0),
156138
);
157139
const outputSum = tx.outputs.reduce(
158-
(sum, output) => sum + BigInt(output.value || 0),
140+
(sum, output) => sum + output.sats,
159141
BigInt(0),
160142
);
161143
return Number(inputSum - outputSum);
@@ -167,13 +149,15 @@ export class ChronikAPI extends ProviderAPIInterface {
167149
try {
168150
const scriptBytes = Buffer.from(scriptHex, 'hex');
169151
const script = new Script(scriptBytes);
170-
const address = Address.fromScript(script);
171-
const fullAddress = address.toString();
152+
const fullAddress = Address.fromScript(
153+
script,
154+
this.networkInfo.cashAddrPrefix,
155+
).toString();
172156

173157
return fullAddress.split(':')[1] || fullAddress;
174158
} catch (error) {
175159
console.error(
176-
'[scriptToAddress] Invalid script:',
160+
'[scriptToAddress] Could not derive address from script, only p2pkh and p2sh are supported:',
177161
scriptHex.slice(0, 20),
178162
error,
179163
);

packages/extension/src/providers/ecash/libs/utils.ts

Lines changed: 32 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,22 @@ export const isValidECashAddress = (address: string): boolean => {
1212

1313
const scriptAddressCache = new Map<string, string>();
1414

15-
export function scriptToAddress(script: string): string {
15+
export function scriptToAddress(
16+
script: string,
17+
cashAddrPrefix: string = 'ecash',
18+
): string {
1619
if (!script) return 'Unknown';
1720

18-
if (scriptAddressCache.has(script)) {
19-
return scriptAddressCache.get(script)!;
21+
const cacheKey = `${cashAddrPrefix}:${script}`;
22+
if (scriptAddressCache.has(cacheKey)) {
23+
return scriptAddressCache.get(cacheKey)!;
2024
}
2125

2226
try {
23-
const address = Address.fromScriptHex(script);
27+
const address = Address.fromScriptHex(script, cashAddrPrefix);
2428
const addressWithoutPrefix = getAddressWithoutPrefix(address);
2529

26-
scriptAddressCache.set(script, addressWithoutPrefix);
30+
scriptAddressCache.set(cacheKey, addressWithoutPrefix);
2731
return addressWithoutPrefix;
2832
} catch (error) {
2933
console.error('[scriptToAddress] Error:', error, script.slice(0, 20));
@@ -33,7 +37,7 @@ export function scriptToAddress(script: string): string {
3337
? `${script.slice(0, 8)}...${script.slice(-8)}`
3438
: script;
3539

36-
scriptAddressCache.set(script, fallback);
40+
scriptAddressCache.set(cacheKey, fallback);
3741
return fallback;
3842
}
3943
}
@@ -47,36 +51,36 @@ export function extractSats(item: any): string {
4751
}
4852

4953
export function sumSatoshis(items: any[]): string {
50-
return items.reduce((sum, item) => {
51-
return toBN(sum)
52-
.add(toBN(extractSats(item)))
53-
.toString();
54-
}, '0');
54+
return items
55+
.reduce((sum, item) => sum.add(toBN(extractSats(item))), toBN('0'))
56+
.toString();
5557
}
5658

5759
/**
5860
* Calculate transaction value for receive or send
5961
* @param outputs - Array of transaction outputs
6062
* @param normalizedAddress - The address to check against
6163
* @param isReceive - true for received funds, false for sent funds
64+
* @param cashAddrPrefix - The cash address prefix (default: 'ecash')
6265
*/
6366
export function calculateTransactionValue(
6467
outputs: any[],
6568
normalizedAddress: string,
6669
isReceive: boolean,
70+
cashAddrPrefix: string = 'ecash',
6771
): string {
6872
return outputs
6973
.filter((output: any) => {
70-
const outputAddress = scriptToAddress(output.outputScript);
74+
const outputAddress = scriptToAddress(
75+
output.outputScript,
76+
cashAddrPrefix,
77+
);
7178
return isReceive
7279
? outputAddress === normalizedAddress
7380
: outputAddress !== normalizedAddress;
7481
})
75-
.reduce((sum: string, output: any) => {
76-
return toBN(sum)
77-
.add(toBN(extractSats(output)))
78-
.toString();
79-
}, '0');
82+
.reduce((sum, output) => sum.add(toBN(extractSats(output))), toBN('0'))
83+
.toString();
8084
}
8185

8286
export function calculateOnchainTxFee(tx: any): number {
@@ -90,23 +94,27 @@ export function getTransactionAddresses(
9094
normalizedAddress: string,
9195
isReceive: boolean,
9296
isSend: boolean,
97+
cashAddrPrefix: string = 'ecash',
9398
): { fromAddress: string; toAddress: string } {
9499
let fromAddress = 'Unknown';
95100
let toAddress = 'Unknown';
96101

97102
if (isReceive) {
98103
fromAddress = tx.inputs[0]?.outputScript
99-
? scriptToAddress(tx.inputs[0].outputScript)
104+
? scriptToAddress(tx.inputs[0].outputScript, cashAddrPrefix)
100105
: 'Unknown';
101106
toAddress = normalizedAddress;
102107
} else if (isSend) {
103108
fromAddress = normalizedAddress;
104109
const recipientOutput = tx.outputs.find((output: any) => {
105-
const outputAddress = scriptToAddress(output.outputScript);
110+
const outputAddress = scriptToAddress(
111+
output.outputScript,
112+
cashAddrPrefix,
113+
);
106114
return outputAddress !== normalizedAddress;
107115
});
108116
toAddress = recipientOutput
109-
? scriptToAddress(recipientOutput.outputScript)
117+
? scriptToAddress(recipientOutput.outputScript, cashAddrPrefix)
110118
: 'Unknown';
111119
}
112120

@@ -115,16 +123,16 @@ export function getTransactionAddresses(
115123

116124
export function getTransactionTimestamp(tx: any): number {
117125
if (tx.block?.timestamp) {
118-
return tx.block.timestamp * 1000;
126+
return tx.block.timestamp;
119127
}
120128
if (tx.timeFirstSeen) {
121-
return parseInt(tx.timeFirstSeen) * 1000;
129+
return Number(tx.timeFirstSeen);
122130
}
123-
return Date.now();
131+
return Math.floor(Date.now() / 1000);
124132
}
125133

126134
export function getAddressWithoutPrefix(address: Address | string): string {
127135
const fullAddress =
128136
typeof address === 'string' ? address : address.toString();
129-
return fullAddress.replace(/^ecash:/, '');
137+
return fullAddress.replace(/^\w+:/, '');
130138
}

0 commit comments

Comments
 (0)