@profullstack/coinpay 0.3.9 → 0.4.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/src/wallet.js ADDED
@@ -0,0 +1,757 @@
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/sha256';
14
+ import { bytesToHex, hexToBytes } from '@noble/hashes/utils';
15
+
16
+ const DEFAULT_BASE_URL = 'https://coinpayportal.com/api';
17
+
18
+ /**
19
+ * Supported blockchain chains
20
+ */
21
+ export const WalletChain = {
22
+ BTC: 'BTC',
23
+ BCH: 'BCH',
24
+ ETH: 'ETH',
25
+ POL: 'POL',
26
+ SOL: 'SOL',
27
+ BNB: 'BNB',
28
+ USDC_ETH: 'USDC_ETH',
29
+ USDC_POL: 'USDC_POL',
30
+ USDC_SOL: 'USDC_SOL',
31
+ USDT_ETH: 'USDT_ETH',
32
+ USDT_POL: 'USDT_POL',
33
+ USDT_SOL: 'USDT_SOL',
34
+ };
35
+
36
+ /**
37
+ * Default chains to derive on wallet creation
38
+ */
39
+ export const DEFAULT_CHAINS = ['BTC', 'ETH', 'SOL', 'POL', 'BCH'];
40
+
41
+ /**
42
+ * BIP44 coin types for derivation paths
43
+ */
44
+ const COIN_TYPES = {
45
+ BTC: 0,
46
+ BCH: 145,
47
+ ETH: 60,
48
+ POL: 60, // Uses ETH path
49
+ BNB: 60, // Uses ETH path (BSC is EVM)
50
+ SOL: 501,
51
+ USDC_ETH: 60,
52
+ USDC_POL: 60,
53
+ USDC_SOL: 501,
54
+ USDT_ETH: 60,
55
+ USDT_POL: 60,
56
+ USDT_SOL: 501,
57
+ };
58
+
59
+ /**
60
+ * Chains that use secp256k1 curve (vs ed25519 for Solana)
61
+ */
62
+ const SECP256K1_CHAINS = ['BTC', 'BCH', 'ETH', 'POL', 'BNB', 'USDC_ETH', 'USDC_POL', 'USDT_ETH', 'USDT_POL'];
63
+
64
+ /**
65
+ * Generate a new mnemonic phrase
66
+ * @param {number} [words=12] - Number of words (12 or 24)
67
+ * @returns {string} BIP39 mnemonic phrase
68
+ */
69
+ export function generateMnemonic(words = 12) {
70
+ if (words !== 12 && words !== 24) {
71
+ throw new Error('Invalid word count. Must be 12 or 24.');
72
+ }
73
+ const strength = words === 12 ? 128 : 256;
74
+ return bip39.generateMnemonic(wordlist, strength);
75
+ }
76
+
77
+ /**
78
+ * Validate a mnemonic phrase
79
+ * @param {string} mnemonic - BIP39 mnemonic phrase
80
+ * @returns {boolean} Whether the mnemonic is valid
81
+ */
82
+ export function validateMnemonic(mnemonic) {
83
+ if (!mnemonic || typeof mnemonic !== 'string') {
84
+ return false;
85
+ }
86
+ return bip39.validateMnemonic(mnemonic.trim(), wordlist);
87
+ }
88
+
89
+ /**
90
+ * Derive seed from mnemonic
91
+ * @param {string} mnemonic - BIP39 mnemonic phrase
92
+ * @returns {Uint8Array} Seed bytes
93
+ */
94
+ function mnemonicToSeed(mnemonic) {
95
+ return bip39.mnemonicToSeedSync(mnemonic.trim());
96
+ }
97
+
98
+ /**
99
+ * Get derivation path for a chain
100
+ * @param {string} chain - Chain code
101
+ * @param {number} [index=0] - Address index
102
+ * @returns {string} BIP44 derivation path
103
+ */
104
+ export function getDerivationPath(chain, index = 0) {
105
+ const coinType = COIN_TYPES[chain];
106
+ if (coinType === undefined) {
107
+ throw new Error(`Unsupported chain: ${chain}`);
108
+ }
109
+
110
+ // BIP44 path: m / purpose' / coin_type' / account' / change / address_index
111
+ if (chain === 'SOL' || chain.startsWith('USDC_SOL') || chain.startsWith('USDT_SOL')) {
112
+ // Solana uses different derivation
113
+ return `m/44'/${coinType}'/${index}'/0'`;
114
+ }
115
+
116
+ return `m/44'/${coinType}'/0'/0/${index}`;
117
+ }
118
+
119
+ /**
120
+ * Derive key pair from seed for a specific chain
121
+ * @private
122
+ */
123
+ function deriveKeyPair(seed, chain, index = 0) {
124
+ const path = getDerivationPath(chain, index);
125
+ const hdKey = HDKey.fromMasterSeed(seed);
126
+ const derived = hdKey.derive(path);
127
+
128
+ if (!derived.privateKey) {
129
+ throw new Error('Failed to derive private key');
130
+ }
131
+
132
+ return {
133
+ privateKey: derived.privateKey,
134
+ publicKey: derived.publicKey,
135
+ path,
136
+ };
137
+ }
138
+
139
+ /**
140
+ * Get public key hex from seed for secp256k1 chains
141
+ * @private
142
+ */
143
+ function getSecp256k1PublicKey(seed) {
144
+ // Use ETH derivation for the master secp256k1 key
145
+ const hdKey = HDKey.fromMasterSeed(seed);
146
+ const derived = hdKey.derive("m/44'/60'/0'/0/0");
147
+ return bytesToHex(derived.publicKey);
148
+ }
149
+
150
+ /**
151
+ * Sign a message with secp256k1 private key
152
+ * @private
153
+ */
154
+ function signMessage(message, privateKey) {
155
+ const messageHash = sha256(new TextEncoder().encode(message));
156
+ const signature = secp256k1.sign(messageHash, privateKey);
157
+ // Handle different noble-curves versions:
158
+ // v1.x returns Signature object with toCompactHex()
159
+ // v2.x returns raw Uint8Array directly
160
+ if (signature instanceof Uint8Array) {
161
+ return bytesToHex(signature);
162
+ }
163
+ if (typeof signature.toCompactHex === 'function') {
164
+ return signature.toCompactHex();
165
+ }
166
+ // Fallback: try toCompactRawBytes
167
+ return bytesToHex(signature.toCompactRawBytes());
168
+ }
169
+
170
+ /**
171
+ * Derive address placeholder - actual address derivation happens on client
172
+ * This returns the public key that the server can use to verify ownership
173
+ * @private
174
+ */
175
+ function deriveAddressInfo(seed, chain, index = 0) {
176
+ const { publicKey, path } = deriveKeyPair(seed, chain, index);
177
+
178
+ return {
179
+ chain,
180
+ publicKey: bytesToHex(publicKey),
181
+ derivation_path: path,
182
+ derivation_index: index,
183
+ };
184
+ }
185
+
186
+ /**
187
+ * WalletClient - Manages wallet operations
188
+ *
189
+ * The wallet client handles:
190
+ * - Local key derivation (seed never leaves client)
191
+ * - Server registration of public keys/addresses
192
+ * - Authenticated API calls using signature-based auth
193
+ */
194
+ export class WalletClient {
195
+ #mnemonic;
196
+ #seed;
197
+ #walletId;
198
+ #authToken;
199
+ #baseUrl;
200
+ #timeout;
201
+ #publicKeySecp256k1;
202
+
203
+ /**
204
+ * Create a wallet client
205
+ * @private - Use WalletClient.create() or WalletClient.fromSeed() instead
206
+ */
207
+ constructor(options = {}) {
208
+ this.#baseUrl = (options.baseUrl || DEFAULT_BASE_URL).replace(/\/$/, '');
209
+ this.#timeout = options.timeout || 30000;
210
+ this.#mnemonic = options.mnemonic || null;
211
+ this.#seed = options.seed || null;
212
+ this.#walletId = options.walletId || null;
213
+ this.#authToken = options.authToken || null;
214
+ this.#publicKeySecp256k1 = options.publicKeySecp256k1 || null;
215
+ }
216
+
217
+ /**
218
+ * Create a new wallet with a fresh mnemonic
219
+ * @param {Object} options - Creation options
220
+ * @param {number} [options.words=12] - Number of words (12 or 24)
221
+ * @param {string[]} [options.chains] - Chains to derive initial addresses for
222
+ * @param {string} [options.baseUrl] - API base URL
223
+ * @param {number} [options.timeout] - Request timeout
224
+ * @returns {Promise<WalletClient>} Wallet client with fresh mnemonic
225
+ */
226
+ static async create(options = {}) {
227
+ const words = options.words || 12;
228
+ const chains = options.chains || DEFAULT_CHAINS;
229
+
230
+ const mnemonic = generateMnemonic(words);
231
+ const seed = mnemonicToSeed(mnemonic);
232
+ const publicKeySecp256k1 = getSecp256k1PublicKey(seed);
233
+
234
+ const client = new WalletClient({
235
+ baseUrl: options.baseUrl,
236
+ timeout: options.timeout,
237
+ mnemonic,
238
+ seed,
239
+ publicKeySecp256k1,
240
+ });
241
+
242
+ // Register wallet with server
243
+ const initialAddresses = chains.map(chain => {
244
+ const info = deriveAddressInfo(seed, chain, 0);
245
+ // For registration, we need to provide a placeholder address
246
+ // The actual address would be derived client-side with full implementation
247
+ return {
248
+ chain: info.chain,
249
+ address: info.publicKey.slice(0, 42), // Placeholder - real impl derives actual address
250
+ derivation_path: info.derivation_path,
251
+ };
252
+ });
253
+
254
+ const result = await client.#request('/web-wallet/create', {
255
+ method: 'POST',
256
+ body: JSON.stringify({
257
+ public_key_secp256k1: publicKeySecp256k1,
258
+ initial_addresses: initialAddresses,
259
+ }),
260
+ });
261
+
262
+ client.#walletId = result.wallet_id;
263
+
264
+ return client;
265
+ }
266
+
267
+ /**
268
+ * Import an existing wallet from a mnemonic
269
+ * @param {string} mnemonic - BIP39 mnemonic phrase
270
+ * @param {Object} options - Import options
271
+ * @param {string[]} [options.chains] - Chains to derive addresses for
272
+ * @param {string} [options.baseUrl] - API base URL
273
+ * @param {number} [options.timeout] - Request timeout
274
+ * @returns {Promise<WalletClient>} Wallet client with imported mnemonic
275
+ */
276
+ static async fromSeed(mnemonic, options = {}) {
277
+ if (!validateMnemonic(mnemonic)) {
278
+ throw new Error('Invalid mnemonic phrase');
279
+ }
280
+
281
+ const chains = options.chains || DEFAULT_CHAINS;
282
+ const seed = mnemonicToSeed(mnemonic);
283
+ const publicKeySecp256k1 = getSecp256k1PublicKey(seed);
284
+
285
+ const client = new WalletClient({
286
+ baseUrl: options.baseUrl,
287
+ timeout: options.timeout,
288
+ mnemonic: mnemonic.trim(),
289
+ seed,
290
+ publicKeySecp256k1,
291
+ });
292
+
293
+ // Create proof of ownership
294
+ const proofMessage = `CoinPay Wallet Import: ${Date.now()}`;
295
+ const { privateKey } = deriveKeyPair(seed, 'ETH', 0);
296
+ const signature = signMessage(proofMessage, privateKey);
297
+
298
+ // Derive addresses for registration
299
+ const addresses = chains.map(chain => {
300
+ const info = deriveAddressInfo(seed, chain, 0);
301
+ return {
302
+ chain: info.chain,
303
+ address: info.publicKey.slice(0, 42), // Placeholder
304
+ derivation_path: info.derivation_path,
305
+ };
306
+ });
307
+
308
+ // Register/import wallet with server
309
+ const result = await client.#request('/web-wallet/import', {
310
+ method: 'POST',
311
+ body: JSON.stringify({
312
+ public_key_secp256k1: publicKeySecp256k1,
313
+ addresses,
314
+ proof_of_ownership: {
315
+ message: proofMessage,
316
+ signature,
317
+ },
318
+ }),
319
+ });
320
+
321
+ client.#walletId = result.wallet_id;
322
+
323
+ return client;
324
+ }
325
+
326
+ /**
327
+ * Make an API request
328
+ * @private
329
+ */
330
+ async #request(endpoint, options = {}) {
331
+ const url = `${this.#baseUrl}${endpoint}`;
332
+ const controller = new AbortController();
333
+ const timeoutId = setTimeout(() => controller.abort(), this.#timeout);
334
+
335
+ const headers = {
336
+ 'Content-Type': 'application/json',
337
+ ...options.headers,
338
+ };
339
+
340
+ // Add auth token if we have one
341
+ if (this.#authToken) {
342
+ headers['Authorization'] = `Bearer ${this.#authToken}`;
343
+ }
344
+
345
+ try {
346
+ const response = await fetch(url, {
347
+ ...options,
348
+ signal: controller.signal,
349
+ headers,
350
+ });
351
+
352
+ const data = await response.json();
353
+
354
+ if (!response.ok) {
355
+ const error = new Error(data.error || `HTTP ${response.status}`);
356
+ error.status = response.status;
357
+ error.code = data.code;
358
+ error.response = data;
359
+ throw error;
360
+ }
361
+
362
+ return data;
363
+ } catch (error) {
364
+ if (error.name === 'AbortError') {
365
+ throw new Error(`Request timeout after ${this.#timeout}ms`);
366
+ }
367
+ throw error;
368
+ } finally {
369
+ clearTimeout(timeoutId);
370
+ }
371
+ }
372
+
373
+ /**
374
+ * Authenticate with the server to get a JWT token
375
+ * @returns {Promise<void>}
376
+ */
377
+ async authenticate() {
378
+ if (!this.#walletId || !this.#seed) {
379
+ throw new Error('Wallet not initialized. Use create() or fromSeed() first.');
380
+ }
381
+
382
+ // Get challenge
383
+ const { challenge } = await this.#request(`/web-wallet/auth/challenge`, {
384
+ method: 'POST',
385
+ body: JSON.stringify({ wallet_id: this.#walletId }),
386
+ });
387
+
388
+ // Sign challenge
389
+ const { privateKey } = deriveKeyPair(this.#seed, 'ETH', 0);
390
+ const signature = signMessage(challenge, privateKey);
391
+
392
+ // Verify and get token
393
+ const result = await this.#request('/web-wallet/auth/verify', {
394
+ method: 'POST',
395
+ body: JSON.stringify({
396
+ wallet_id: this.#walletId,
397
+ challenge_id: result.challenge_id,
398
+ signature,
399
+ }),
400
+ });
401
+
402
+ this.#authToken = result.auth_token;
403
+ }
404
+
405
+ /**
406
+ * Get the mnemonic phrase (for backup)
407
+ * @returns {string|null} Mnemonic phrase
408
+ */
409
+ getMnemonic() {
410
+ return this.#mnemonic;
411
+ }
412
+
413
+ /**
414
+ * Get the wallet ID
415
+ * @returns {string|null} Wallet ID
416
+ */
417
+ getWalletId() {
418
+ return this.#walletId;
419
+ }
420
+
421
+ /**
422
+ * Get wallet info
423
+ * @returns {Promise<Object>} Wallet information
424
+ */
425
+ async getInfo() {
426
+ if (!this.#walletId) {
427
+ throw new Error('Wallet not initialized');
428
+ }
429
+
430
+ return this.#request(`/web-wallet/${this.#walletId}`);
431
+ }
432
+
433
+ /**
434
+ * Get all addresses for this wallet
435
+ * @param {Object} [options] - Query options
436
+ * @param {string} [options.chain] - Filter by chain
437
+ * @param {boolean} [options.activeOnly=true] - Only return active addresses
438
+ * @returns {Promise<Object>} Address list
439
+ */
440
+ async getAddresses(options = {}) {
441
+ if (!this.#walletId) {
442
+ throw new Error('Wallet not initialized');
443
+ }
444
+
445
+ const params = new URLSearchParams();
446
+ if (options.chain) params.set('chain', options.chain);
447
+ if (options.activeOnly !== false) params.set('active_only', 'true');
448
+
449
+ const query = params.toString();
450
+ const endpoint = `/web-wallet/${this.#walletId}/addresses${query ? `?${query}` : ''}`;
451
+
452
+ return this.#request(endpoint);
453
+ }
454
+
455
+ /**
456
+ * Derive a new address for a chain
457
+ * @param {string} chain - Blockchain chain code
458
+ * @param {number} [index=0] - Derivation index
459
+ * @returns {Promise<Object>} Derived address info
460
+ */
461
+ async deriveAddress(chain, index = 0) {
462
+ if (!this.#walletId || !this.#seed) {
463
+ throw new Error('Wallet not initialized');
464
+ }
465
+
466
+ const info = deriveAddressInfo(this.#seed, chain, index);
467
+
468
+ const result = await this.#request(`/web-wallet/${this.#walletId}/derive`, {
469
+ method: 'POST',
470
+ body: JSON.stringify({
471
+ chain: info.chain,
472
+ address: info.publicKey.slice(0, 42), // Placeholder
473
+ derivation_index: info.derivation_index,
474
+ derivation_path: info.derivation_path,
475
+ }),
476
+ });
477
+
478
+ return result;
479
+ }
480
+
481
+ /**
482
+ * Derive addresses for any missing chains
483
+ * @param {string[]} [targetChains] - Chains to check (default: all supported)
484
+ * @returns {Promise<Object[]>} Newly derived addresses
485
+ */
486
+ async deriveMissingChains(targetChains) {
487
+ const chains = targetChains || Object.keys(WalletChain);
488
+ const { addresses } = await this.getAddresses({ activeOnly: true });
489
+
490
+ const existingChains = new Set(addresses.map(a => a.chain));
491
+ const missingChains = chains.filter(c => !existingChains.has(c));
492
+
493
+ const results = [];
494
+ for (const chain of missingChains) {
495
+ try {
496
+ const result = await this.deriveAddress(chain, 0);
497
+ results.push(result);
498
+ } catch (error) {
499
+ console.warn(`Failed to derive ${chain}: ${error.message}`);
500
+ }
501
+ }
502
+
503
+ return results;
504
+ }
505
+
506
+ /**
507
+ * Get all balances for this wallet
508
+ * @param {Object} [options] - Query options
509
+ * @param {string} [options.chain] - Filter by chain
510
+ * @param {boolean} [options.refresh=false] - Force refresh from blockchain
511
+ * @returns {Promise<Object>} Balances
512
+ */
513
+ async getBalances(options = {}) {
514
+ if (!this.#walletId) {
515
+ throw new Error('Wallet not initialized');
516
+ }
517
+
518
+ const params = new URLSearchParams();
519
+ if (options.chain) params.set('chain', options.chain);
520
+ if (options.refresh) params.set('refresh', 'true');
521
+
522
+ const query = params.toString();
523
+ const endpoint = `/web-wallet/${this.#walletId}/balances${query ? `?${query}` : ''}`;
524
+
525
+ return this.#request(endpoint);
526
+ }
527
+
528
+ /**
529
+ * Get balance for a specific chain
530
+ * @param {string} chain - Chain code
531
+ * @returns {Promise<Object>} Balance for the chain
532
+ */
533
+ async getBalance(chain) {
534
+ return this.getBalances({ chain });
535
+ }
536
+
537
+ /**
538
+ * Send a transaction
539
+ * @param {Object} options - Send options
540
+ * @param {string} options.chain - Target chain
541
+ * @param {string} options.to - Recipient address
542
+ * @param {string} options.amount - Amount to send
543
+ * @param {string} [options.priority='medium'] - Fee priority (low/medium/high)
544
+ * @returns {Promise<Object>} Transaction result
545
+ */
546
+ async send(options) {
547
+ if (!this.#walletId || !this.#seed) {
548
+ throw new Error('Wallet not initialized');
549
+ }
550
+
551
+ const { chain, to, amount, priority = 'medium' } = options;
552
+
553
+ if (!chain || !to || !amount) {
554
+ throw new Error('chain, to, and amount are required');
555
+ }
556
+
557
+ // Get our address for this chain
558
+ const { addresses } = await this.getAddresses({ chain });
559
+ if (!addresses || addresses.length === 0) {
560
+ throw new Error(`No address found for chain ${chain}`);
561
+ }
562
+
563
+ const fromAddress = addresses[0].address;
564
+
565
+ // Step 1: Prepare the transaction
566
+ const prepareResult = await this.#request(`/web-wallet/${this.#walletId}/prepare-tx`, {
567
+ method: 'POST',
568
+ body: JSON.stringify({
569
+ from_address: fromAddress,
570
+ to_address: to,
571
+ chain,
572
+ amount,
573
+ priority,
574
+ }),
575
+ });
576
+
577
+ // Step 2: Sign the transaction locally
578
+ // Note: This is a simplified version - real implementation would need
579
+ // chain-specific signing logic
580
+ const { privateKey } = deriveKeyPair(this.#seed, chain, 0);
581
+ const unsignedTx = prepareResult.unsigned_tx;
582
+
583
+ // For EVM chains, sign the transaction hash
584
+ // For BTC, sign each input
585
+ // This is simplified - real implementation needs chain-specific logic
586
+ const signedTx = signMessage(unsignedTx, privateKey);
587
+
588
+ // Step 3: Broadcast the signed transaction
589
+ const broadcastResult = await this.#request(`/web-wallet/${this.#walletId}/broadcast`, {
590
+ method: 'POST',
591
+ body: JSON.stringify({
592
+ tx_id: prepareResult.tx_id,
593
+ signed_tx: signedTx,
594
+ chain,
595
+ }),
596
+ });
597
+
598
+ return broadcastResult;
599
+ }
600
+
601
+ /**
602
+ * Get transaction history
603
+ * @param {Object} [options] - Query options
604
+ * @param {string} [options.chain] - Filter by chain
605
+ * @param {string} [options.direction] - Filter by direction (incoming/outgoing)
606
+ * @param {number} [options.limit=50] - Number of results
607
+ * @param {number} [options.offset=0] - Pagination offset
608
+ * @returns {Promise<Object>} Transaction history
609
+ */
610
+ async getHistory(options = {}) {
611
+ if (!this.#walletId) {
612
+ throw new Error('Wallet not initialized');
613
+ }
614
+
615
+ const params = new URLSearchParams();
616
+ if (options.chain) params.set('chain', options.chain);
617
+ if (options.direction) params.set('direction', options.direction);
618
+ if (options.limit) params.set('limit', String(options.limit));
619
+ if (options.offset) params.set('offset', String(options.offset));
620
+
621
+ const query = params.toString();
622
+ const endpoint = `/web-wallet/${this.#walletId}/transactions${query ? `?${query}` : ''}`;
623
+
624
+ return this.#request(endpoint);
625
+ }
626
+
627
+ /**
628
+ * Estimate transaction fee
629
+ * @param {string} chain - Target chain
630
+ * @param {string} [to] - Recipient address (optional, for more accurate estimate)
631
+ * @param {string} [amount] - Amount (optional, for more accurate estimate)
632
+ * @returns {Promise<Object>} Fee estimates
633
+ */
634
+ async estimateFee(chain, to, amount) {
635
+ if (!this.#walletId) {
636
+ throw new Error('Wallet not initialized');
637
+ }
638
+
639
+ const body = { chain };
640
+ if (to) body.to_address = to;
641
+ if (amount) body.amount = amount;
642
+
643
+ return this.#request(`/web-wallet/${this.#walletId}/estimate-fee`, {
644
+ method: 'POST',
645
+ body: JSON.stringify(body),
646
+ });
647
+ }
648
+
649
+ /**
650
+ * Encrypt and backup the seed phrase
651
+ * @param {string} password - Encryption password
652
+ * @returns {Promise<string>} Encrypted seed (base64)
653
+ */
654
+ async backupSeed(password) {
655
+ if (!this.#mnemonic) {
656
+ throw new Error('No mnemonic available');
657
+ }
658
+
659
+ if (!password || password.length < 8) {
660
+ throw new Error('Password must be at least 8 characters');
661
+ }
662
+
663
+ // Simple encryption using Web Crypto API
664
+ const encoder = new TextEncoder();
665
+ const data = encoder.encode(this.#mnemonic);
666
+
667
+ // Derive key from password
668
+ const keyMaterial = await crypto.subtle.importKey(
669
+ 'raw',
670
+ encoder.encode(password),
671
+ 'PBKDF2',
672
+ false,
673
+ ['deriveBits', 'deriveKey']
674
+ );
675
+
676
+ const salt = crypto.getRandomValues(new Uint8Array(16));
677
+ const key = await crypto.subtle.deriveKey(
678
+ {
679
+ name: 'PBKDF2',
680
+ salt,
681
+ iterations: 100000,
682
+ hash: 'SHA-256',
683
+ },
684
+ keyMaterial,
685
+ { name: 'AES-GCM', length: 256 },
686
+ false,
687
+ ['encrypt']
688
+ );
689
+
690
+ const iv = crypto.getRandomValues(new Uint8Array(12));
691
+ const encrypted = await crypto.subtle.encrypt(
692
+ { name: 'AES-GCM', iv },
693
+ key,
694
+ data
695
+ );
696
+
697
+ // Combine salt + iv + encrypted data
698
+ const combined = new Uint8Array(salt.length + iv.length + encrypted.byteLength);
699
+ combined.set(salt, 0);
700
+ combined.set(iv, salt.length);
701
+ combined.set(new Uint8Array(encrypted), salt.length + iv.length);
702
+
703
+ // Return as base64
704
+ return btoa(String.fromCharCode(...combined));
705
+ }
706
+ }
707
+
708
+ /**
709
+ * Restore a seed from encrypted backup
710
+ * @param {string} encryptedBackup - Base64 encrypted backup
711
+ * @param {string} password - Decryption password
712
+ * @returns {Promise<string>} Decrypted mnemonic
713
+ */
714
+ export async function restoreFromBackup(encryptedBackup, password) {
715
+ const encoder = new TextEncoder();
716
+ const decoder = new TextDecoder();
717
+
718
+ // Decode base64
719
+ const combined = Uint8Array.from(atob(encryptedBackup), c => c.charCodeAt(0));
720
+
721
+ // Extract salt, iv, and encrypted data
722
+ const salt = combined.slice(0, 16);
723
+ const iv = combined.slice(16, 28);
724
+ const encrypted = combined.slice(28);
725
+
726
+ // Derive key from password
727
+ const keyMaterial = await crypto.subtle.importKey(
728
+ 'raw',
729
+ encoder.encode(password),
730
+ 'PBKDF2',
731
+ false,
732
+ ['deriveBits', 'deriveKey']
733
+ );
734
+
735
+ const key = await crypto.subtle.deriveKey(
736
+ {
737
+ name: 'PBKDF2',
738
+ salt,
739
+ iterations: 100000,
740
+ hash: 'SHA-256',
741
+ },
742
+ keyMaterial,
743
+ { name: 'AES-GCM', length: 256 },
744
+ false,
745
+ ['decrypt']
746
+ );
747
+
748
+ const decrypted = await crypto.subtle.decrypt(
749
+ { name: 'AES-GCM', iv },
750
+ key,
751
+ encrypted
752
+ );
753
+
754
+ return decoder.decode(decrypted);
755
+ }
756
+
757
+ export default WalletClient;