@cryptforge/blockchain-evm 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +230 -0
- package/dist/index.d.mts +580 -0
- package/dist/index.d.ts +580 -0
- package/dist/index.js +1146 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +1140 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +56 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1146 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var ethers = require('ethers');
|
|
4
|
+
|
|
5
|
+
// src/EVMAdapter.ts
|
|
6
|
+
var EVMAdapter = class {
|
|
7
|
+
/**
|
|
8
|
+
* Create a new EVM blockchain adapter
|
|
9
|
+
* @param config - Chain configuration
|
|
10
|
+
*/
|
|
11
|
+
constructor(config) {
|
|
12
|
+
this.chainData = config.chainData;
|
|
13
|
+
this.coinType = config.coinType ?? 60;
|
|
14
|
+
this.standardDecimals = config.standardDecimals ?? 18;
|
|
15
|
+
this.account = config.account ?? 0;
|
|
16
|
+
this.change = config.change ?? 0;
|
|
17
|
+
this.addressIndex = config.addressIndex ?? 0;
|
|
18
|
+
this.networks = {};
|
|
19
|
+
if (config.networks) {
|
|
20
|
+
for (const [key, value] of Object.entries(config.networks)) {
|
|
21
|
+
if (value !== void 0) {
|
|
22
|
+
this.networks[key] = value;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
this.currentNetworkKey = config.defaultNetwork ?? "mainnet";
|
|
27
|
+
this.dataProvider = config.dataProvider;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Get the BIP44 derivation path for this chain
|
|
31
|
+
* @returns BIP44 path string (e.g., "m/44'/60'/0'/0/0")
|
|
32
|
+
*/
|
|
33
|
+
getDerivationPath() {
|
|
34
|
+
return `m/44'/${this.coinType}'/${this.account}'/${this.change}/${this.addressIndex}`;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Get the current network configuration
|
|
38
|
+
* @returns Current network configuration, or undefined if no network is configured
|
|
39
|
+
*/
|
|
40
|
+
getNetwork() {
|
|
41
|
+
return this.networks[this.currentNetworkKey];
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Get the current network key (e.g., 'mainnet', 'testnet')
|
|
45
|
+
* @returns Current network key
|
|
46
|
+
*/
|
|
47
|
+
getCurrentNetworkKey() {
|
|
48
|
+
return this.currentNetworkKey;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Set the active network
|
|
52
|
+
* @param networkKey - Network key to switch to (e.g., 'mainnet', 'testnet')
|
|
53
|
+
* @throws Error if the network key doesn't exist
|
|
54
|
+
*/
|
|
55
|
+
setNetwork(networkKey) {
|
|
56
|
+
if (!this.networks[networkKey]) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
`Network "${networkKey}" is not configured for ${this.chainData.name}. Available networks: ${Object.keys(this.networks).join(", ")}`
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
this.currentNetworkKey = networkKey;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Get all available network keys
|
|
65
|
+
* @returns Array of network keys (e.g., ['mainnet', 'testnet'])
|
|
66
|
+
*/
|
|
67
|
+
getAvailableNetworks() {
|
|
68
|
+
return Object.keys(this.networks);
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Check if a network is configured
|
|
72
|
+
* @param networkKey - Network key to check
|
|
73
|
+
* @returns True if the network exists
|
|
74
|
+
*/
|
|
75
|
+
hasNetwork(networkKey) {
|
|
76
|
+
return networkKey in this.networks;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Derive EVM keys from a BIP39 mnemonic.
|
|
80
|
+
* Uses the BIP44 derivation path specified in the configuration.
|
|
81
|
+
*
|
|
82
|
+
* @param mnemonic - BIP39 mnemonic phrase
|
|
83
|
+
* @returns KeyData with derived keys and address
|
|
84
|
+
*/
|
|
85
|
+
async deriveKeys(mnemonic) {
|
|
86
|
+
if (!ethers.ethers.Mnemonic.isValidMnemonic(mnemonic)) {
|
|
87
|
+
throw new Error("Invalid mnemonic phrase");
|
|
88
|
+
}
|
|
89
|
+
const path = this.getDerivationPath();
|
|
90
|
+
const hdNode = ethers.ethers.HDNodeWallet.fromPhrase(mnemonic, void 0, path);
|
|
91
|
+
const privateKeyHex = hdNode.privateKey;
|
|
92
|
+
const privateKeyBuffer = this.hexToBytes(privateKeyHex.slice(2));
|
|
93
|
+
const publicKeyHex = hdNode.publicKey;
|
|
94
|
+
const publicKeyBuffer = this.hexToBytes(publicKeyHex.slice(2));
|
|
95
|
+
const address = hdNode.address;
|
|
96
|
+
const seed = this.hexToBytes(ethers.ethers.id(mnemonic).slice(2));
|
|
97
|
+
return {
|
|
98
|
+
mnemonic,
|
|
99
|
+
seed,
|
|
100
|
+
privateKey: privateKeyBuffer,
|
|
101
|
+
privateKeyHex: privateKeyHex.slice(2),
|
|
102
|
+
// Without 0x prefix
|
|
103
|
+
publicKey: publicKeyBuffer,
|
|
104
|
+
publicKeyHex: publicKeyHex.slice(2),
|
|
105
|
+
// Without 0x prefix
|
|
106
|
+
address,
|
|
107
|
+
path
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Derive keys at a specific address index
|
|
112
|
+
* @param mnemonic - BIP39 mnemonic phrase
|
|
113
|
+
* @param addressIndex - Address index in the BIP44 path
|
|
114
|
+
* @returns KeyData with derived keys
|
|
115
|
+
*/
|
|
116
|
+
async deriveKeysAtIndex(mnemonic, addressIndex) {
|
|
117
|
+
if (!ethers.ethers.Mnemonic.isValidMnemonic(mnemonic)) {
|
|
118
|
+
throw new Error("Invalid mnemonic phrase");
|
|
119
|
+
}
|
|
120
|
+
const path = `m/44'/${this.coinType}'/${this.account}'/${this.change}/${addressIndex}`;
|
|
121
|
+
const hdNode = ethers.ethers.HDNodeWallet.fromPhrase(mnemonic, void 0, path);
|
|
122
|
+
const privateKeyHex = hdNode.privateKey;
|
|
123
|
+
const privateKeyBuffer = this.hexToBytes(privateKeyHex.slice(2));
|
|
124
|
+
const publicKeyHex = hdNode.publicKey;
|
|
125
|
+
const publicKeyBuffer = this.hexToBytes(publicKeyHex.slice(2));
|
|
126
|
+
const address = hdNode.address;
|
|
127
|
+
const seed = this.hexToBytes(ethers.ethers.id(mnemonic).slice(2));
|
|
128
|
+
return {
|
|
129
|
+
mnemonic,
|
|
130
|
+
seed,
|
|
131
|
+
privateKey: privateKeyBuffer,
|
|
132
|
+
privateKeyHex: privateKeyHex.slice(2),
|
|
133
|
+
// Without 0x prefix
|
|
134
|
+
publicKey: publicKeyBuffer,
|
|
135
|
+
publicKeyHex: publicKeyHex.slice(2),
|
|
136
|
+
// Without 0x prefix
|
|
137
|
+
address,
|
|
138
|
+
path
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Derive keys at a custom derivation path
|
|
143
|
+
* @param mnemonic - BIP39 mnemonic phrase
|
|
144
|
+
* @param path - Full BIP44 derivation path
|
|
145
|
+
* @returns KeyData with derived keys
|
|
146
|
+
*/
|
|
147
|
+
async deriveKeysAtPath(mnemonic, path) {
|
|
148
|
+
if (!ethers.ethers.Mnemonic.isValidMnemonic(mnemonic)) {
|
|
149
|
+
throw new Error("Invalid mnemonic phrase");
|
|
150
|
+
}
|
|
151
|
+
const hdNode = ethers.ethers.HDNodeWallet.fromPhrase(mnemonic, void 0, path);
|
|
152
|
+
const privateKeyHex = hdNode.privateKey;
|
|
153
|
+
const privateKeyBuffer = this.hexToBytes(privateKeyHex.slice(2));
|
|
154
|
+
const publicKeyHex = hdNode.publicKey;
|
|
155
|
+
const publicKeyBuffer = this.hexToBytes(publicKeyHex.slice(2));
|
|
156
|
+
const address = hdNode.address;
|
|
157
|
+
const seed = this.hexToBytes(ethers.ethers.id(mnemonic).slice(2));
|
|
158
|
+
return {
|
|
159
|
+
mnemonic,
|
|
160
|
+
seed,
|
|
161
|
+
privateKey: privateKeyBuffer,
|
|
162
|
+
privateKeyHex: privateKeyHex.slice(2),
|
|
163
|
+
// Without 0x prefix
|
|
164
|
+
publicKey: publicKeyBuffer,
|
|
165
|
+
publicKeyHex: publicKeyHex.slice(2),
|
|
166
|
+
// Without 0x prefix
|
|
167
|
+
address,
|
|
168
|
+
path
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Get address at a specific index (read-only, no private key)
|
|
173
|
+
* @param mnemonic - BIP39 mnemonic phrase
|
|
174
|
+
* @param index - Address index
|
|
175
|
+
* @returns Address and public key information
|
|
176
|
+
*/
|
|
177
|
+
async getAddressAtIndex(mnemonic, index) {
|
|
178
|
+
if (!ethers.ethers.Mnemonic.isValidMnemonic(mnemonic)) {
|
|
179
|
+
throw new Error("Invalid mnemonic phrase");
|
|
180
|
+
}
|
|
181
|
+
const path = `m/44'/${this.coinType}'/${this.account}'/${this.change}/${index}`;
|
|
182
|
+
const hdNode = ethers.ethers.HDNodeWallet.fromPhrase(mnemonic, void 0, path);
|
|
183
|
+
return {
|
|
184
|
+
address: hdNode.address,
|
|
185
|
+
publicKey: hdNode.publicKey.slice(2),
|
|
186
|
+
// Without 0x prefix
|
|
187
|
+
path
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Get multiple addresses starting from an index
|
|
192
|
+
* @param mnemonic - BIP39 mnemonic phrase
|
|
193
|
+
* @param startIndex - Starting address index
|
|
194
|
+
* @param count - Number of addresses to generate
|
|
195
|
+
* @returns Array of addresses with metadata
|
|
196
|
+
*/
|
|
197
|
+
async getAddresses(mnemonic, startIndex, count) {
|
|
198
|
+
if (!ethers.ethers.Mnemonic.isValidMnemonic(mnemonic)) {
|
|
199
|
+
throw new Error("Invalid mnemonic phrase");
|
|
200
|
+
}
|
|
201
|
+
const addresses = [];
|
|
202
|
+
for (let i = 0; i < count; i++) {
|
|
203
|
+
const index = startIndex + i;
|
|
204
|
+
const path = `m/44'/${this.coinType}'/${this.account}'/${this.change}/${index}`;
|
|
205
|
+
const hdNode = ethers.ethers.HDNodeWallet.fromPhrase(mnemonic, void 0, path);
|
|
206
|
+
addresses.push({
|
|
207
|
+
address: hdNode.address,
|
|
208
|
+
path,
|
|
209
|
+
index
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
return addresses;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Sign a message with a private key
|
|
216
|
+
* @param privateKey - Private key as Uint8Array
|
|
217
|
+
* @param message - Message to sign (string or Uint8Array)
|
|
218
|
+
* @returns Signature
|
|
219
|
+
*/
|
|
220
|
+
async signMessage(privateKey, message) {
|
|
221
|
+
const privateKeyHex = "0x" + this.bytesToHex(privateKey);
|
|
222
|
+
const wallet = new ethers.ethers.Wallet(privateKeyHex);
|
|
223
|
+
const messageStr = typeof message === "string" ? message : new TextDecoder().decode(message);
|
|
224
|
+
const signature = await wallet.signMessage(messageStr);
|
|
225
|
+
return { signature };
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Sign a transaction with a private key
|
|
229
|
+
* @param privateKey - Private key as Uint8Array
|
|
230
|
+
* @param transaction - Chain-specific transaction object
|
|
231
|
+
* @returns Signed transaction and signature
|
|
232
|
+
*/
|
|
233
|
+
async signTransaction(privateKey, transaction) {
|
|
234
|
+
const privateKeyHex = "0x" + this.bytesToHex(privateKey);
|
|
235
|
+
const wallet = new ethers.ethers.Wallet(privateKeyHex);
|
|
236
|
+
const signedTransaction = await wallet.signTransaction(transaction);
|
|
237
|
+
const tx = ethers.ethers.Transaction.from(signedTransaction);
|
|
238
|
+
const signature = tx.signature ? ethers.ethers.Signature.from(tx.signature).serialized : "";
|
|
239
|
+
return {
|
|
240
|
+
signedTransaction,
|
|
241
|
+
signature
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Verify a signature against a public key
|
|
246
|
+
* @param message - Original message
|
|
247
|
+
* @param signature - Signature to verify
|
|
248
|
+
* @param publicKey - Public key (hex string)
|
|
249
|
+
* @returns True if signature is valid
|
|
250
|
+
*/
|
|
251
|
+
async verifySignature(message, signature, publicKey) {
|
|
252
|
+
try {
|
|
253
|
+
const messageStr = typeof message === "string" ? message : new TextDecoder().decode(message);
|
|
254
|
+
const recoveredAddress = ethers.ethers.verifyMessage(messageStr, signature);
|
|
255
|
+
const publicKeyWithPrefix = publicKey.startsWith("0x") ? publicKey : "0x" + publicKey;
|
|
256
|
+
const computedAddress = ethers.ethers.computeAddress(publicKeyWithPrefix);
|
|
257
|
+
return recoveredAddress.toLowerCase() === computedAddress.toLowerCase();
|
|
258
|
+
} catch (error) {
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Set or update the data provider
|
|
264
|
+
* @param provider - The blockchain data provider to use
|
|
265
|
+
*/
|
|
266
|
+
setDataProvider(provider) {
|
|
267
|
+
this.dataProvider = provider;
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Get the current data provider
|
|
271
|
+
* @returns The current data provider, or undefined if not set
|
|
272
|
+
*/
|
|
273
|
+
getDataProvider() {
|
|
274
|
+
return this.dataProvider;
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Ensure a data provider is configured
|
|
278
|
+
* @throws Error if no data provider is set
|
|
279
|
+
*/
|
|
280
|
+
ensureProvider() {
|
|
281
|
+
if (!this.dataProvider) {
|
|
282
|
+
throw new Error(
|
|
283
|
+
`No data provider configured for ${this.chainData.name}. Please provide a BlockchainDataProvider (e.g., AlchemyProvider) in the adapter configuration.`
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Get native token balance for an address
|
|
289
|
+
* @param address - Wallet address
|
|
290
|
+
* @returns TokenBalance object with native token information
|
|
291
|
+
* @throws Error if no data provider is configured
|
|
292
|
+
*/
|
|
293
|
+
async getNativeBalance(address) {
|
|
294
|
+
this.ensureProvider();
|
|
295
|
+
return this.dataProvider.getNativeBalance(address);
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Get all ERC-20 token balances for an address
|
|
299
|
+
* @param address - Wallet address
|
|
300
|
+
* @returns Array of token balances with metadata
|
|
301
|
+
* @throws Error if no data provider is configured
|
|
302
|
+
*/
|
|
303
|
+
async getTokenBalances(address) {
|
|
304
|
+
this.ensureProvider();
|
|
305
|
+
return this.dataProvider.getTokenBalances(address);
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Get balance for a specific ERC-20 token
|
|
309
|
+
* @param address - Wallet address
|
|
310
|
+
* @param tokenAddress - Token contract address
|
|
311
|
+
* @returns Balance in smallest unit as string
|
|
312
|
+
* @throws Error if no data provider is configured
|
|
313
|
+
*/
|
|
314
|
+
async getTokenBalance(address, tokenAddress) {
|
|
315
|
+
this.ensureProvider();
|
|
316
|
+
return this.dataProvider.getTokenBalance(address, tokenAddress);
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Get all transactions for an address
|
|
320
|
+
* @param address - Wallet address
|
|
321
|
+
* @param options - Optional filtering and pagination options
|
|
322
|
+
* @returns Array of transactions
|
|
323
|
+
* @throws Error if no data provider is configured
|
|
324
|
+
*/
|
|
325
|
+
async getTransactions(address, options) {
|
|
326
|
+
this.ensureProvider();
|
|
327
|
+
return this.dataProvider.getTransactions(address, options);
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Get all ERC-20 token transfers for an address
|
|
331
|
+
* @param address - Wallet address
|
|
332
|
+
* @param options - Optional filtering and pagination options
|
|
333
|
+
* @returns Array of token transfers
|
|
334
|
+
* @throws Error if no data provider is configured
|
|
335
|
+
*/
|
|
336
|
+
async getTokenTransfers(address, options) {
|
|
337
|
+
this.ensureProvider();
|
|
338
|
+
return this.dataProvider.getTokenTransfers(address, options);
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Validate if an address is a valid ERC-20 token contract
|
|
342
|
+
* @param tokenAddress - Token contract address
|
|
343
|
+
* @returns True if valid ERC-20, false otherwise
|
|
344
|
+
*/
|
|
345
|
+
async validateTokenContract(tokenAddress) {
|
|
346
|
+
try {
|
|
347
|
+
const network = this.getNetwork();
|
|
348
|
+
if (!network) {
|
|
349
|
+
throw new Error(`No network configured for ${this.chainData.name}`);
|
|
350
|
+
}
|
|
351
|
+
const provider = new ethers.ethers.JsonRpcProvider(network.rpcUrl);
|
|
352
|
+
const contract = new ethers.ethers.Contract(
|
|
353
|
+
tokenAddress,
|
|
354
|
+
["function symbol() view returns (string)"],
|
|
355
|
+
provider
|
|
356
|
+
);
|
|
357
|
+
const symbol = await contract.symbol();
|
|
358
|
+
return typeof symbol === "string" && symbol.length > 0;
|
|
359
|
+
} catch (error) {
|
|
360
|
+
return false;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Send native tokens (ETH, S, etc.)
|
|
365
|
+
* @param params - Transaction parameters including private key
|
|
366
|
+
* @returns Transaction receipt
|
|
367
|
+
*/
|
|
368
|
+
async sendNativeToken(params) {
|
|
369
|
+
try {
|
|
370
|
+
const network = this.getNetwork();
|
|
371
|
+
if (!network) {
|
|
372
|
+
throw new Error(`No network configured for ${this.chainData.name}`);
|
|
373
|
+
}
|
|
374
|
+
const privateKeyHex = "0x" + this.bytesToHex(params.privateKey);
|
|
375
|
+
const wallet = new ethers.ethers.Wallet(privateKeyHex);
|
|
376
|
+
const provider = new ethers.ethers.JsonRpcProvider(network.rpcUrl);
|
|
377
|
+
const connectedSigner = wallet.connect(provider);
|
|
378
|
+
const tx = await connectedSigner.sendTransaction({
|
|
379
|
+
to: params.to,
|
|
380
|
+
value: params.amount
|
|
381
|
+
});
|
|
382
|
+
return {
|
|
383
|
+
hash: tx.hash,
|
|
384
|
+
from: tx.from,
|
|
385
|
+
to: tx.to,
|
|
386
|
+
value: tx.value.toString(),
|
|
387
|
+
chainId: network.chainId,
|
|
388
|
+
status: "pending",
|
|
389
|
+
nonce: tx.nonce
|
|
390
|
+
};
|
|
391
|
+
} catch (error) {
|
|
392
|
+
throw new Error(
|
|
393
|
+
`Failed to send native token: ${error instanceof Error ? error.message : String(error)}`
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Send ERC-20 tokens
|
|
399
|
+
* Private key must be obtained from AuthClient after password verification
|
|
400
|
+
* @param params - Transaction parameters including private key and token address
|
|
401
|
+
* @returns Transaction receipt
|
|
402
|
+
*/
|
|
403
|
+
async sendToken(params) {
|
|
404
|
+
try {
|
|
405
|
+
const isValidToken = await this.validateTokenContract(
|
|
406
|
+
params.tokenAddress
|
|
407
|
+
);
|
|
408
|
+
if (!isValidToken) {
|
|
409
|
+
throw new Error(
|
|
410
|
+
`Invalid ERC-20 token contract: ${params.tokenAddress}`
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
const network = this.getNetwork();
|
|
414
|
+
if (!network) {
|
|
415
|
+
throw new Error(`No network configured for ${this.chainData.name}`);
|
|
416
|
+
}
|
|
417
|
+
const privateKeyHex = "0x" + this.bytesToHex(params.privateKey);
|
|
418
|
+
const wallet = new ethers.ethers.Wallet(privateKeyHex);
|
|
419
|
+
const provider = new ethers.ethers.JsonRpcProvider(network.rpcUrl);
|
|
420
|
+
const connectedSigner = wallet.connect(provider);
|
|
421
|
+
const tokenContract = new ethers.ethers.Contract(
|
|
422
|
+
params.tokenAddress,
|
|
423
|
+
[
|
|
424
|
+
"function transfer(address to, uint256 amount) returns (bool)",
|
|
425
|
+
"function decimals() view returns (uint8)",
|
|
426
|
+
"function symbol() view returns (string)"
|
|
427
|
+
],
|
|
428
|
+
connectedSigner
|
|
429
|
+
);
|
|
430
|
+
const tx = await tokenContract.transfer(params.to, params.amount);
|
|
431
|
+
return {
|
|
432
|
+
hash: tx.hash,
|
|
433
|
+
from: tx.from,
|
|
434
|
+
to: params.tokenAddress,
|
|
435
|
+
// For token transfers, 'to' is the contract address
|
|
436
|
+
value: "0",
|
|
437
|
+
// Native value is 0 for token transfers
|
|
438
|
+
chainId: network.chainId,
|
|
439
|
+
status: "pending",
|
|
440
|
+
nonce: tx.nonce
|
|
441
|
+
};
|
|
442
|
+
} catch (error) {
|
|
443
|
+
throw new Error(
|
|
444
|
+
`Failed to send token: ${error instanceof Error ? error.message : String(error)}`
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Wait for transaction confirmation and return detailed status
|
|
450
|
+
* @param hash - Transaction hash
|
|
451
|
+
* @returns Transaction status update
|
|
452
|
+
*/
|
|
453
|
+
async getTransactionStatus(hash) {
|
|
454
|
+
try {
|
|
455
|
+
const network = this.getNetwork();
|
|
456
|
+
if (!network) {
|
|
457
|
+
throw new Error(`No network configured for ${this.chainData.name}`);
|
|
458
|
+
}
|
|
459
|
+
const provider = new ethers.ethers.JsonRpcProvider(network.rpcUrl);
|
|
460
|
+
const receipt = await provider.getTransactionReceipt(hash);
|
|
461
|
+
if (!receipt) {
|
|
462
|
+
return {
|
|
463
|
+
hash,
|
|
464
|
+
status: "pending"
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
const currentBlock = await provider.getBlockNumber();
|
|
468
|
+
const confirmations = currentBlock - receipt.blockNumber + 1;
|
|
469
|
+
return {
|
|
470
|
+
hash,
|
|
471
|
+
status: receipt.status === 1 ? "confirmed" : "failed",
|
|
472
|
+
blockNumber: receipt.blockNumber,
|
|
473
|
+
confirmations,
|
|
474
|
+
gasUsed: receipt.gasUsed.toString(),
|
|
475
|
+
error: receipt.status === 0 ? "Transaction failed" : void 0
|
|
476
|
+
};
|
|
477
|
+
} catch (error) {
|
|
478
|
+
throw new Error(
|
|
479
|
+
`Failed to get transaction status: ${error instanceof Error ? error.message : String(error)}`
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
// Helper methods for byte/hex conversion
|
|
484
|
+
bytesToHex(bytes) {
|
|
485
|
+
return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
486
|
+
}
|
|
487
|
+
hexToBytes(hex) {
|
|
488
|
+
const bytes = new Uint8Array(hex.length / 2);
|
|
489
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
490
|
+
bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
491
|
+
}
|
|
492
|
+
return bytes;
|
|
493
|
+
}
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
// src/utils/formatBalance.ts
|
|
497
|
+
var formatBalance = (balance, decimals) => {
|
|
498
|
+
const divisor = Math.pow(10, decimals);
|
|
499
|
+
const balanceNum = parseFloat(balance) / divisor;
|
|
500
|
+
return balanceNum.toLocaleString("en-US", {
|
|
501
|
+
minimumFractionDigits: 0,
|
|
502
|
+
maximumFractionDigits: decimals,
|
|
503
|
+
useGrouping: false
|
|
504
|
+
});
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
// src/providers/AlchemyProvider.ts
|
|
508
|
+
var AlchemyProvider = class {
|
|
509
|
+
constructor(config) {
|
|
510
|
+
this.apiKey = config.apiKey;
|
|
511
|
+
this.network = config.network;
|
|
512
|
+
this.provider = new ethers.ethers.JsonRpcProvider(config.rpcUrl);
|
|
513
|
+
this.nativeTokenSymbol = config.nativeTokenSymbol;
|
|
514
|
+
this.nativeTokenName = config.nativeTokenName;
|
|
515
|
+
this.nativeTokenDecimals = config.nativeTokenDecimals ?? 18;
|
|
516
|
+
this.cmc_id = config.cmc_id;
|
|
517
|
+
console.log(`\u2705 AlchemyProvider initialized for ${config.network}`);
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Get native token balance (e.g., ETH, S) for an address using RPC
|
|
521
|
+
*/
|
|
522
|
+
async getNativeBalance(address) {
|
|
523
|
+
try {
|
|
524
|
+
const balance = await this.provider.getBalance(address);
|
|
525
|
+
const balanceStr = balance.toString();
|
|
526
|
+
return {
|
|
527
|
+
contractAddress: "native",
|
|
528
|
+
// Native token doesn't have a contract address
|
|
529
|
+
balance: balanceStr,
|
|
530
|
+
balanceFormatted: formatBalance(balanceStr, this.nativeTokenDecimals),
|
|
531
|
+
symbol: this.nativeTokenSymbol,
|
|
532
|
+
name: this.nativeTokenName,
|
|
533
|
+
decimals: this.nativeTokenDecimals,
|
|
534
|
+
logo: `https://s2.coinmarketcap.com/static/img/coins/128x128/${this.cmc_id}.png`,
|
|
535
|
+
isNFT: false
|
|
536
|
+
};
|
|
537
|
+
} catch (error) {
|
|
538
|
+
throw new Error(
|
|
539
|
+
`Failed to fetch native balance: ${error instanceof Error ? error.message : String(error)}`
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* Get all ERC-20 token balances for an address using Alchemy Portfolio API
|
|
545
|
+
*/
|
|
546
|
+
async getTokenBalances(address) {
|
|
547
|
+
try {
|
|
548
|
+
const url = `https://api.g.alchemy.com/data/v1/${this.apiKey}/assets/tokens/by-address`;
|
|
549
|
+
const response = await fetch(url, {
|
|
550
|
+
method: "POST",
|
|
551
|
+
headers: { "Content-Type": "application/json" },
|
|
552
|
+
body: JSON.stringify({
|
|
553
|
+
addresses: [
|
|
554
|
+
{
|
|
555
|
+
address,
|
|
556
|
+
networks: [this.network]
|
|
557
|
+
}
|
|
558
|
+
],
|
|
559
|
+
withMetadata: true,
|
|
560
|
+
includeNativeTokens: false,
|
|
561
|
+
// We get native balance separately
|
|
562
|
+
includeErc20Tokens: true
|
|
563
|
+
})
|
|
564
|
+
});
|
|
565
|
+
if (!response.ok) {
|
|
566
|
+
const errorText = await response.text();
|
|
567
|
+
throw new Error(
|
|
568
|
+
`Portfolio API request failed: ${response.status} ${errorText}`
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
const data = await response.json();
|
|
572
|
+
const tokens = data.data?.tokens || [];
|
|
573
|
+
const tokenBalances = [];
|
|
574
|
+
for (const token of tokens) {
|
|
575
|
+
if (token.error) {
|
|
576
|
+
console.warn(
|
|
577
|
+
`Error fetching token ${token.tokenAddress}:`,
|
|
578
|
+
token.error
|
|
579
|
+
);
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
let balance = token.tokenBalance || "0";
|
|
583
|
+
if (balance.startsWith("0x")) {
|
|
584
|
+
balance = BigInt(balance).toString();
|
|
585
|
+
}
|
|
586
|
+
if (balance === "0") {
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
589
|
+
const decimals = token.tokenMetadata?.decimals ?? 18;
|
|
590
|
+
tokenBalances.push({
|
|
591
|
+
contractAddress: token.tokenAddress,
|
|
592
|
+
balance,
|
|
593
|
+
balanceFormatted: formatBalance(balance, decimals),
|
|
594
|
+
symbol: token.tokenMetadata?.symbol || "UNKNOWN",
|
|
595
|
+
name: token.tokenMetadata?.name || "Unknown Token",
|
|
596
|
+
decimals,
|
|
597
|
+
logo: token.tokenMetadata?.logo,
|
|
598
|
+
// This endpoint returns ERC-20 tokens only, not NFTs
|
|
599
|
+
isNFT: false
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
return tokenBalances;
|
|
603
|
+
} catch (error) {
|
|
604
|
+
throw new Error(
|
|
605
|
+
`Failed to fetch token balances: ${error instanceof Error ? error.message : String(error)}`
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* Get balance for a specific ERC-20 token using RPC
|
|
611
|
+
*/
|
|
612
|
+
async getTokenBalance(address, tokenAddress) {
|
|
613
|
+
try {
|
|
614
|
+
const contract = new ethers.ethers.Contract(
|
|
615
|
+
tokenAddress,
|
|
616
|
+
["function balanceOf(address) view returns (uint256)"],
|
|
617
|
+
this.provider
|
|
618
|
+
);
|
|
619
|
+
const balance = await contract.balanceOf(address);
|
|
620
|
+
return balance.toString();
|
|
621
|
+
} catch (error) {
|
|
622
|
+
throw new Error(
|
|
623
|
+
`Failed to fetch token balance: ${error instanceof Error ? error.message : String(error)}`
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* Get all transactions for an address using Alchemy's getAssetTransfers API
|
|
629
|
+
*/
|
|
630
|
+
async getTransactions(address, options = {}) {
|
|
631
|
+
try {
|
|
632
|
+
const transactions = [];
|
|
633
|
+
const sentTransfers = await this.fetchAssetTransfers({
|
|
634
|
+
fromAddress: address,
|
|
635
|
+
category: ["external", "internal"],
|
|
636
|
+
order: options.sort === "asc" ? "asc" : "desc",
|
|
637
|
+
maxCount: options.offset ? `0x${options.offset.toString(16)}` : "0x64",
|
|
638
|
+
// Default 100
|
|
639
|
+
withMetadata: true
|
|
640
|
+
});
|
|
641
|
+
const receivedTransfers = await this.fetchAssetTransfers({
|
|
642
|
+
toAddress: address,
|
|
643
|
+
category: ["external", "internal"],
|
|
644
|
+
order: options.sort === "asc" ? "asc" : "desc",
|
|
645
|
+
maxCount: options.offset ? `0x${options.offset.toString(16)}` : "0x64",
|
|
646
|
+
withMetadata: true
|
|
647
|
+
});
|
|
648
|
+
const allTransfers = [
|
|
649
|
+
...sentTransfers.transfers,
|
|
650
|
+
...receivedTransfers.transfers
|
|
651
|
+
];
|
|
652
|
+
const uniqueHashes = /* @__PURE__ */ new Set();
|
|
653
|
+
for (const transfer of allTransfers) {
|
|
654
|
+
if (uniqueHashes.has(transfer.hash)) continue;
|
|
655
|
+
uniqueHashes.add(transfer.hash);
|
|
656
|
+
transactions.push({
|
|
657
|
+
hash: transfer.hash,
|
|
658
|
+
from: transfer.from,
|
|
659
|
+
to: transfer.to || "",
|
|
660
|
+
value: transfer.value ? transfer.value.toString() : "0",
|
|
661
|
+
timestamp: transfer.metadata?.blockTimestamp ? new Date(transfer.metadata.blockTimestamp).getTime() / 1e3 : 0,
|
|
662
|
+
blockNumber: parseInt(transfer.blockNum, 16),
|
|
663
|
+
gasUsed: "0",
|
|
664
|
+
// Not provided by getAssetTransfers
|
|
665
|
+
gasPrice: "0",
|
|
666
|
+
// Not provided by getAssetTransfers
|
|
667
|
+
status: "success",
|
|
668
|
+
// Transfers API only returns successful transactions
|
|
669
|
+
isError: false
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
transactions.sort(
|
|
673
|
+
(a, b) => options.sort === "asc" ? a.blockNumber - b.blockNumber : b.blockNumber - a.blockNumber
|
|
674
|
+
);
|
|
675
|
+
return transactions;
|
|
676
|
+
} catch (error) {
|
|
677
|
+
throw new Error(
|
|
678
|
+
`Failed to fetch transactions: ${error instanceof Error ? error.message : String(error)}`
|
|
679
|
+
);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* Get all ERC-20 token transfers for an address using Alchemy's getAssetTransfers API
|
|
684
|
+
*/
|
|
685
|
+
async getTokenTransfers(address, options = {}) {
|
|
686
|
+
try {
|
|
687
|
+
const tokenTransfers = [];
|
|
688
|
+
const sentTransfers = await this.fetchAssetTransfers({
|
|
689
|
+
fromAddress: address,
|
|
690
|
+
category: ["erc20"],
|
|
691
|
+
order: options.sort === "asc" ? "asc" : "desc",
|
|
692
|
+
maxCount: options.offset ? `0x${options.offset.toString(16)}` : "0x64",
|
|
693
|
+
withMetadata: true
|
|
694
|
+
});
|
|
695
|
+
const receivedTransfers = await this.fetchAssetTransfers({
|
|
696
|
+
toAddress: address,
|
|
697
|
+
category: ["erc20"],
|
|
698
|
+
order: options.sort === "asc" ? "asc" : "desc",
|
|
699
|
+
maxCount: options.offset ? `0x${options.offset.toString(16)}` : "0x64",
|
|
700
|
+
withMetadata: true
|
|
701
|
+
});
|
|
702
|
+
const allTransfers = [
|
|
703
|
+
...sentTransfers.transfers,
|
|
704
|
+
...receivedTransfers.transfers
|
|
705
|
+
];
|
|
706
|
+
const uniqueIds = /* @__PURE__ */ new Set();
|
|
707
|
+
for (const transfer of allTransfers) {
|
|
708
|
+
if (uniqueIds.has(transfer.uniqueId)) continue;
|
|
709
|
+
uniqueIds.add(transfer.uniqueId);
|
|
710
|
+
tokenTransfers.push({
|
|
711
|
+
hash: transfer.hash,
|
|
712
|
+
from: transfer.from,
|
|
713
|
+
to: transfer.to || "",
|
|
714
|
+
value: transfer.value ? transfer.value.toString() : "0",
|
|
715
|
+
timestamp: transfer.metadata?.blockTimestamp ? new Date(transfer.metadata.blockTimestamp).getTime() / 1e3 : 0,
|
|
716
|
+
blockNumber: parseInt(transfer.blockNum, 16),
|
|
717
|
+
tokenAddress: transfer.rawContract?.address || "",
|
|
718
|
+
tokenSymbol: transfer.asset || "UNKNOWN",
|
|
719
|
+
tokenName: transfer.asset || "Unknown Token",
|
|
720
|
+
tokenDecimal: transfer.rawContract?.decimal || "18"
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
tokenTransfers.sort(
|
|
724
|
+
(a, b) => options.sort === "asc" ? a.blockNumber - b.blockNumber : b.blockNumber - a.blockNumber
|
|
725
|
+
);
|
|
726
|
+
return tokenTransfers;
|
|
727
|
+
} catch (error) {
|
|
728
|
+
throw new Error(
|
|
729
|
+
`Failed to fetch token transfers: ${error instanceof Error ? error.message : String(error)}`
|
|
730
|
+
);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
/**
|
|
734
|
+
* Helper method to fetch asset transfers from Alchemy API
|
|
735
|
+
*/
|
|
736
|
+
async fetchAssetTransfers(params) {
|
|
737
|
+
const response = await fetch(this.provider._getConnection().url, {
|
|
738
|
+
method: "POST",
|
|
739
|
+
headers: { "Content-Type": "application/json" },
|
|
740
|
+
body: JSON.stringify({
|
|
741
|
+
jsonrpc: "2.0",
|
|
742
|
+
id: 1,
|
|
743
|
+
method: "alchemy_getAssetTransfers",
|
|
744
|
+
params: [
|
|
745
|
+
{
|
|
746
|
+
fromBlock: "0x0",
|
|
747
|
+
toBlock: "latest",
|
|
748
|
+
...params,
|
|
749
|
+
excludeZeroValue: true
|
|
750
|
+
}
|
|
751
|
+
]
|
|
752
|
+
})
|
|
753
|
+
});
|
|
754
|
+
if (!response.ok) {
|
|
755
|
+
const errorText = await response.text();
|
|
756
|
+
throw new Error(
|
|
757
|
+
`getAssetTransfers request failed: ${response.status} ${errorText}`
|
|
758
|
+
);
|
|
759
|
+
}
|
|
760
|
+
const data = await response.json();
|
|
761
|
+
if (data.error) {
|
|
762
|
+
throw new Error(`Alchemy API error: ${data.error.message}`);
|
|
763
|
+
}
|
|
764
|
+
return data.result || { transfers: [] };
|
|
765
|
+
}
|
|
766
|
+
};
|
|
767
|
+
|
|
768
|
+
// src/providers/EtherscanProvider.ts
|
|
769
|
+
var EtherscanProvider = class {
|
|
770
|
+
/**
|
|
771
|
+
* Create an Etherscan provider using API V2
|
|
772
|
+
* @param config - Provider configuration
|
|
773
|
+
*/
|
|
774
|
+
constructor(config) {
|
|
775
|
+
this.apiKey = config.apiKey;
|
|
776
|
+
this.chainId = config.chainId;
|
|
777
|
+
this.nativeTokenSymbol = config.nativeTokenSymbol;
|
|
778
|
+
this.nativeTokenName = config.nativeTokenName;
|
|
779
|
+
this.nativeTokenDecimals = config.nativeTokenDecimals ?? 18;
|
|
780
|
+
this.cmc_id = config.cmc_id;
|
|
781
|
+
this.baseUrl = config.baseUrl ?? "https://api.etherscan.io/v2/api";
|
|
782
|
+
}
|
|
783
|
+
/**
|
|
784
|
+
* Get native token balance for an address
|
|
785
|
+
* @param address - Wallet address
|
|
786
|
+
* @returns TokenBalance object with native token information
|
|
787
|
+
*/
|
|
788
|
+
async getNativeBalance(address) {
|
|
789
|
+
const params = new URLSearchParams({
|
|
790
|
+
chainid: this.chainId.toString(),
|
|
791
|
+
module: "account",
|
|
792
|
+
action: "balance",
|
|
793
|
+
address,
|
|
794
|
+
tag: "latest",
|
|
795
|
+
apikey: this.apiKey
|
|
796
|
+
});
|
|
797
|
+
const url = `${this.baseUrl}?${params.toString()}`;
|
|
798
|
+
const response = await this.fetchWithRetry(url);
|
|
799
|
+
const data = await response.json();
|
|
800
|
+
if (data.status !== "1") {
|
|
801
|
+
throw new Error(
|
|
802
|
+
`Etherscan API error: ${data.message} (result: ${data.result})`
|
|
803
|
+
);
|
|
804
|
+
}
|
|
805
|
+
return {
|
|
806
|
+
contractAddress: "native",
|
|
807
|
+
// Native token doesn't have a contract address
|
|
808
|
+
balance: data.result,
|
|
809
|
+
balanceFormatted: formatBalance(data.result, this.nativeTokenDecimals),
|
|
810
|
+
symbol: this.nativeTokenSymbol,
|
|
811
|
+
name: this.nativeTokenName,
|
|
812
|
+
decimals: this.nativeTokenDecimals,
|
|
813
|
+
logo: `https://s2.coinmarketcap.com/static/img/coins/128x128/${this.cmc_id}.png`,
|
|
814
|
+
isNFT: false
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
/**
|
|
818
|
+
* Get all ERC-20 token balances for an address
|
|
819
|
+
* @param address - Wallet address
|
|
820
|
+
* @returns Array of token balances with metadata
|
|
821
|
+
*
|
|
822
|
+
* Note: This endpoint prefers the Etherscan API Pro endpoint for better performance,
|
|
823
|
+
* but automatically falls back to a free-tier compatible approach if needed.
|
|
824
|
+
*/
|
|
825
|
+
async getTokenBalances(address) {
|
|
826
|
+
const params = new URLSearchParams({
|
|
827
|
+
chainid: this.chainId.toString(),
|
|
828
|
+
module: "account",
|
|
829
|
+
action: "addresstokenbalance",
|
|
830
|
+
address,
|
|
831
|
+
page: "1",
|
|
832
|
+
offset: "100",
|
|
833
|
+
apikey: this.apiKey
|
|
834
|
+
});
|
|
835
|
+
const url = `${this.baseUrl}?${params.toString()}`;
|
|
836
|
+
const response = await this.fetchWithRetry(url);
|
|
837
|
+
const data = await response.json();
|
|
838
|
+
if (data.status !== "1") {
|
|
839
|
+
if (data.result && typeof data.result === "string" && data.result.includes("API Pro")) {
|
|
840
|
+
return this.getTokenBalancesFallback(address);
|
|
841
|
+
}
|
|
842
|
+
if (data.result && typeof data.result === "string" && data.result.includes("rate limit")) {
|
|
843
|
+
console.warn("Rate limit hit, using fallback method with delays...");
|
|
844
|
+
await this.delay(1e3);
|
|
845
|
+
return this.getTokenBalancesFallback(address);
|
|
846
|
+
}
|
|
847
|
+
throw new Error(
|
|
848
|
+
`Etherscan API error: ${data.message} (result: ${data.result})`
|
|
849
|
+
);
|
|
850
|
+
}
|
|
851
|
+
if (!Array.isArray(data.result)) {
|
|
852
|
+
throw new Error("Unexpected API response format");
|
|
853
|
+
}
|
|
854
|
+
return data.result.map((token) => {
|
|
855
|
+
const decimals = parseInt(token.TokenDivisor, 10);
|
|
856
|
+
return {
|
|
857
|
+
contractAddress: token.TokenAddress,
|
|
858
|
+
balance: token.TokenQuantity,
|
|
859
|
+
balanceFormatted: formatBalance(token.TokenQuantity, decimals),
|
|
860
|
+
symbol: token.TokenSymbol,
|
|
861
|
+
name: token.TokenName,
|
|
862
|
+
decimals,
|
|
863
|
+
// This endpoint returns ERC-20 tokens only, not NFTs
|
|
864
|
+
isNFT: false
|
|
865
|
+
};
|
|
866
|
+
});
|
|
867
|
+
}
|
|
868
|
+
/**
|
|
869
|
+
* Free-tier fallback for getTokenBalances
|
|
870
|
+
* Discovers tokens via transfer history and fetches individual balances
|
|
871
|
+
* Rate-limited to respect Etherscan free tier limits (3 calls/sec)
|
|
872
|
+
* @param address - Wallet address
|
|
873
|
+
* @returns Array of token balances with metadata
|
|
874
|
+
*/
|
|
875
|
+
async getTokenBalancesFallback(address) {
|
|
876
|
+
const transfers = await this.getTokenTransfers(address, {
|
|
877
|
+
page: 1,
|
|
878
|
+
offset: 1e4
|
|
879
|
+
// Get more transfers to discover more tokens
|
|
880
|
+
});
|
|
881
|
+
if (transfers.length === 0) {
|
|
882
|
+
return [];
|
|
883
|
+
}
|
|
884
|
+
const uniqueTokens = /* @__PURE__ */ new Map();
|
|
885
|
+
for (const transfer of transfers) {
|
|
886
|
+
if (!uniqueTokens.has(transfer.tokenAddress)) {
|
|
887
|
+
uniqueTokens.set(transfer.tokenAddress, {
|
|
888
|
+
symbol: transfer.tokenSymbol,
|
|
889
|
+
name: transfer.tokenName,
|
|
890
|
+
decimals: parseInt(transfer.tokenDecimal, 10)
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
await this.delay(1e3);
|
|
895
|
+
const balances = [];
|
|
896
|
+
const batchSize = 2;
|
|
897
|
+
const delayMs = 1e3;
|
|
898
|
+
const tokenEntries = Array.from(uniqueTokens.entries());
|
|
899
|
+
for (let i = 0; i < tokenEntries.length; i++) {
|
|
900
|
+
const [contractAddress, metadata] = tokenEntries[i];
|
|
901
|
+
try {
|
|
902
|
+
const balance = await this.getTokenBalance(address, contractAddress);
|
|
903
|
+
if (balance !== "0") {
|
|
904
|
+
balances.push({
|
|
905
|
+
contractAddress,
|
|
906
|
+
balance,
|
|
907
|
+
balanceFormatted: formatBalance(balance, metadata.decimals),
|
|
908
|
+
symbol: metadata.symbol,
|
|
909
|
+
name: metadata.name,
|
|
910
|
+
decimals: metadata.decimals,
|
|
911
|
+
// This endpoint returns ERC-20 tokens only, not NFTs
|
|
912
|
+
isNFT: false
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
} catch (error) {
|
|
916
|
+
console.warn(
|
|
917
|
+
`Failed to fetch balance for token ${contractAddress}:`,
|
|
918
|
+
error
|
|
919
|
+
);
|
|
920
|
+
}
|
|
921
|
+
if ((i + 1) % batchSize === 0 && i + 1 < tokenEntries.length) {
|
|
922
|
+
await this.delay(delayMs);
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
return balances;
|
|
926
|
+
}
|
|
927
|
+
/**
|
|
928
|
+
* Helper method to add delay (for rate limiting)
|
|
929
|
+
* @param ms - Milliseconds to delay
|
|
930
|
+
*/
|
|
931
|
+
delay(ms) {
|
|
932
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
933
|
+
}
|
|
934
|
+
/**
|
|
935
|
+
* Wrapper for fetch with automatic retry on rate limit
|
|
936
|
+
* @param url - URL to fetch
|
|
937
|
+
* @param maxRetries - Maximum number of retries (default: 2)
|
|
938
|
+
* @returns Response
|
|
939
|
+
*/
|
|
940
|
+
async fetchWithRetry(url, maxRetries = 2) {
|
|
941
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
942
|
+
const response = await fetch(url);
|
|
943
|
+
if (!response.ok) {
|
|
944
|
+
if (response.status === 429) {
|
|
945
|
+
if (attempt < maxRetries) {
|
|
946
|
+
const delayMs = 1e3 * (attempt + 1);
|
|
947
|
+
console.warn(
|
|
948
|
+
`Rate limit hit (429), retrying in ${delayMs}ms... (attempt ${attempt + 1}/${maxRetries})`
|
|
949
|
+
);
|
|
950
|
+
await this.delay(delayMs);
|
|
951
|
+
continue;
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
throw new Error(
|
|
955
|
+
`HTTP error! status: ${response.status} ${response.statusText}`
|
|
956
|
+
);
|
|
957
|
+
}
|
|
958
|
+
return response;
|
|
959
|
+
}
|
|
960
|
+
throw new Error("Max retries exceeded");
|
|
961
|
+
}
|
|
962
|
+
/**
|
|
963
|
+
* Get balance for a specific ERC-20 token
|
|
964
|
+
* @param address - Wallet address
|
|
965
|
+
* @param tokenAddress - Token contract address
|
|
966
|
+
* @returns Balance in smallest unit as string
|
|
967
|
+
*/
|
|
968
|
+
async getTokenBalance(address, tokenAddress) {
|
|
969
|
+
const params = new URLSearchParams({
|
|
970
|
+
chainid: this.chainId.toString(),
|
|
971
|
+
module: "account",
|
|
972
|
+
action: "tokenbalance",
|
|
973
|
+
contractaddress: tokenAddress,
|
|
974
|
+
address,
|
|
975
|
+
tag: "latest",
|
|
976
|
+
apikey: this.apiKey
|
|
977
|
+
});
|
|
978
|
+
const url = `${this.baseUrl}?${params.toString()}`;
|
|
979
|
+
const response = await this.fetchWithRetry(url);
|
|
980
|
+
const data = await response.json();
|
|
981
|
+
if (data.status !== "1") {
|
|
982
|
+
throw new Error(
|
|
983
|
+
`Etherscan API error: ${data.message} (result: ${data.result})`
|
|
984
|
+
);
|
|
985
|
+
}
|
|
986
|
+
return data.result;
|
|
987
|
+
}
|
|
988
|
+
/**
|
|
989
|
+
* Get all transactions for an address
|
|
990
|
+
* @param address - Wallet address
|
|
991
|
+
* @param options - Optional filtering and pagination options
|
|
992
|
+
* @returns Array of transactions
|
|
993
|
+
*/
|
|
994
|
+
async getTransactions(address, options) {
|
|
995
|
+
const params = new URLSearchParams({
|
|
996
|
+
chainid: this.chainId.toString(),
|
|
997
|
+
module: "account",
|
|
998
|
+
action: "txlist",
|
|
999
|
+
address,
|
|
1000
|
+
startblock: (options?.startBlock ?? 0).toString(),
|
|
1001
|
+
endblock: (options?.endBlock ?? 99999999).toString(),
|
|
1002
|
+
page: (options?.page ?? 1).toString(),
|
|
1003
|
+
offset: (options?.offset ?? 100).toString(),
|
|
1004
|
+
sort: options?.sort ?? "desc",
|
|
1005
|
+
apikey: this.apiKey
|
|
1006
|
+
});
|
|
1007
|
+
const url = `${this.baseUrl}?${params.toString()}`;
|
|
1008
|
+
const response = await this.fetchWithRetry(url);
|
|
1009
|
+
const data = await response.json();
|
|
1010
|
+
if (data.status !== "1") {
|
|
1011
|
+
if (data.message === "No transactions found") {
|
|
1012
|
+
return [];
|
|
1013
|
+
}
|
|
1014
|
+
throw new Error(
|
|
1015
|
+
`Etherscan API error: ${data.message} (result: ${data.result})`
|
|
1016
|
+
);
|
|
1017
|
+
}
|
|
1018
|
+
return data.result.map((tx) => ({
|
|
1019
|
+
hash: tx.hash,
|
|
1020
|
+
from: tx.from,
|
|
1021
|
+
to: tx.to,
|
|
1022
|
+
value: tx.value,
|
|
1023
|
+
timestamp: parseInt(tx.timeStamp, 10),
|
|
1024
|
+
blockNumber: parseInt(tx.blockNumber, 10),
|
|
1025
|
+
gasUsed: tx.gasUsed,
|
|
1026
|
+
gasPrice: tx.gasPrice,
|
|
1027
|
+
status: tx.txreceipt_status === "1" ? "success" : tx.txreceipt_status === "0" ? "failed" : "pending",
|
|
1028
|
+
isError: tx.isError === "1"
|
|
1029
|
+
}));
|
|
1030
|
+
}
|
|
1031
|
+
/**
|
|
1032
|
+
* Get all ERC-20 token transfers for an address
|
|
1033
|
+
* @param address - Wallet address
|
|
1034
|
+
* @param options - Optional filtering and pagination options
|
|
1035
|
+
* @returns Array of token transfers
|
|
1036
|
+
*/
|
|
1037
|
+
async getTokenTransfers(address, options) {
|
|
1038
|
+
const params = new URLSearchParams({
|
|
1039
|
+
chainid: this.chainId.toString(),
|
|
1040
|
+
module: "account",
|
|
1041
|
+
action: "tokentx",
|
|
1042
|
+
address,
|
|
1043
|
+
startblock: (options?.startBlock ?? 0).toString(),
|
|
1044
|
+
endblock: (options?.endBlock ?? 99999999).toString(),
|
|
1045
|
+
page: (options?.page ?? 1).toString(),
|
|
1046
|
+
offset: (options?.offset ?? 100).toString(),
|
|
1047
|
+
sort: options?.sort ?? "desc",
|
|
1048
|
+
apikey: this.apiKey
|
|
1049
|
+
});
|
|
1050
|
+
const url = `${this.baseUrl}?${params.toString()}`;
|
|
1051
|
+
const response = await this.fetchWithRetry(url);
|
|
1052
|
+
const data = await response.json();
|
|
1053
|
+
if (data.status !== "1") {
|
|
1054
|
+
if (data.message === "No transactions found") {
|
|
1055
|
+
return [];
|
|
1056
|
+
}
|
|
1057
|
+
throw new Error(
|
|
1058
|
+
`Etherscan API error: ${data.message} (result: ${data.result})`
|
|
1059
|
+
);
|
|
1060
|
+
}
|
|
1061
|
+
return data.result.map((tx) => ({
|
|
1062
|
+
hash: tx.hash,
|
|
1063
|
+
from: tx.from,
|
|
1064
|
+
to: tx.to,
|
|
1065
|
+
value: tx.value,
|
|
1066
|
+
timestamp: parseInt(tx.timeStamp, 10),
|
|
1067
|
+
blockNumber: parseInt(tx.blockNumber, 10),
|
|
1068
|
+
tokenAddress: tx.contractAddress,
|
|
1069
|
+
tokenSymbol: tx.tokenSymbol,
|
|
1070
|
+
tokenName: tx.tokenName,
|
|
1071
|
+
tokenDecimal: tx.tokenDecimal
|
|
1072
|
+
}));
|
|
1073
|
+
}
|
|
1074
|
+
};
|
|
1075
|
+
|
|
1076
|
+
// src/chains/ethereum.ts
|
|
1077
|
+
var EthereumAdapter = new EVMAdapter({
|
|
1078
|
+
chainData: {
|
|
1079
|
+
name: "Ethereum",
|
|
1080
|
+
symbol: "ETH",
|
|
1081
|
+
tokenStandard: "ERC20",
|
|
1082
|
+
chainId: 60,
|
|
1083
|
+
cmc_id: 1027,
|
|
1084
|
+
// CoinMarketCap ID for Ethereum
|
|
1085
|
+
decimals: 18
|
|
1086
|
+
},
|
|
1087
|
+
coinType: 60,
|
|
1088
|
+
// Standard Ethereum BIP44 coin type
|
|
1089
|
+
standardDecimals: 18,
|
|
1090
|
+
networks: {
|
|
1091
|
+
mainnet: {
|
|
1092
|
+
name: "Ethereum Mainnet",
|
|
1093
|
+
rpcUrl: "https://mainnet.infura.io/v3/YOUR_INFURA_API_KEY",
|
|
1094
|
+
// Replace with your API key
|
|
1095
|
+
explorerUrl: "https://etherscan.io",
|
|
1096
|
+
chainId: 1
|
|
1097
|
+
},
|
|
1098
|
+
testnet: {
|
|
1099
|
+
name: "Ethereum Testnet (Sepolia)",
|
|
1100
|
+
rpcUrl: "https://sepolia.infura.io/v3/YOUR_INFURA_API_KEY",
|
|
1101
|
+
// Replace with your API key
|
|
1102
|
+
explorerUrl: "https://sepolia.etherscan.io",
|
|
1103
|
+
chainId: 11155111
|
|
1104
|
+
}
|
|
1105
|
+
},
|
|
1106
|
+
defaultNetwork: "mainnet"
|
|
1107
|
+
});
|
|
1108
|
+
|
|
1109
|
+
// src/chains/sonic.ts
|
|
1110
|
+
var SonicAdapter = new EVMAdapter({
|
|
1111
|
+
chainData: {
|
|
1112
|
+
name: "Sonic",
|
|
1113
|
+
symbol: "S",
|
|
1114
|
+
tokenStandard: "ERC20",
|
|
1115
|
+
chainId: 60,
|
|
1116
|
+
cmc_id: 32684,
|
|
1117
|
+
// CoinMarketCap ID for Sonic
|
|
1118
|
+
decimals: 18
|
|
1119
|
+
},
|
|
1120
|
+
coinType: 60,
|
|
1121
|
+
// Uses standard Ethereum coin type (EVM-compatible)
|
|
1122
|
+
standardDecimals: 18,
|
|
1123
|
+
networks: {
|
|
1124
|
+
mainnet: {
|
|
1125
|
+
name: "Sonic Mainnet",
|
|
1126
|
+
rpcUrl: "https://rpc.sonic.soniclabs.com",
|
|
1127
|
+
explorerUrl: "https://sonicscan.org",
|
|
1128
|
+
chainId: 146
|
|
1129
|
+
},
|
|
1130
|
+
testnet: {
|
|
1131
|
+
name: "Sonic Testnet",
|
|
1132
|
+
rpcUrl: "https://rpc.blaze.soniclabs.com",
|
|
1133
|
+
explorerUrl: "https://testnet.sonicscan.org",
|
|
1134
|
+
chainId: 57054
|
|
1135
|
+
}
|
|
1136
|
+
},
|
|
1137
|
+
defaultNetwork: "mainnet"
|
|
1138
|
+
});
|
|
1139
|
+
|
|
1140
|
+
exports.AlchemyProvider = AlchemyProvider;
|
|
1141
|
+
exports.EVMAdapter = EVMAdapter;
|
|
1142
|
+
exports.EthereumAdapter = EthereumAdapter;
|
|
1143
|
+
exports.EtherscanProvider = EtherscanProvider;
|
|
1144
|
+
exports.SonicAdapter = SonicAdapter;
|
|
1145
|
+
//# sourceMappingURL=index.js.map
|
|
1146
|
+
//# sourceMappingURL=index.js.map
|