@arkade-os/sdk 0.3.8 → 0.3.9

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.
Files changed (41) hide show
  1. package/README.md +78 -1
  2. package/dist/cjs/identity/singleKey.js +33 -1
  3. package/dist/cjs/index.js +17 -2
  4. package/dist/cjs/intent/index.js +31 -2
  5. package/dist/cjs/providers/ark.js +9 -3
  6. package/dist/cjs/providers/indexer.js +2 -2
  7. package/dist/cjs/wallet/batch.js +183 -0
  8. package/dist/cjs/wallet/index.js +15 -0
  9. package/dist/cjs/wallet/serviceWorker/request.js +0 -2
  10. package/dist/cjs/wallet/serviceWorker/wallet.js +98 -34
  11. package/dist/cjs/wallet/serviceWorker/worker.js +163 -72
  12. package/dist/cjs/wallet/utils.js +2 -2
  13. package/dist/cjs/wallet/vtxo-manager.js +5 -0
  14. package/dist/cjs/wallet/wallet.js +341 -359
  15. package/dist/esm/identity/singleKey.js +31 -0
  16. package/dist/esm/index.js +12 -7
  17. package/dist/esm/intent/index.js +31 -2
  18. package/dist/esm/providers/ark.js +9 -3
  19. package/dist/esm/providers/indexer.js +2 -2
  20. package/dist/esm/wallet/batch.js +180 -0
  21. package/dist/esm/wallet/index.js +14 -0
  22. package/dist/esm/wallet/serviceWorker/request.js +0 -2
  23. package/dist/esm/wallet/serviceWorker/wallet.js +96 -33
  24. package/dist/esm/wallet/serviceWorker/worker.js +165 -74
  25. package/dist/esm/wallet/utils.js +2 -2
  26. package/dist/esm/wallet/vtxo-manager.js +6 -1
  27. package/dist/esm/wallet/wallet.js +342 -362
  28. package/dist/types/identity/index.d.ts +5 -3
  29. package/dist/types/identity/singleKey.d.ts +20 -1
  30. package/dist/types/index.d.ts +11 -8
  31. package/dist/types/intent/index.d.ts +19 -2
  32. package/dist/types/providers/ark.d.ts +9 -8
  33. package/dist/types/providers/indexer.d.ts +2 -2
  34. package/dist/types/wallet/batch.d.ts +87 -0
  35. package/dist/types/wallet/index.d.ts +75 -16
  36. package/dist/types/wallet/serviceWorker/request.d.ts +5 -1
  37. package/dist/types/wallet/serviceWorker/wallet.d.ts +46 -15
  38. package/dist/types/wallet/serviceWorker/worker.d.ts +6 -3
  39. package/dist/types/wallet/utils.d.ts +8 -3
  40. package/dist/types/wallet/wallet.d.ts +87 -36
  41. package/package.json +123 -113
@@ -1,92 +1,58 @@
1
1
  import { base64, hex } from "@scure/base";
2
2
  import * as bip68 from "bip68";
3
3
  import { tapLeafHash } from "@scure/btc-signer/payment.js";
4
- import { SigHash, Transaction, Address, OutScript, } from "@scure/btc-signer";
4
+ import { SigHash, Transaction, Address, OutScript } from "@scure/btc-signer";
5
5
  import { sha256 } from "@scure/btc-signer/utils.js";
6
6
  import { vtxosToTxs } from '../utils/transactionHistory.js';
7
7
  import { ArkAddress } from '../script/address.js';
8
8
  import { DefaultVtxo } from '../script/default.js';
9
9
  import { getNetwork } from '../networks.js';
10
10
  import { ESPLORA_URL, EsploraProvider, } from '../providers/onchain.js';
11
- import { SettlementEventType, RestArkProvider, } from '../providers/ark.js';
11
+ import { RestArkProvider, } from '../providers/ark.js';
12
12
  import { buildForfeitTx } from '../forfeit.js';
13
13
  import { validateConnectorsTxGraph, validateVtxoTxGraph, } from '../tree/validation.js';
14
- import { isRecoverable, isSpendable, isSubdust, TxType, } from './index.js';
14
+ import { isExpired, isRecoverable, isSpendable, isSubdust, TxType, } from './index.js';
15
15
  import { VtxoScript } from '../script/base.js';
16
- import { CSVMultisigTapscript } from '../script/tapscript.js';
16
+ import { CLTVMultisigTapscript, CSVMultisigTapscript, } from '../script/tapscript.js';
17
17
  import { buildOffchainTx, hasBoardingTxExpired } from '../utils/arkTransaction.js';
18
18
  import { DEFAULT_RENEWAL_CONFIG } from './vtxo-manager.js';
19
19
  import { ArkNote } from '../arknote/index.js';
20
20
  import { Intent } from '../intent/index.js';
21
21
  import { RestIndexerProvider } from '../providers/indexer.js';
22
- import { TxTree } from '../tree/txTree.js';
23
22
  import { ConditionWitness, VtxoTaprootTree } from '../utils/unknownFields.js';
24
23
  import { InMemoryStorageAdapter } from '../storage/inMemory.js';
25
24
  import { WalletRepositoryImpl, } from '../repositories/walletRepository.js';
26
25
  import { ContractRepositoryImpl, } from '../repositories/contractRepository.js';
27
26
  import { extendCoin, extendVirtualCoin } from './utils.js';
28
27
  import { ArkError } from '../providers/errors.js';
28
+ import { Batch } from './batch.js';
29
29
  /**
30
- * Main wallet implementation for Bitcoin transactions with Ark protocol support.
31
- * The wallet does not store any data locally and relies on Ark and onchain
32
- * providers to fetch UTXOs and VTXOs.
33
- *
34
- * @example
35
- * ```typescript
36
- * // Create a wallet with URL configuration
37
- * const wallet = await Wallet.create({
38
- * identity: SingleKey.fromHex('your_private_key'),
39
- * arkServerUrl: 'https://ark.example.com',
40
- * esploraUrl: 'https://mempool.space/api'
41
- * });
42
- *
43
- * // Or with custom provider instances (e.g., for Expo/React Native)
44
- * const wallet = await Wallet.create({
45
- * identity: SingleKey.fromHex('your_private_key'),
46
- * arkProvider: new ExpoArkProvider('https://ark.example.com'),
47
- * indexerProvider: new ExpoIndexerProvider('https://ark.example.com'),
48
- * esploraUrl: 'https://mempool.space/api'
49
- * });
50
- *
51
- * // Get addresses
52
- * const arkAddress = await wallet.getAddress();
53
- * const boardingAddress = await wallet.getBoardingAddress();
54
- *
55
- * // Send bitcoin
56
- * const txid = await wallet.sendBitcoin({
57
- * address: 'tb1...',
58
- * amount: 50000
59
- * });
60
- * ```
30
+ * Type guard function to check if an identity has a toReadonly method.
61
31
  */
62
- export class Wallet {
63
- constructor(identity, network, networkName, onchainProvider, arkProvider, indexerProvider, arkServerPublicKey, offchainTapscript, boardingTapscript, serverUnrollScript, forfeitOutputScript, forfeitPubkey, dustAmount, walletRepository, contractRepository, renewalConfig) {
32
+ function hasToReadonly(identity) {
33
+ return (typeof identity === "object" &&
34
+ identity !== null &&
35
+ "toReadonly" in identity &&
36
+ typeof identity.toReadonly === "function");
37
+ }
38
+ export class ReadonlyWallet {
39
+ constructor(identity, network, onchainProvider, indexerProvider, arkServerPublicKey, offchainTapscript, boardingTapscript, dustAmount, walletRepository, contractRepository) {
64
40
  this.identity = identity;
65
41
  this.network = network;
66
- this.networkName = networkName;
67
42
  this.onchainProvider = onchainProvider;
68
- this.arkProvider = arkProvider;
69
43
  this.indexerProvider = indexerProvider;
70
44
  this.arkServerPublicKey = arkServerPublicKey;
71
45
  this.offchainTapscript = offchainTapscript;
72
46
  this.boardingTapscript = boardingTapscript;
73
- this.serverUnrollScript = serverUnrollScript;
74
- this.forfeitOutputScript = forfeitOutputScript;
75
- this.forfeitPubkey = forfeitPubkey;
76
47
  this.dustAmount = dustAmount;
77
48
  this.walletRepository = walletRepository;
78
49
  this.contractRepository = contractRepository;
79
- this.renewalConfig = {
80
- enabled: renewalConfig?.enabled ?? false,
81
- ...DEFAULT_RENEWAL_CONFIG,
82
- ...renewalConfig,
83
- };
84
50
  }
85
- static async create(config) {
86
- const pubkey = await config.identity.xOnlyPublicKey();
87
- if (!pubkey) {
88
- throw new Error("Invalid configured public key");
89
- }
51
+ /**
52
+ * Protected helper to set up shared wallet configuration.
53
+ * Extracts common logic used by both ReadonlyWallet.create() and Wallet.create().
54
+ */
55
+ static async setupWalletConfig(config, pubkey) {
90
56
  // Use provided arkProvider instance or create a new one from arkServerUrl
91
57
  const arkProvider = config.arkProvider ||
92
58
  (() => {
@@ -150,25 +116,32 @@ export class Wallet {
150
116
  });
151
117
  // Save tapscripts
152
118
  const offchainTapscript = bareVtxoTapscript;
153
- // the serverUnrollScript is the one used to create output scripts of the checkpoint transactions
154
- let serverUnrollScript;
155
- try {
156
- const raw = hex.decode(info.checkpointTapscript);
157
- serverUnrollScript = CSVMultisigTapscript.decode(raw);
158
- }
159
- catch (e) {
160
- throw new Error("Invalid checkpointTapscript from server");
161
- }
162
- // parse the server forfeit address
163
- // server is expecting funds to be sent to this address
164
- const forfeitPubkey = hex.decode(info.forfeitPubkey).slice(1);
165
- const forfeitAddress = Address(network).decode(info.forfeitAddress);
166
- const forfeitOutputScript = OutScript.encode(forfeitAddress);
167
119
  // Set up storage and repositories
168
120
  const storage = config.storage || new InMemoryStorageAdapter();
169
121
  const walletRepository = new WalletRepositoryImpl(storage);
170
122
  const contractRepository = new ContractRepositoryImpl(storage);
171
- return new Wallet(config.identity, network, info.network, onchainProvider, arkProvider, indexerProvider, serverPubKey, offchainTapscript, boardingTapscript, serverUnrollScript, forfeitOutputScript, forfeitPubkey, info.dust, walletRepository, contractRepository, config.renewalConfig);
123
+ return {
124
+ arkProvider,
125
+ indexerProvider,
126
+ onchainProvider,
127
+ network,
128
+ networkName: info.network,
129
+ serverPubKey,
130
+ offchainTapscript,
131
+ boardingTapscript,
132
+ dustAmount: info.dust,
133
+ walletRepository,
134
+ contractRepository,
135
+ info,
136
+ };
137
+ }
138
+ static async create(config) {
139
+ const pubkey = await config.identity.xOnlyPublicKey();
140
+ if (!pubkey) {
141
+ throw new Error("Invalid configured public key");
142
+ }
143
+ const setup = await ReadonlyWallet.setupWalletConfig(config, pubkey);
144
+ return new ReadonlyWallet(config.identity, setup.network, setup.onchainProvider, setup.indexerProvider, setup.serverPubKey, setup.offchainTapscript, setup.boardingTapscript, setup.dustAmount, setup.walletRepository, setup.contractRepository);
172
145
  }
173
146
  get arkAddress() {
174
147
  return this.offchainTapscript.address(this.network.hrp, this.arkServerPublicKey);
@@ -243,7 +216,7 @@ export class Wallet {
243
216
  let vtxos = allVtxos.filter(isSpendable);
244
217
  // all recoverable vtxos are spendable by definition
245
218
  if (!filter.withRecoverable) {
246
- vtxos = vtxos.filter((vtxo) => !isRecoverable(vtxo));
219
+ vtxos = vtxos.filter((vtxo) => !isRecoverable(vtxo) && !isExpired(vtxo));
247
220
  }
248
221
  if (filter.withUnrolled) {
249
222
  const spentVtxos = allVtxos.filter((vtxo) => !isSpendable(vtxo));
@@ -252,9 +225,6 @@ export class Wallet {
252
225
  return vtxos;
253
226
  }
254
227
  async getTransactionHistory() {
255
- if (!this.indexerProvider) {
256
- return [];
257
- }
258
228
  const response = await this.indexerProvider.getVtxos({
259
229
  scripts: [hex.encode(this.offchainTapscript.pkScript)],
260
230
  });
@@ -358,6 +328,178 @@ export class Wallet {
358
328
  await this.walletRepository.saveUtxos(boardingAddress, utxos);
359
329
  return utxos;
360
330
  }
331
+ async notifyIncomingFunds(eventCallback) {
332
+ const arkAddress = await this.getAddress();
333
+ const boardingAddress = await this.getBoardingAddress();
334
+ let onchainStopFunc;
335
+ let indexerStopFunc;
336
+ if (this.onchainProvider && boardingAddress) {
337
+ const findVoutOnTx = (tx) => {
338
+ return tx.vout.findIndex((v) => v.scriptpubkey_address === boardingAddress);
339
+ };
340
+ onchainStopFunc = await this.onchainProvider.watchAddresses([boardingAddress], (txs) => {
341
+ // find all utxos belonging to our boarding address
342
+ const coins = txs
343
+ // filter txs where address is in output
344
+ .filter((tx) => findVoutOnTx(tx) !== -1)
345
+ // return utxo as Coin
346
+ .map((tx) => {
347
+ const { txid, status } = tx;
348
+ const vout = findVoutOnTx(tx);
349
+ const value = Number(tx.vout[vout].value);
350
+ return { txid, vout, value, status };
351
+ });
352
+ // and notify via callback
353
+ eventCallback({
354
+ type: "utxo",
355
+ coins,
356
+ });
357
+ });
358
+ }
359
+ if (this.indexerProvider && arkAddress) {
360
+ const offchainScript = this.offchainTapscript;
361
+ const subscriptionId = await this.indexerProvider.subscribeForScripts([
362
+ hex.encode(offchainScript.pkScript),
363
+ ]);
364
+ const abortController = new AbortController();
365
+ const subscription = this.indexerProvider.getSubscription(subscriptionId, abortController.signal);
366
+ indexerStopFunc = async () => {
367
+ abortController.abort();
368
+ await this.indexerProvider?.unsubscribeForScripts(subscriptionId);
369
+ };
370
+ // Handle subscription updates asynchronously without blocking
371
+ (async () => {
372
+ try {
373
+ for await (const update of subscription) {
374
+ if (update.newVtxos?.length > 0 ||
375
+ update.spentVtxos?.length > 0) {
376
+ eventCallback({
377
+ type: "vtxo",
378
+ newVtxos: update.newVtxos.map((vtxo) => extendVirtualCoin(this, vtxo)),
379
+ spentVtxos: update.spentVtxos.map((vtxo) => extendVirtualCoin(this, vtxo)),
380
+ });
381
+ }
382
+ }
383
+ }
384
+ catch (error) {
385
+ console.error("Subscription error:", error);
386
+ }
387
+ })();
388
+ }
389
+ const stopFunc = () => {
390
+ onchainStopFunc?.();
391
+ indexerStopFunc?.();
392
+ };
393
+ return stopFunc;
394
+ }
395
+ async fetchPendingTxs() {
396
+ // get non-swept VTXOs, rely on the indexer only in case DB doesn't have the right state
397
+ const scripts = [hex.encode(this.offchainTapscript.pkScript)];
398
+ let { vtxos } = await this.indexerProvider.getVtxos({
399
+ scripts,
400
+ });
401
+ return vtxos
402
+ .filter((vtxo) => vtxo.virtualStatus.state !== "swept" &&
403
+ vtxo.virtualStatus.state !== "settled" &&
404
+ vtxo.arkTxId !== undefined)
405
+ .map((_) => _.arkTxId);
406
+ }
407
+ }
408
+ /**
409
+ * Main wallet implementation for Bitcoin transactions with Ark protocol support.
410
+ * The wallet does not store any data locally and relies on Ark and onchain
411
+ * providers to fetch UTXOs and VTXOs.
412
+ *
413
+ * @example
414
+ * ```typescript
415
+ * // Create a wallet with URL configuration
416
+ * const wallet = await Wallet.create({
417
+ * identity: SingleKey.fromHex('your_private_key'),
418
+ * arkServerUrl: 'https://ark.example.com',
419
+ * esploraUrl: 'https://mempool.space/api'
420
+ * });
421
+ *
422
+ * // Or with custom provider instances (e.g., for Expo/React Native)
423
+ * const wallet = await Wallet.create({
424
+ * identity: SingleKey.fromHex('your_private_key'),
425
+ * arkProvider: new ExpoArkProvider('https://ark.example.com'),
426
+ * indexerProvider: new ExpoIndexerProvider('https://ark.example.com'),
427
+ * esploraUrl: 'https://mempool.space/api'
428
+ * });
429
+ *
430
+ * // Get addresses
431
+ * const arkAddress = await wallet.getAddress();
432
+ * const boardingAddress = await wallet.getBoardingAddress();
433
+ *
434
+ * // Send bitcoin
435
+ * const txid = await wallet.sendBitcoin({
436
+ * address: 'tb1...',
437
+ * amount: 50000
438
+ * });
439
+ * ```
440
+ */
441
+ export class Wallet extends ReadonlyWallet {
442
+ constructor(identity, network, networkName, onchainProvider, arkProvider, indexerProvider, arkServerPublicKey, offchainTapscript, boardingTapscript, serverUnrollScript, forfeitOutputScript, forfeitPubkey, dustAmount, walletRepository, contractRepository, renewalConfig) {
443
+ super(identity, network, onchainProvider, indexerProvider, arkServerPublicKey, offchainTapscript, boardingTapscript, dustAmount, walletRepository, contractRepository);
444
+ this.networkName = networkName;
445
+ this.arkProvider = arkProvider;
446
+ this.serverUnrollScript = serverUnrollScript;
447
+ this.forfeitOutputScript = forfeitOutputScript;
448
+ this.forfeitPubkey = forfeitPubkey;
449
+ this.identity = identity;
450
+ this.renewalConfig = {
451
+ enabled: renewalConfig?.enabled ?? false,
452
+ ...DEFAULT_RENEWAL_CONFIG,
453
+ ...renewalConfig,
454
+ };
455
+ }
456
+ static async create(config) {
457
+ const pubkey = await config.identity.xOnlyPublicKey();
458
+ if (!pubkey) {
459
+ throw new Error("Invalid configured public key");
460
+ }
461
+ const setup = await ReadonlyWallet.setupWalletConfig(config, pubkey);
462
+ // Compute Wallet-specific forfeit and unroll scripts
463
+ // the serverUnrollScript is the one used to create output scripts of the checkpoint transactions
464
+ let serverUnrollScript;
465
+ try {
466
+ const raw = hex.decode(setup.info.checkpointTapscript);
467
+ serverUnrollScript = CSVMultisigTapscript.decode(raw);
468
+ }
469
+ catch (e) {
470
+ throw new Error("Invalid checkpointTapscript from server");
471
+ }
472
+ // parse the server forfeit address
473
+ // server is expecting funds to be sent to this address
474
+ const forfeitPubkey = hex.decode(setup.info.forfeitPubkey).slice(1);
475
+ const forfeitAddress = Address(setup.network).decode(setup.info.forfeitAddress);
476
+ const forfeitOutputScript = OutScript.encode(forfeitAddress);
477
+ return new Wallet(config.identity, setup.network, setup.networkName, setup.onchainProvider, setup.arkProvider, setup.indexerProvider, setup.serverPubKey, setup.offchainTapscript, setup.boardingTapscript, serverUnrollScript, forfeitOutputScript, forfeitPubkey, setup.dustAmount, setup.walletRepository, setup.contractRepository, config.renewalConfig);
478
+ }
479
+ /**
480
+ * Convert this wallet to a readonly wallet.
481
+ *
482
+ * @returns A readonly wallet with the same configuration but readonly identity
483
+ * @example
484
+ * ```typescript
485
+ * const wallet = await Wallet.create({ identity: SingleKey.fromHex('...'), ... });
486
+ * const readonlyWallet = await wallet.toReadonly();
487
+ *
488
+ * // Can query balance and addresses
489
+ * const balance = await readonlyWallet.getBalance();
490
+ * const address = await readonlyWallet.getAddress();
491
+ *
492
+ * // But cannot send transactions (type error)
493
+ * // readonlyWallet.sendBitcoin(...); // TypeScript error
494
+ * ```
495
+ */
496
+ async toReadonly() {
497
+ // Check if the identity has a toReadonly method using type guard
498
+ const readonlyIdentity = hasToReadonly(this.identity)
499
+ ? await this.identity.toReadonly()
500
+ : this.identity; // Identity extends ReadonlyIdentity, so this is safe
501
+ return new ReadonlyWallet(readonlyIdentity, this.network, this.onchainProvider, this.indexerProvider, this.arkServerPublicKey, this.offchainTapscript, this.boardingTapscript, this.dustAmount, this.walletRepository, this.contractRepository);
502
+ }
361
503
  async sendBitcoin(params) {
362
504
  if (params.amount <= 0) {
363
505
  throw new Error("Amount must be positive");
@@ -444,7 +586,7 @@ export class Wallet {
444
586
  vout: outputs.length - 1,
445
587
  createdAt: new Date(createdAt),
446
588
  forfeitTapLeafScript: this.offchainTapscript.forfeit(),
447
- intentTapLeafScript: this.offchainTapscript.exit(),
589
+ intentTapLeafScript: this.offchainTapscript.forfeit(),
448
590
  isUnrolled: false,
449
591
  isSpent: false,
450
592
  tapTree: this.offchainTapscript.encode(),
@@ -554,285 +696,31 @@ export class Wallet {
554
696
  this.makeDeleteIntentSignature(params.inputs),
555
697
  ]);
556
698
  const intentId = await this.safeRegisterIntent(intent);
699
+ const topics = [
700
+ ...signingPublicKeys,
701
+ ...params.inputs.map((input) => `${input.txid}:${input.vout}`),
702
+ ];
703
+ const handler = this.createBatchHandler(intentId, params.inputs, session);
557
704
  const abortController = new AbortController();
558
- // listen to settlement events
559
705
  try {
560
- let step;
561
- const topics = [
562
- ...signingPublicKeys,
563
- ...params.inputs.map((input) => `${input.txid}:${input.vout}`),
564
- ];
565
- const settlementStream = this.arkProvider.getEventStream(abortController.signal, topics);
566
- // batchId, sweepTapTreeRoot and forfeitOutputScript are set once the BatchStarted event is received
567
- let batchId;
568
- let sweepTapTreeRoot;
569
- const vtxoChunks = [];
570
- const connectorsChunks = [];
571
- let vtxoGraph;
572
- let connectorsGraph;
573
- for await (const event of settlementStream) {
574
- if (eventCallback) {
575
- eventCallback(event);
576
- }
577
- switch (event.type) {
578
- // the settlement failed
579
- case SettlementEventType.BatchFailed:
580
- throw new Error(event.reason);
581
- case SettlementEventType.BatchStarted:
582
- if (step !== undefined) {
583
- continue;
584
- }
585
- const res = await this.handleBatchStartedEvent(event, intentId, this.forfeitPubkey, this.forfeitOutputScript);
586
- if (!res.skip) {
587
- step = event.type;
588
- sweepTapTreeRoot = res.sweepTapTreeRoot;
589
- batchId = res.roundId;
590
- if (!hasOffchainOutputs) {
591
- // if there are no offchain outputs, we don't have to handle musig2 tree signatures
592
- // we can directly advance to the finalization step
593
- step = SettlementEventType.TreeNonces;
594
- }
595
- }
596
- break;
597
- case SettlementEventType.TreeTx:
598
- if (step !== SettlementEventType.BatchStarted &&
599
- step !== SettlementEventType.TreeNonces) {
600
- continue;
601
- }
602
- // index 0 = vtxo tree
603
- if (event.batchIndex === 0) {
604
- vtxoChunks.push(event.chunk);
605
- // index 1 = connectors tree
606
- }
607
- else if (event.batchIndex === 1) {
608
- connectorsChunks.push(event.chunk);
609
- }
610
- else {
611
- throw new Error(`Invalid batch index: ${event.batchIndex}`);
612
- }
613
- break;
614
- case SettlementEventType.TreeSignature:
615
- if (step !== SettlementEventType.TreeNonces) {
616
- continue;
617
- }
618
- if (!hasOffchainOutputs) {
619
- continue;
620
- }
621
- if (!vtxoGraph) {
622
- throw new Error("Vtxo graph not set, something went wrong");
623
- }
624
- // index 0 = vtxo graph
625
- if (event.batchIndex === 0) {
626
- const tapKeySig = hex.decode(event.signature);
627
- vtxoGraph.update(event.txid, (tx) => {
628
- tx.updateInput(0, {
629
- tapKeySig,
630
- });
631
- });
632
- }
633
- break;
634
- // the server has started the signing process of the vtxo tree transactions
635
- // the server expects the partial musig2 nonces for each tx
636
- case SettlementEventType.TreeSigningStarted:
637
- if (step !== SettlementEventType.BatchStarted) {
638
- continue;
639
- }
640
- if (hasOffchainOutputs) {
641
- if (!session) {
642
- throw new Error("Signing session not set");
643
- }
644
- if (!sweepTapTreeRoot) {
645
- throw new Error("Sweep tap tree root not set");
646
- }
647
- if (vtxoChunks.length === 0) {
648
- throw new Error("unsigned vtxo graph not received");
649
- }
650
- vtxoGraph = TxTree.create(vtxoChunks);
651
- await this.handleSettlementSigningEvent(event, sweepTapTreeRoot, session, vtxoGraph);
652
- }
653
- step = event.type;
654
- break;
655
- // the musig2 nonces of the vtxo tree transactions are generated
656
- // the server expects now the partial musig2 signatures
657
- case SettlementEventType.TreeNonces:
658
- if (step !== SettlementEventType.TreeSigningStarted) {
659
- continue;
660
- }
661
- if (hasOffchainOutputs) {
662
- if (!session) {
663
- throw new Error("Signing session not set");
664
- }
665
- const signed = await this.handleSettlementTreeNoncesEvent(event, session);
666
- if (signed) {
667
- step = event.type;
668
- }
669
- break;
670
- }
671
- step = event.type;
672
- break;
673
- // the vtxo tree is signed, craft, sign and submit forfeit transactions
674
- // if any boarding utxos are involved, the settlement tx is also signed
675
- case SettlementEventType.BatchFinalization:
676
- if (step !== SettlementEventType.TreeNonces) {
677
- continue;
678
- }
679
- if (!this.forfeitOutputScript) {
680
- throw new Error("Forfeit output script not set");
681
- }
682
- if (connectorsChunks.length > 0) {
683
- connectorsGraph = TxTree.create(connectorsChunks);
684
- validateConnectorsTxGraph(event.commitmentTx, connectorsGraph);
685
- }
686
- await this.handleSettlementFinalizationEvent(event, params.inputs, this.forfeitOutputScript, connectorsGraph);
687
- step = event.type;
688
- break;
689
- // the settlement is done, last event to be received
690
- case SettlementEventType.BatchFinalized:
691
- if (step !== SettlementEventType.BatchFinalization) {
692
- continue;
693
- }
694
- if (event.id === batchId) {
695
- abortController.abort();
696
- return event.commitmentTxid;
697
- }
698
- }
699
- }
706
+ const stream = this.arkProvider.getEventStream(abortController.signal, topics);
707
+ return await Batch.join(stream, handler, {
708
+ abortController,
709
+ skipVtxoTreeSigning: !hasOffchainOutputs,
710
+ eventCallback: eventCallback
711
+ ? (event) => Promise.resolve(eventCallback(event))
712
+ : undefined,
713
+ });
700
714
  }
701
715
  catch (error) {
702
- // close the stream
703
- abortController.abort();
704
- try {
705
- // delete the intent to not be stuck in the queue
706
- await this.arkProvider.deleteIntent(deleteIntent);
707
- }
708
- catch (error) {
709
- console.error("failed to delete intent: ", error);
710
- }
716
+ // delete the intent to not be stuck in the queue
717
+ await this.arkProvider.deleteIntent(deleteIntent).catch(() => { });
711
718
  throw error;
712
719
  }
713
- throw new Error("Settlement failed");
714
- }
715
- async notifyIncomingFunds(eventCallback) {
716
- const arkAddress = await this.getAddress();
717
- const boardingAddress = await this.getBoardingAddress();
718
- let onchainStopFunc;
719
- let indexerStopFunc;
720
- if (this.onchainProvider && boardingAddress) {
721
- const findVoutOnTx = (tx) => {
722
- return tx.vout.findIndex((v) => v.scriptpubkey_address === boardingAddress);
723
- };
724
- onchainStopFunc = await this.onchainProvider.watchAddresses([boardingAddress], (txs) => {
725
- // find all utxos belonging to our boarding address
726
- const coins = txs
727
- // filter txs where address is in output
728
- .filter((tx) => findVoutOnTx(tx) !== -1)
729
- // return utxo as Coin
730
- .map((tx) => {
731
- const { txid, status } = tx;
732
- const vout = findVoutOnTx(tx);
733
- const value = Number(tx.vout[vout].value);
734
- return { txid, vout, value, status };
735
- });
736
- // and notify via callback
737
- eventCallback({
738
- type: "utxo",
739
- coins,
740
- });
741
- });
742
- }
743
- if (this.indexerProvider && arkAddress) {
744
- const offchainScript = this.offchainTapscript;
745
- const subscriptionId = await this.indexerProvider.subscribeForScripts([
746
- hex.encode(offchainScript.pkScript),
747
- ]);
748
- const abortController = new AbortController();
749
- const subscription = this.indexerProvider.getSubscription(subscriptionId, abortController.signal);
750
- indexerStopFunc = async () => {
751
- abortController.abort();
752
- await this.indexerProvider?.unsubscribeForScripts(subscriptionId);
753
- };
754
- // Handle subscription updates asynchronously without blocking
755
- (async () => {
756
- try {
757
- for await (const update of subscription) {
758
- if (update.newVtxos?.length > 0 ||
759
- update.spentVtxos?.length > 0) {
760
- eventCallback({
761
- type: "vtxo",
762
- newVtxos: update.newVtxos.map((vtxo) => extendVirtualCoin(this, vtxo)),
763
- spentVtxos: update.spentVtxos.map((vtxo) => extendVirtualCoin(this, vtxo)),
764
- });
765
- }
766
- }
767
- }
768
- catch (error) {
769
- console.error("Subscription error:", error);
770
- }
771
- })();
772
- }
773
- const stopFunc = () => {
774
- onchainStopFunc?.();
775
- indexerStopFunc?.();
776
- };
777
- return stopFunc;
778
- }
779
- async handleBatchStartedEvent(event, intentId, forfeitPubKey, forfeitOutputScript) {
780
- const utf8IntentId = new TextEncoder().encode(intentId);
781
- const intentIdHash = sha256(utf8IntentId);
782
- const intentIdHashStr = hex.encode(intentIdHash);
783
- let skip = true;
784
- // check if our intent ID hash matches any in the event
785
- for (const idHash of event.intentIdHashes) {
786
- if (idHash === intentIdHashStr) {
787
- if (!this.arkProvider) {
788
- throw new Error("Ark provider not configured");
789
- }
790
- await this.arkProvider.confirmRegistration(intentId);
791
- skip = false;
792
- }
793
- }
794
- if (skip) {
795
- return { skip };
796
- }
797
- const sweepTapscript = CSVMultisigTapscript.encode({
798
- timelock: {
799
- value: event.batchExpiry,
800
- type: event.batchExpiry >= 512n ? "seconds" : "blocks",
801
- },
802
- pubkeys: [forfeitPubKey],
803
- }).script;
804
- const sweepTapTreeRoot = tapLeafHash(sweepTapscript);
805
- return {
806
- roundId: event.id,
807
- sweepTapTreeRoot,
808
- forfeitOutputScript,
809
- skip: false,
810
- };
811
- }
812
- // validates the vtxo tree, creates a signing session and generates the musig2 nonces
813
- async handleSettlementSigningEvent(event, sweepTapTreeRoot, session, vtxoGraph) {
814
- // validate the unsigned vtxo tree
815
- const commitmentTx = Transaction.fromPSBT(base64.decode(event.unsignedCommitmentTx));
816
- validateVtxoTxGraph(vtxoGraph, commitmentTx, sweepTapTreeRoot);
817
- // TODO check if our registered outputs are in the vtxo tree
818
- const sharedOutput = commitmentTx.getOutput(0);
819
- if (!sharedOutput?.amount) {
820
- throw new Error("Shared output not found");
720
+ finally {
721
+ // close the stream
722
+ abortController.abort();
821
723
  }
822
- session.init(vtxoGraph, sweepTapTreeRoot, sharedOutput.amount);
823
- const pubkey = hex.encode(await session.getPublicKey());
824
- const nonces = await session.getNonces();
825
- await this.arkProvider.submitTreeNonces(event.id, pubkey, nonces);
826
- }
827
- async handleSettlementTreeNoncesEvent(event, session) {
828
- const { hasAllNonces } = await session.aggregatedNonces(event.txid, event.nonces);
829
- // wait to receive and aggregate all nonces before sending signatures
830
- if (!hasAllNonces)
831
- return false;
832
- const signatures = await session.sign();
833
- const pubkey = hex.encode(await session.getPublicKey());
834
- await this.arkProvider.submitTreeSignatures(event.id, pubkey, signatures);
835
- return true;
836
724
  }
837
725
  async handleSettlementFinalizationEvent(event, inputs, forfeitOutputScript, connectorsGraph) {
838
726
  // the signed forfeits transactions to submit
@@ -922,9 +810,98 @@ export class Wallet {
922
810
  : undefined);
923
811
  }
924
812
  }
813
+ /**
814
+ * @implements Batch.Handler interface.
815
+ * @param intentId - The intent ID.
816
+ * @param inputs - The inputs of the intent.
817
+ * @param session - The musig2 signing session, if not provided, the signing will be skipped.
818
+ */
819
+ createBatchHandler(intentId, inputs, session) {
820
+ let sweepTapTreeRoot;
821
+ return {
822
+ onBatchStarted: async (event) => {
823
+ const utf8IntentId = new TextEncoder().encode(intentId);
824
+ const intentIdHash = sha256(utf8IntentId);
825
+ const intentIdHashStr = hex.encode(intentIdHash);
826
+ let skip = true;
827
+ // check if our intent ID hash matches any in the event
828
+ for (const idHash of event.intentIdHashes) {
829
+ if (idHash === intentIdHashStr) {
830
+ if (!this.arkProvider) {
831
+ throw new Error("Ark provider not configured");
832
+ }
833
+ await this.arkProvider.confirmRegistration(intentId);
834
+ skip = false;
835
+ }
836
+ }
837
+ if (skip) {
838
+ return { skip };
839
+ }
840
+ const sweepTapscript = CSVMultisigTapscript.encode({
841
+ timelock: {
842
+ value: event.batchExpiry,
843
+ type: event.batchExpiry >= 512n ? "seconds" : "blocks",
844
+ },
845
+ pubkeys: [this.forfeitPubkey],
846
+ }).script;
847
+ sweepTapTreeRoot = tapLeafHash(sweepTapscript);
848
+ return { skip: false };
849
+ },
850
+ onTreeSigningStarted: async (event, vtxoTree) => {
851
+ if (!session) {
852
+ return { skip: true };
853
+ }
854
+ if (!sweepTapTreeRoot) {
855
+ throw new Error("Sweep tap tree root not set");
856
+ }
857
+ const xOnlyPublicKeys = event.cosignersPublicKeys.map((k) => k.slice(2));
858
+ const signerPublicKey = await session.getPublicKey();
859
+ const xonlySignerPublicKey = signerPublicKey.subarray(1);
860
+ if (!xOnlyPublicKeys.includes(hex.encode(xonlySignerPublicKey))) {
861
+ // not a cosigner, skip the signing
862
+ return { skip: true };
863
+ }
864
+ // validate the unsigned vtxo tree
865
+ const commitmentTx = Transaction.fromPSBT(base64.decode(event.unsignedCommitmentTx));
866
+ validateVtxoTxGraph(vtxoTree, commitmentTx, sweepTapTreeRoot);
867
+ // TODO check if our registered outputs are in the vtxo tree
868
+ const sharedOutput = commitmentTx.getOutput(0);
869
+ if (!sharedOutput?.amount) {
870
+ throw new Error("Shared output not found");
871
+ }
872
+ await session.init(vtxoTree, sweepTapTreeRoot, sharedOutput.amount);
873
+ const pubkey = hex.encode(await session.getPublicKey());
874
+ const nonces = await session.getNonces();
875
+ await this.arkProvider.submitTreeNonces(event.id, pubkey, nonces);
876
+ return { skip: false };
877
+ },
878
+ onTreeNonces: async (event) => {
879
+ if (!session) {
880
+ return { fullySigned: true }; // Signing complete (no signing needed)
881
+ }
882
+ const { hasAllNonces } = await session.aggregatedNonces(event.txid, event.nonces);
883
+ // wait to receive and aggregate all nonces before sending signatures
884
+ if (!hasAllNonces)
885
+ return { fullySigned: false };
886
+ const signatures = await session.sign();
887
+ const pubkey = hex.encode(await session.getPublicKey());
888
+ await this.arkProvider.submitTreeSignatures(event.id, pubkey, signatures);
889
+ return { fullySigned: true };
890
+ },
891
+ onBatchFinalization: async (event, _, connectorTree) => {
892
+ if (!this.forfeitOutputScript) {
893
+ throw new Error("Forfeit output script not set");
894
+ }
895
+ if (connectorTree) {
896
+ validateConnectorsTxGraph(event.commitmentTx, connectorTree);
897
+ }
898
+ await this.handleSettlementFinalizationEvent(event, inputs, this.forfeitOutputScript, connectorTree);
899
+ },
900
+ };
901
+ }
925
902
  async safeRegisterIntent(intent) {
926
903
  try {
927
- return this.arkProvider.registerIntent(intent);
904
+ return await this.arkProvider.registerIntent(intent);
928
905
  }
929
906
  catch (error) {
930
907
  // catch the "already registered by another intent" error
@@ -952,12 +929,11 @@ export class Wallet {
952
929
  expire_at: 0,
953
930
  cosigners_public_keys: cosignerPubKeys,
954
931
  };
955
- const encodedMessage = JSON.stringify(message, null, 0);
956
- const proof = Intent.create(encodedMessage, inputs, outputs);
932
+ const proof = Intent.create(message, inputs, outputs);
957
933
  const signedProof = await this.identity.sign(proof);
958
934
  return {
959
935
  proof: base64.encode(signedProof.toPSBT()),
960
- message: encodedMessage,
936
+ message,
961
937
  };
962
938
  }
963
939
  async makeDeleteIntentSignature(coins) {
@@ -966,12 +942,11 @@ export class Wallet {
966
942
  type: "delete",
967
943
  expire_at: 0,
968
944
  };
969
- const encodedMessage = JSON.stringify(message, null, 0);
970
- const proof = Intent.create(encodedMessage, inputs, []);
945
+ const proof = Intent.create(message, inputs, []);
971
946
  const signedProof = await this.identity.sign(proof);
972
947
  return {
973
948
  proof: base64.encode(signedProof.toPSBT()),
974
- message: encodedMessage,
949
+ message,
975
950
  };
976
951
  }
977
952
  async makeGetPendingTxIntentSignature(vtxos) {
@@ -980,12 +955,11 @@ export class Wallet {
980
955
  type: "get-pending-tx",
981
956
  expire_at: 0,
982
957
  };
983
- const encodedMessage = JSON.stringify(message, null, 0);
984
- const proof = Intent.create(encodedMessage, inputs, []);
958
+ const proof = Intent.create(message, inputs, []);
985
959
  const signedProof = await this.identity.sign(proof);
986
960
  return {
987
961
  proof: base64.encode(signedProof.toPSBT()),
988
- message: encodedMessage,
962
+ message,
989
963
  };
990
964
  }
991
965
  /**
@@ -1039,7 +1013,7 @@ export class Wallet {
1039
1013
  const inputs = [];
1040
1014
  for (const input of coins) {
1041
1015
  const vtxoScript = VtxoScript.decode(input.tapTree);
1042
- const sequence = getSequence(input);
1016
+ const sequence = getSequence(input.intentTapLeafScript);
1043
1017
  const unknown = [VtxoTaprootTree.encode(input.tapTree)];
1044
1018
  if (input.extraWitness) {
1045
1019
  unknown.push(ConditionWitness.encode(input.extraWitness));
@@ -1060,15 +1034,21 @@ export class Wallet {
1060
1034
  }
1061
1035
  }
1062
1036
  Wallet.MIN_FEE_RATE = 1; // sats/vbyte
1063
- function getSequence(coin) {
1037
+ export function getSequence(tapLeafScript) {
1064
1038
  let sequence = undefined;
1065
1039
  try {
1066
- const scriptWithLeafVersion = coin.intentTapLeafScript[1];
1040
+ const scriptWithLeafVersion = tapLeafScript[1];
1067
1041
  const script = scriptWithLeafVersion.subarray(0, scriptWithLeafVersion.length - 1);
1068
- const params = CSVMultisigTapscript.decode(script).params;
1069
- sequence = bip68.encode(params.timelock.type === "blocks"
1070
- ? { blocks: Number(params.timelock.value) }
1071
- : { seconds: Number(params.timelock.value) });
1042
+ try {
1043
+ const params = CSVMultisigTapscript.decode(script).params;
1044
+ sequence = bip68.encode(params.timelock.type === "blocks"
1045
+ ? { blocks: Number(params.timelock.value) }
1046
+ : { seconds: Number(params.timelock.value) });
1047
+ }
1048
+ catch {
1049
+ const params = CLTVMultisigTapscript.decode(script).params;
1050
+ sequence = Number(params.absoluteTimelock);
1051
+ }
1072
1052
  }
1073
1053
  catch { }
1074
1054
  return sequence;