@profullstack/coinpay 0.3.10 → 0.4.1

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/src/wallet.js ADDED
@@ -0,0 +1,1016 @@
1
+ /**
2
+ * Wallet Module for CoinPay SDK
3
+ *
4
+ * Client-side wallet management with server-side address registration.
5
+ * IMPORTANT: Mnemonic/seed phrases are NEVER sent to the server.
6
+ * Only public keys and signed proofs are transmitted.
7
+ */
8
+
9
+ import * as bip39 from '@scure/bip39';
10
+ import { wordlist } from '@scure/bip39/wordlists/english';
11
+ import { HDKey } from '@scure/bip32';
12
+ import { secp256k1 } from '@noble/curves/secp256k1';
13
+ import { sha256 } from '@noble/hashes/sha2.js';
14
+ import { ripemd160 } from '@noble/hashes/ripemd160.js';
15
+ import { keccak_256 } from '@noble/hashes/sha3.js';
16
+ import { ed25519 } from '@noble/curves/ed25519';
17
+ import { hmac } from '@noble/hashes/hmac.js';
18
+ import { sha512 } from '@noble/hashes/sha2.js';
19
+ import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
20
+
21
+ const DEFAULT_BASE_URL = 'https://coinpayportal.com/api';
22
+
23
+ /**
24
+ * Supported blockchain chains
25
+ */
26
+ export const WalletChain = {
27
+ BTC: 'BTC',
28
+ BCH: 'BCH',
29
+ ETH: 'ETH',
30
+ POL: 'POL',
31
+ SOL: 'SOL',
32
+ BNB: 'BNB',
33
+ USDC_ETH: 'USDC_ETH',
34
+ USDC_POL: 'USDC_POL',
35
+ USDC_SOL: 'USDC_SOL',
36
+ USDT_ETH: 'USDT_ETH',
37
+ USDT_POL: 'USDT_POL',
38
+ USDT_SOL: 'USDT_SOL',
39
+ };
40
+
41
+ /**
42
+ * Default chains to derive on wallet creation
43
+ */
44
+ export const DEFAULT_CHAINS = ['BTC', 'ETH', 'SOL', 'POL', 'BCH'];
45
+
46
+ /**
47
+ * BIP44 coin types for derivation paths
48
+ */
49
+ const COIN_TYPES = {
50
+ BTC: 0,
51
+ BCH: 145,
52
+ ETH: 60,
53
+ POL: 60, // Uses ETH path
54
+ BNB: 60, // Uses ETH path (BSC is EVM)
55
+ SOL: 501,
56
+ USDC_ETH: 60,
57
+ USDC_POL: 60,
58
+ USDC_SOL: 501,
59
+ USDT_ETH: 60,
60
+ USDT_POL: 60,
61
+ USDT_SOL: 501,
62
+ };
63
+
64
+ /**
65
+ * Chains that use secp256k1 curve (vs ed25519 for Solana)
66
+ */
67
+ const SECP256K1_CHAINS = ['BTC', 'BCH', 'ETH', 'POL', 'BNB', 'USDC_ETH', 'USDC_POL', 'USDT_ETH', 'USDT_POL'];
68
+
69
+ // ============================================================
70
+ // Address derivation helpers
71
+ // ============================================================
72
+
73
+ const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
74
+
75
+ /**
76
+ * Base58 encode a byte array
77
+ */
78
+ function base58Encode(bytes) {
79
+ const digits = [0];
80
+ for (const byte of bytes) {
81
+ let carry = byte;
82
+ for (let j = 0; j < digits.length; j++) {
83
+ carry += digits[j] << 8;
84
+ digits[j] = carry % 58;
85
+ carry = (carry / 58) | 0;
86
+ }
87
+ while (carry > 0) {
88
+ digits.push(carry % 58);
89
+ carry = (carry / 58) | 0;
90
+ }
91
+ }
92
+ // Leading zeros
93
+ let result = '';
94
+ for (const byte of bytes) {
95
+ if (byte === 0) result += BASE58_ALPHABET[0];
96
+ else break;
97
+ }
98
+ for (let i = digits.length - 1; i >= 0; i--) {
99
+ result += BASE58_ALPHABET[digits[i]];
100
+ }
101
+ return result;
102
+ }
103
+
104
+ /**
105
+ * Base58Check encode (used for BTC addresses)
106
+ */
107
+ function base58CheckEncode(version, payload) {
108
+ const data = new Uint8Array(1 + payload.length);
109
+ data[0] = version;
110
+ data.set(payload, 1);
111
+ const checksum = sha256(sha256(data)).slice(0, 4);
112
+ const full = new Uint8Array(data.length + 4);
113
+ full.set(data);
114
+ full.set(checksum, data.length);
115
+ return base58Encode(full);
116
+ }
117
+
118
+ /**
119
+ * Hash160: SHA256 then RIPEMD160
120
+ */
121
+ function hash160(data) {
122
+ return ripemd160(sha256(data));
123
+ }
124
+
125
+ /**
126
+ * Derive BTC P2PKH address from compressed public key
127
+ */
128
+ function deriveBTCAddress(compressedPubKey) {
129
+ const h = hash160(compressedPubKey);
130
+ return base58CheckEncode(0x00, h);
131
+ }
132
+
133
+ /**
134
+ * Derive BCH CashAddr from compressed public key
135
+ * Uses simplified CashAddr encoding (lowercase bech32-like with bitcoincash: prefix)
136
+ */
137
+ function deriveBCHAddress(compressedPubKey) {
138
+ const h = hash160(compressedPubKey);
139
+ // CashAddr encoding
140
+ const CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
141
+
142
+ // Convert hash160 to 5-bit groups with version byte
143
+ // Version byte: 0 = P2PKH, 160-bit hash → type=0, hash_size=0 → version_byte = 0
144
+ const payload8 = new Uint8Array(1 + h.length);
145
+ payload8[0] = 0; // version byte
146
+ payload8.set(h, 1);
147
+
148
+ // Convert 8-bit to 5-bit
149
+ const payload5 = convertBits(payload8, 8, 5, true);
150
+
151
+ // Polymod checksum
152
+ const prefixData = expandPrefix('bitcoincash');
153
+ const checksumInput = new Uint8Array(prefixData.length + payload5.length + 8);
154
+ checksumInput.set(prefixData);
155
+ checksumInput.set(payload5, prefixData.length);
156
+ // Last 8 bytes are zeros for checksum calculation
157
+ const polymod = cashAddrPolymod(checksumInput) ^ 1;
158
+
159
+ const checksum = new Uint8Array(8);
160
+ for (let i = 0; i < 8; i++) {
161
+ checksum[i] = (polymod >> (5 * (7 - i))) & 0x1f;
162
+ }
163
+
164
+ let encoded = 'bitcoincash:';
165
+ for (const val of payload5) encoded += CHARSET[val];
166
+ for (const val of checksum) encoded += CHARSET[val];
167
+
168
+ return encoded;
169
+ }
170
+
171
+ function convertBits(data, fromBits, toBits, pad) {
172
+ let acc = 0;
173
+ let bits = 0;
174
+ const result = [];
175
+ const maxv = (1 << toBits) - 1;
176
+ for (const value of data) {
177
+ acc = (acc << fromBits) | value;
178
+ bits += fromBits;
179
+ while (bits >= toBits) {
180
+ bits -= toBits;
181
+ result.push((acc >> bits) & maxv);
182
+ }
183
+ }
184
+ if (pad && bits > 0) {
185
+ result.push((acc << (toBits - bits)) & maxv);
186
+ }
187
+ return new Uint8Array(result);
188
+ }
189
+
190
+ function expandPrefix(prefix) {
191
+ const result = new Uint8Array(prefix.length + 1);
192
+ for (let i = 0; i < prefix.length; i++) {
193
+ result[i] = prefix.charCodeAt(i) & 0x1f;
194
+ }
195
+ result[prefix.length] = 0;
196
+ return result;
197
+ }
198
+
199
+ function cashAddrPolymod(values) {
200
+ const GENERATORS = [
201
+ 0x98f2bc8e61n, 0x79b76d99e2n, 0xf33e5fb3c4n, 0xae2eabe2a8n, 0x1e4f43e470n
202
+ ];
203
+ let c = 1n;
204
+ for (const d of values) {
205
+ const c0 = c >> 35n;
206
+ c = ((c & 0x07ffffffffn) << 5n) ^ BigInt(d);
207
+ for (let i = 0; i < 5; i++) {
208
+ if ((c0 >> BigInt(i)) & 1n) {
209
+ c ^= GENERATORS[i];
210
+ }
211
+ }
212
+ }
213
+ return Number(c);
214
+ }
215
+
216
+ /**
217
+ * Derive ETH/EVM address from compressed public key bytes
218
+ */
219
+ function deriveETHAddress(compressedPubKey) {
220
+ // Decompress: get uncompressed point (65 bytes: 04 || x || y)
221
+ const point = secp256k1.ProjectivePoint.fromHex(compressedPubKey);
222
+ const uncompressed = point.toRawBytes(false); // 65 bytes with 04 prefix
223
+ // Keccak256 of the 64 bytes (without 04 prefix)
224
+ const hash = keccak_256(uncompressed.slice(1));
225
+ // Last 20 bytes
226
+ const addr = bytesToHex(hash.slice(12));
227
+ return '0x' + addr;
228
+ }
229
+
230
+ /**
231
+ * SLIP-0010 ed25519 key derivation from seed
232
+ * Solana uses ed25519, derived via SLIP-0010 (not BIP32 secp256k1)
233
+ */
234
+ function deriveEd25519Key(seed, path) {
235
+ // SLIP-0010 master key derivation
236
+ const I = hmac(sha512, new TextEncoder().encode('ed25519 seed'), seed);
237
+ let key = I.slice(0, 32);
238
+ let chainCode = I.slice(32);
239
+
240
+ // Parse path
241
+ const segments = path.replace('m/', '').split('/');
242
+ for (const seg of segments) {
243
+ const hardened = seg.endsWith("'");
244
+ const index = parseInt(seg.replace("'", ''), 10);
245
+ if (!hardened) throw new Error('SLIP-0010 ed25519 only supports hardened derivation');
246
+
247
+ const indexBuf = new Uint8Array(4);
248
+ const val = (index | 0x80000000) >>> 0;
249
+ indexBuf[0] = (val >> 24) & 0xff;
250
+ indexBuf[1] = (val >> 16) & 0xff;
251
+ indexBuf[2] = (val >> 8) & 0xff;
252
+ indexBuf[3] = val & 0xff;
253
+
254
+ const data = new Uint8Array(1 + 32 + 4);
255
+ data[0] = 0x00;
256
+ data.set(key, 1);
257
+ data.set(indexBuf, 33);
258
+
259
+ const derived = hmac(sha512, chainCode, data);
260
+ key = derived.slice(0, 32);
261
+ chainCode = derived.slice(32);
262
+ }
263
+
264
+ return key;
265
+ }
266
+
267
+ /**
268
+ * Derive SOL address from seed using SLIP-0010 ed25519
269
+ */
270
+ function deriveSOLAddress(seed, index = 0) {
271
+ const path = `m/44'/501'/${index}'/0'`;
272
+ const privateKey = deriveEd25519Key(seed, path);
273
+ const publicKey = ed25519.getPublicKey(privateKey);
274
+ return { address: base58Encode(publicKey), publicKey, privateKey, path };
275
+ }
276
+
277
+ /**
278
+ * Derive the blockchain address for a given chain from seed/public key
279
+ * @param {Uint8Array} seed - Master seed
280
+ * @param {string} chain - Chain code
281
+ * @param {number} index - Derivation index
282
+ * @returns {string} The derived address
283
+ */
284
+ export function deriveAddress(seed, chain, index = 0) {
285
+ // Solana-based chains
286
+ if (chain === 'SOL' || chain === 'USDC_SOL' || chain === 'USDT_SOL') {
287
+ return deriveSOLAddress(seed, index).address;
288
+ }
289
+
290
+ // secp256k1-based chains
291
+ const { publicKey } = deriveKeyPair(seed, chain, index);
292
+
293
+ switch (chain) {
294
+ case 'BTC':
295
+ return deriveBTCAddress(publicKey);
296
+ case 'BCH':
297
+ return deriveBCHAddress(publicKey);
298
+ case 'ETH':
299
+ case 'POL':
300
+ case 'BNB':
301
+ case 'USDC_ETH':
302
+ case 'USDC_POL':
303
+ case 'USDT_ETH':
304
+ case 'USDT_POL':
305
+ return deriveETHAddress(publicKey);
306
+ default:
307
+ throw new Error(`Unsupported chain for address derivation: ${chain}`);
308
+ }
309
+ }
310
+
311
+ /**
312
+ * Generate a new mnemonic phrase
313
+ * @param {number} [words=12] - Number of words (12 or 24)
314
+ * @returns {string} BIP39 mnemonic phrase
315
+ */
316
+ export function generateMnemonic(words = 12) {
317
+ if (words !== 12 && words !== 24) {
318
+ throw new Error('Invalid word count. Must be 12 or 24.');
319
+ }
320
+ const strength = words === 12 ? 128 : 256;
321
+ return bip39.generateMnemonic(wordlist, strength);
322
+ }
323
+
324
+ /**
325
+ * Validate a mnemonic phrase
326
+ * @param {string} mnemonic - BIP39 mnemonic phrase
327
+ * @returns {boolean} Whether the mnemonic is valid
328
+ */
329
+ export function validateMnemonic(mnemonic) {
330
+ if (!mnemonic || typeof mnemonic !== 'string') {
331
+ return false;
332
+ }
333
+ return bip39.validateMnemonic(mnemonic.trim(), wordlist);
334
+ }
335
+
336
+ /**
337
+ * Derive seed from mnemonic
338
+ * @param {string} mnemonic - BIP39 mnemonic phrase
339
+ * @returns {Uint8Array} Seed bytes
340
+ */
341
+ function mnemonicToSeed(mnemonic) {
342
+ return bip39.mnemonicToSeedSync(mnemonic.trim());
343
+ }
344
+
345
+ /**
346
+ * Get derivation path for a chain
347
+ * @param {string} chain - Chain code
348
+ * @param {number} [index=0] - Address index
349
+ * @returns {string} BIP44 derivation path
350
+ */
351
+ export function getDerivationPath(chain, index = 0) {
352
+ const coinType = COIN_TYPES[chain];
353
+ if (coinType === undefined) {
354
+ throw new Error(`Unsupported chain: ${chain}`);
355
+ }
356
+
357
+ // BIP44 path: m / purpose' / coin_type' / account' / change / address_index
358
+ if (chain === 'SOL' || chain.startsWith('USDC_SOL') || chain.startsWith('USDT_SOL')) {
359
+ // Solana uses different derivation
360
+ return `m/44'/${coinType}'/${index}'/0'`;
361
+ }
362
+
363
+ return `m/44'/${coinType}'/0'/0/${index}`;
364
+ }
365
+
366
+ /**
367
+ * Derive key pair from seed for a specific chain
368
+ * @private
369
+ */
370
+ function deriveKeyPair(seed, chain, index = 0) {
371
+ const path = getDerivationPath(chain, index);
372
+ const hdKey = HDKey.fromMasterSeed(seed);
373
+ const derived = hdKey.derive(path);
374
+
375
+ if (!derived.privateKey) {
376
+ throw new Error('Failed to derive private key');
377
+ }
378
+
379
+ return {
380
+ privateKey: derived.privateKey,
381
+ publicKey: derived.publicKey,
382
+ path,
383
+ };
384
+ }
385
+
386
+ /**
387
+ * Get public key hex from seed for secp256k1 chains
388
+ * @private
389
+ */
390
+ function getSecp256k1PublicKey(seed) {
391
+ // Use ETH derivation for the master secp256k1 key
392
+ const hdKey = HDKey.fromMasterSeed(seed);
393
+ const derived = hdKey.derive("m/44'/60'/0'/0/0");
394
+ return bytesToHex(derived.publicKey);
395
+ }
396
+
397
+ /**
398
+ * Sign a message with secp256k1 private key
399
+ * @private
400
+ */
401
+ function signMessage(message, privateKey) {
402
+ const messageHash = sha256(new TextEncoder().encode(message));
403
+ const signature = secp256k1.sign(messageHash, privateKey);
404
+ // Handle different noble-curves versions:
405
+ // v1.x returns Signature object with toCompactHex()
406
+ // v2.x returns raw Uint8Array directly
407
+ if (signature instanceof Uint8Array) {
408
+ return bytesToHex(signature);
409
+ }
410
+ if (typeof signature.toCompactHex === 'function') {
411
+ return signature.toCompactHex();
412
+ }
413
+ // Fallback: try toCompactRawBytes
414
+ return bytesToHex(signature.toCompactRawBytes());
415
+ }
416
+
417
+ /**
418
+ * Derive address placeholder - actual address derivation happens on client
419
+ * This returns the public key that the server can use to verify ownership
420
+ * @private
421
+ */
422
+ function deriveAddressInfo(seed, chain, index = 0) {
423
+ const address = deriveAddress(seed, chain, index);
424
+ const path = getDerivationPath(chain, index);
425
+
426
+ // For SOL chains, the public key is derived differently (ed25519)
427
+ let publicKeyHex;
428
+ if (chain === 'SOL' || chain === 'USDC_SOL' || chain === 'USDT_SOL') {
429
+ const solInfo = deriveSOLAddress(seed, index);
430
+ publicKeyHex = bytesToHex(solInfo.publicKey);
431
+ } else {
432
+ const { publicKey } = deriveKeyPair(seed, chain, index);
433
+ publicKeyHex = bytesToHex(publicKey);
434
+ }
435
+
436
+ return {
437
+ chain,
438
+ address,
439
+ publicKey: publicKeyHex,
440
+ derivation_path: path,
441
+ derivation_index: index,
442
+ };
443
+ }
444
+
445
+ /**
446
+ * WalletClient - Manages wallet operations
447
+ *
448
+ * The wallet client handles:
449
+ * - Local key derivation (seed never leaves client)
450
+ * - Server registration of public keys/addresses
451
+ * - Authenticated API calls using signature-based auth
452
+ */
453
+ export class WalletClient {
454
+ #mnemonic;
455
+ #seed;
456
+ #walletId;
457
+ #authToken;
458
+ #baseUrl;
459
+ #timeout;
460
+ #publicKeySecp256k1;
461
+
462
+ /**
463
+ * Create a wallet client
464
+ * @private - Use WalletClient.create() or WalletClient.fromSeed() instead
465
+ */
466
+ constructor(options = {}) {
467
+ this.#baseUrl = (options.baseUrl || DEFAULT_BASE_URL).replace(/\/$/, '');
468
+ this.#timeout = options.timeout || 30000;
469
+ this.#mnemonic = options.mnemonic || null;
470
+ this.#seed = options.seed || null;
471
+ this.#walletId = options.walletId || null;
472
+ this.#authToken = options.authToken || null;
473
+ this.#publicKeySecp256k1 = options.publicKeySecp256k1 || null;
474
+ }
475
+
476
+ /**
477
+ * Create a new wallet with a fresh mnemonic
478
+ * @param {Object} options - Creation options
479
+ * @param {number} [options.words=12] - Number of words (12 or 24)
480
+ * @param {string[]} [options.chains] - Chains to derive initial addresses for
481
+ * @param {string} [options.baseUrl] - API base URL
482
+ * @param {number} [options.timeout] - Request timeout
483
+ * @returns {Promise<WalletClient>} Wallet client with fresh mnemonic
484
+ */
485
+ static async create(options = {}) {
486
+ const words = options.words || 12;
487
+ const chains = options.chains || DEFAULT_CHAINS;
488
+
489
+ const mnemonic = generateMnemonic(words);
490
+ const seed = mnemonicToSeed(mnemonic);
491
+ const publicKeySecp256k1 = getSecp256k1PublicKey(seed);
492
+
493
+ const client = new WalletClient({
494
+ baseUrl: options.baseUrl,
495
+ timeout: options.timeout,
496
+ mnemonic,
497
+ seed,
498
+ publicKeySecp256k1,
499
+ });
500
+
501
+ // Register wallet with server
502
+ const initialAddresses = chains.map(chain => {
503
+ const info = deriveAddressInfo(seed, chain, 0);
504
+ // For registration, we need to provide a placeholder address
505
+ // The actual address would be derived client-side with full implementation
506
+ return {
507
+ chain: info.chain,
508
+ address: info.address,
509
+ derivation_path: info.derivation_path,
510
+ };
511
+ });
512
+
513
+ const result = await client.#request('/web-wallet/create', {
514
+ method: 'POST',
515
+ body: JSON.stringify({
516
+ public_key_secp256k1: publicKeySecp256k1,
517
+ initial_addresses: initialAddresses,
518
+ }),
519
+ });
520
+
521
+ client.#walletId = result.wallet_id;
522
+
523
+ return client;
524
+ }
525
+
526
+ /**
527
+ * Import an existing wallet from a mnemonic
528
+ * @param {string} mnemonic - BIP39 mnemonic phrase
529
+ * @param {Object} options - Import options
530
+ * @param {string[]} [options.chains] - Chains to derive addresses for
531
+ * @param {string} [options.baseUrl] - API base URL
532
+ * @param {number} [options.timeout] - Request timeout
533
+ * @returns {Promise<WalletClient>} Wallet client with imported mnemonic
534
+ */
535
+ static async fromSeed(mnemonic, options = {}) {
536
+ if (!validateMnemonic(mnemonic)) {
537
+ throw new Error('Invalid mnemonic phrase');
538
+ }
539
+
540
+ const chains = options.chains || DEFAULT_CHAINS;
541
+ const seed = mnemonicToSeed(mnemonic);
542
+ const publicKeySecp256k1 = getSecp256k1PublicKey(seed);
543
+
544
+ const client = new WalletClient({
545
+ baseUrl: options.baseUrl,
546
+ timeout: options.timeout,
547
+ mnemonic: mnemonic.trim(),
548
+ seed,
549
+ publicKeySecp256k1,
550
+ });
551
+
552
+ // Create proof of ownership
553
+ const proofMessage = `CoinPay Wallet Import: ${Date.now()}`;
554
+ const { privateKey } = deriveKeyPair(seed, 'ETH', 0);
555
+ const signature = signMessage(proofMessage, privateKey);
556
+
557
+ // Derive addresses for registration
558
+ const addresses = chains.map(chain => {
559
+ const info = deriveAddressInfo(seed, chain, 0);
560
+ return {
561
+ chain: info.chain,
562
+ address: info.address,
563
+ derivation_path: info.derivation_path,
564
+ };
565
+ });
566
+
567
+ // Register/import wallet with server
568
+ const result = await client.#request('/web-wallet/import', {
569
+ method: 'POST',
570
+ body: JSON.stringify({
571
+ public_key_secp256k1: publicKeySecp256k1,
572
+ addresses,
573
+ proof_of_ownership: {
574
+ message: proofMessage,
575
+ signature,
576
+ },
577
+ }),
578
+ });
579
+
580
+ client.#walletId = result.wallet_id;
581
+
582
+ return client;
583
+ }
584
+
585
+ /**
586
+ * Make an API request
587
+ * @private
588
+ */
589
+ async #request(endpoint, options = {}) {
590
+ const url = `${this.#baseUrl}${endpoint}`;
591
+ const controller = new AbortController();
592
+ const timeoutId = setTimeout(() => controller.abort(), this.#timeout);
593
+
594
+ const headers = {
595
+ 'Content-Type': 'application/json',
596
+ ...options.headers,
597
+ };
598
+
599
+ // Add auth token if we have one
600
+ if (this.#authToken) {
601
+ headers['Authorization'] = `Bearer ${this.#authToken}`;
602
+ }
603
+
604
+ try {
605
+ const response = await fetch(url, {
606
+ ...options,
607
+ signal: controller.signal,
608
+ headers,
609
+ });
610
+
611
+ const data = await response.json();
612
+
613
+ if (!response.ok) {
614
+ const error = new Error(data.error || `HTTP ${response.status}`);
615
+ error.status = response.status;
616
+ error.code = data.code;
617
+ error.response = data;
618
+ throw error;
619
+ }
620
+
621
+ return data;
622
+ } catch (error) {
623
+ if (error.name === 'AbortError') {
624
+ throw new Error(`Request timeout after ${this.#timeout}ms`);
625
+ }
626
+ throw error;
627
+ } finally {
628
+ clearTimeout(timeoutId);
629
+ }
630
+ }
631
+
632
+ /**
633
+ * Authenticate with the server to get a JWT token
634
+ * @returns {Promise<void>}
635
+ */
636
+ async authenticate() {
637
+ if (!this.#walletId || !this.#seed) {
638
+ throw new Error('Wallet not initialized. Use create() or fromSeed() first.');
639
+ }
640
+
641
+ // Get challenge
642
+ const { challenge } = await this.#request(`/web-wallet/auth/challenge`, {
643
+ method: 'POST',
644
+ body: JSON.stringify({ wallet_id: this.#walletId }),
645
+ });
646
+
647
+ // Sign challenge
648
+ const { privateKey } = deriveKeyPair(this.#seed, 'ETH', 0);
649
+ const signature = signMessage(challenge, privateKey);
650
+
651
+ // Verify and get token
652
+ const result = await this.#request('/web-wallet/auth/verify', {
653
+ method: 'POST',
654
+ body: JSON.stringify({
655
+ wallet_id: this.#walletId,
656
+ challenge_id: result.challenge_id,
657
+ signature,
658
+ }),
659
+ });
660
+
661
+ this.#authToken = result.auth_token;
662
+ }
663
+
664
+ /**
665
+ * Get the mnemonic phrase (for backup)
666
+ * @returns {string|null} Mnemonic phrase
667
+ */
668
+ getMnemonic() {
669
+ return this.#mnemonic;
670
+ }
671
+
672
+ /**
673
+ * Get the wallet ID
674
+ * @returns {string|null} Wallet ID
675
+ */
676
+ getWalletId() {
677
+ return this.#walletId;
678
+ }
679
+
680
+ /**
681
+ * Get wallet info
682
+ * @returns {Promise<Object>} Wallet information
683
+ */
684
+ async getInfo() {
685
+ if (!this.#walletId) {
686
+ throw new Error('Wallet not initialized');
687
+ }
688
+
689
+ return this.#request(`/web-wallet/${this.#walletId}`);
690
+ }
691
+
692
+ /**
693
+ * Get all addresses for this wallet
694
+ * @param {Object} [options] - Query options
695
+ * @param {string} [options.chain] - Filter by chain
696
+ * @param {boolean} [options.activeOnly=true] - Only return active addresses
697
+ * @returns {Promise<Object>} Address list
698
+ */
699
+ async getAddresses(options = {}) {
700
+ if (!this.#walletId) {
701
+ throw new Error('Wallet not initialized');
702
+ }
703
+
704
+ const params = new URLSearchParams();
705
+ if (options.chain) params.set('chain', options.chain);
706
+ if (options.activeOnly !== false) params.set('active_only', 'true');
707
+
708
+ const query = params.toString();
709
+ const endpoint = `/web-wallet/${this.#walletId}/addresses${query ? `?${query}` : ''}`;
710
+
711
+ return this.#request(endpoint);
712
+ }
713
+
714
+ /**
715
+ * Derive a new address for a chain
716
+ * @param {string} chain - Blockchain chain code
717
+ * @param {number} [index=0] - Derivation index
718
+ * @returns {Promise<Object>} Derived address info
719
+ */
720
+ async deriveAddress(chain, index = 0) {
721
+ if (!this.#walletId || !this.#seed) {
722
+ throw new Error('Wallet not initialized');
723
+ }
724
+
725
+ const info = deriveAddressInfo(this.#seed, chain, index);
726
+
727
+ const result = await this.#request(`/web-wallet/${this.#walletId}/derive`, {
728
+ method: 'POST',
729
+ body: JSON.stringify({
730
+ chain: info.chain,
731
+ address: info.address,
732
+ derivation_index: info.derivation_index,
733
+ derivation_path: info.derivation_path,
734
+ }),
735
+ });
736
+
737
+ return result;
738
+ }
739
+
740
+ /**
741
+ * Derive addresses for any missing chains
742
+ * @param {string[]} [targetChains] - Chains to check (default: all supported)
743
+ * @returns {Promise<Object[]>} Newly derived addresses
744
+ */
745
+ async deriveMissingChains(targetChains) {
746
+ const chains = targetChains || Object.keys(WalletChain);
747
+ const { addresses } = await this.getAddresses({ activeOnly: true });
748
+
749
+ const existingChains = new Set(addresses.map(a => a.chain));
750
+ const missingChains = chains.filter(c => !existingChains.has(c));
751
+
752
+ const results = [];
753
+ for (const chain of missingChains) {
754
+ try {
755
+ const result = await this.deriveAddress(chain, 0);
756
+ results.push(result);
757
+ } catch (error) {
758
+ console.warn(`Failed to derive ${chain}: ${error.message}`);
759
+ }
760
+ }
761
+
762
+ return results;
763
+ }
764
+
765
+ /**
766
+ * Get all balances for this wallet
767
+ * @param {Object} [options] - Query options
768
+ * @param {string} [options.chain] - Filter by chain
769
+ * @param {boolean} [options.refresh=false] - Force refresh from blockchain
770
+ * @returns {Promise<Object>} Balances
771
+ */
772
+ async getBalances(options = {}) {
773
+ if (!this.#walletId) {
774
+ throw new Error('Wallet not initialized');
775
+ }
776
+
777
+ const params = new URLSearchParams();
778
+ if (options.chain) params.set('chain', options.chain);
779
+ if (options.refresh) params.set('refresh', 'true');
780
+
781
+ const query = params.toString();
782
+ const endpoint = `/web-wallet/${this.#walletId}/balances${query ? `?${query}` : ''}`;
783
+
784
+ return this.#request(endpoint);
785
+ }
786
+
787
+ /**
788
+ * Get balance for a specific chain
789
+ * @param {string} chain - Chain code
790
+ * @returns {Promise<Object>} Balance for the chain
791
+ */
792
+ async getBalance(chain) {
793
+ return this.getBalances({ chain });
794
+ }
795
+
796
+ /**
797
+ * Send a transaction
798
+ * @param {Object} options - Send options
799
+ * @param {string} options.chain - Target chain
800
+ * @param {string} options.to - Recipient address
801
+ * @param {string} options.amount - Amount to send
802
+ * @param {string} [options.priority='medium'] - Fee priority (low/medium/high)
803
+ * @returns {Promise<Object>} Transaction result
804
+ */
805
+ async send(options) {
806
+ if (!this.#walletId || !this.#seed) {
807
+ throw new Error('Wallet not initialized');
808
+ }
809
+
810
+ const { chain, to, amount, priority = 'medium' } = options;
811
+
812
+ if (!chain || !to || !amount) {
813
+ throw new Error('chain, to, and amount are required');
814
+ }
815
+
816
+ // Get our address for this chain
817
+ const { addresses } = await this.getAddresses({ chain });
818
+ if (!addresses || addresses.length === 0) {
819
+ throw new Error(`No address found for chain ${chain}`);
820
+ }
821
+
822
+ const fromAddress = addresses[0].address;
823
+
824
+ // Step 1: Prepare the transaction
825
+ const prepareResult = await this.#request(`/web-wallet/${this.#walletId}/prepare-tx`, {
826
+ method: 'POST',
827
+ body: JSON.stringify({
828
+ from_address: fromAddress,
829
+ to_address: to,
830
+ chain,
831
+ amount,
832
+ priority,
833
+ }),
834
+ });
835
+
836
+ // Step 2: Sign the transaction locally
837
+ // Note: This is a simplified version - real implementation would need
838
+ // chain-specific signing logic
839
+ const { privateKey } = deriveKeyPair(this.#seed, chain, 0);
840
+ const unsignedTx = prepareResult.unsigned_tx;
841
+
842
+ // For EVM chains, sign the transaction hash
843
+ // For BTC, sign each input
844
+ // This is simplified - real implementation needs chain-specific logic
845
+ const signedTx = signMessage(unsignedTx, privateKey);
846
+
847
+ // Step 3: Broadcast the signed transaction
848
+ const broadcastResult = await this.#request(`/web-wallet/${this.#walletId}/broadcast`, {
849
+ method: 'POST',
850
+ body: JSON.stringify({
851
+ tx_id: prepareResult.tx_id,
852
+ signed_tx: signedTx,
853
+ chain,
854
+ }),
855
+ });
856
+
857
+ return broadcastResult;
858
+ }
859
+
860
+ /**
861
+ * Get transaction history
862
+ * @param {Object} [options] - Query options
863
+ * @param {string} [options.chain] - Filter by chain
864
+ * @param {string} [options.direction] - Filter by direction (incoming/outgoing)
865
+ * @param {number} [options.limit=50] - Number of results
866
+ * @param {number} [options.offset=0] - Pagination offset
867
+ * @returns {Promise<Object>} Transaction history
868
+ */
869
+ async getHistory(options = {}) {
870
+ if (!this.#walletId) {
871
+ throw new Error('Wallet not initialized');
872
+ }
873
+
874
+ const params = new URLSearchParams();
875
+ if (options.chain) params.set('chain', options.chain);
876
+ if (options.direction) params.set('direction', options.direction);
877
+ if (options.limit) params.set('limit', String(options.limit));
878
+ if (options.offset) params.set('offset', String(options.offset));
879
+
880
+ const query = params.toString();
881
+ const endpoint = `/web-wallet/${this.#walletId}/transactions${query ? `?${query}` : ''}`;
882
+
883
+ return this.#request(endpoint);
884
+ }
885
+
886
+ /**
887
+ * Estimate transaction fee
888
+ * @param {string} chain - Target chain
889
+ * @param {string} [to] - Recipient address (optional, for more accurate estimate)
890
+ * @param {string} [amount] - Amount (optional, for more accurate estimate)
891
+ * @returns {Promise<Object>} Fee estimates
892
+ */
893
+ async estimateFee(chain, to, amount) {
894
+ if (!this.#walletId) {
895
+ throw new Error('Wallet not initialized');
896
+ }
897
+
898
+ const body = { chain };
899
+ if (to) body.to_address = to;
900
+ if (amount) body.amount = amount;
901
+
902
+ return this.#request(`/web-wallet/${this.#walletId}/estimate-fee`, {
903
+ method: 'POST',
904
+ body: JSON.stringify(body),
905
+ });
906
+ }
907
+
908
+ /**
909
+ * Encrypt and backup the seed phrase
910
+ * @param {string} password - Encryption password
911
+ * @returns {Promise<string>} Encrypted seed (base64)
912
+ */
913
+ async backupSeed(password) {
914
+ if (!this.#mnemonic) {
915
+ throw new Error('No mnemonic available');
916
+ }
917
+
918
+ if (!password || password.length < 8) {
919
+ throw new Error('Password must be at least 8 characters');
920
+ }
921
+
922
+ // Simple encryption using Web Crypto API
923
+ const encoder = new TextEncoder();
924
+ const data = encoder.encode(this.#mnemonic);
925
+
926
+ // Derive key from password
927
+ const keyMaterial = await crypto.subtle.importKey(
928
+ 'raw',
929
+ encoder.encode(password),
930
+ 'PBKDF2',
931
+ false,
932
+ ['deriveBits', 'deriveKey']
933
+ );
934
+
935
+ const salt = crypto.getRandomValues(new Uint8Array(16));
936
+ const key = await crypto.subtle.deriveKey(
937
+ {
938
+ name: 'PBKDF2',
939
+ salt,
940
+ iterations: 100000,
941
+ hash: 'SHA-256',
942
+ },
943
+ keyMaterial,
944
+ { name: 'AES-GCM', length: 256 },
945
+ false,
946
+ ['encrypt']
947
+ );
948
+
949
+ const iv = crypto.getRandomValues(new Uint8Array(12));
950
+ const encrypted = await crypto.subtle.encrypt(
951
+ { name: 'AES-GCM', iv },
952
+ key,
953
+ data
954
+ );
955
+
956
+ // Combine salt + iv + encrypted data
957
+ const combined = new Uint8Array(salt.length + iv.length + encrypted.byteLength);
958
+ combined.set(salt, 0);
959
+ combined.set(iv, salt.length);
960
+ combined.set(new Uint8Array(encrypted), salt.length + iv.length);
961
+
962
+ // Return as base64
963
+ return btoa(String.fromCharCode(...combined));
964
+ }
965
+ }
966
+
967
+ /**
968
+ * Restore a seed from encrypted backup
969
+ * @param {string} encryptedBackup - Base64 encrypted backup
970
+ * @param {string} password - Decryption password
971
+ * @returns {Promise<string>} Decrypted mnemonic
972
+ */
973
+ export async function restoreFromBackup(encryptedBackup, password) {
974
+ const encoder = new TextEncoder();
975
+ const decoder = new TextDecoder();
976
+
977
+ // Decode base64
978
+ const combined = Uint8Array.from(atob(encryptedBackup), c => c.charCodeAt(0));
979
+
980
+ // Extract salt, iv, and encrypted data
981
+ const salt = combined.slice(0, 16);
982
+ const iv = combined.slice(16, 28);
983
+ const encrypted = combined.slice(28);
984
+
985
+ // Derive key from password
986
+ const keyMaterial = await crypto.subtle.importKey(
987
+ 'raw',
988
+ encoder.encode(password),
989
+ 'PBKDF2',
990
+ false,
991
+ ['deriveBits', 'deriveKey']
992
+ );
993
+
994
+ const key = await crypto.subtle.deriveKey(
995
+ {
996
+ name: 'PBKDF2',
997
+ salt,
998
+ iterations: 100000,
999
+ hash: 'SHA-256',
1000
+ },
1001
+ keyMaterial,
1002
+ { name: 'AES-GCM', length: 256 },
1003
+ false,
1004
+ ['decrypt']
1005
+ );
1006
+
1007
+ const decrypted = await crypto.subtle.decrypt(
1008
+ { name: 'AES-GCM', iv },
1009
+ key,
1010
+ encrypted
1011
+ );
1012
+
1013
+ return decoder.decode(decrypted);
1014
+ }
1015
+
1016
+ export default WalletClient;