@arkade-os/sdk 0.3.8 → 0.3.10

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 +15 -5
  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 +358 -360
  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 +15 -5
  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 +359 -363
  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 +76 -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 +1 -1
@@ -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");
@@ -369,7 +511,23 @@ export class Wallet {
369
511
  const virtualCoins = await this.getVirtualCoins({
370
512
  withRecoverable: false,
371
513
  });
372
- const selected = selectVirtualCoins(virtualCoins, params.amount);
514
+ let selected;
515
+ if (params.selectedVtxos) {
516
+ const selectedVtxoSum = params.selectedVtxos
517
+ .map((v) => v.value)
518
+ .reduce((a, b) => a + b, 0);
519
+ if (selectedVtxoSum < params.amount) {
520
+ throw new Error("Selected VTXOs do not cover specified amount");
521
+ }
522
+ const changeAmount = selectedVtxoSum - params.amount;
523
+ selected = {
524
+ inputs: params.selectedVtxos,
525
+ changeAmount: BigInt(changeAmount),
526
+ };
527
+ }
528
+ else {
529
+ selected = selectVirtualCoins(virtualCoins, params.amount);
530
+ }
373
531
  const selectedLeaf = this.offchainTapscript.forfeit();
374
532
  if (!selectedLeaf) {
375
533
  throw new Error("Selected leaf not found");
@@ -444,7 +602,7 @@ export class Wallet {
444
602
  vout: outputs.length - 1,
445
603
  createdAt: new Date(createdAt),
446
604
  forfeitTapLeafScript: this.offchainTapscript.forfeit(),
447
- intentTapLeafScript: this.offchainTapscript.exit(),
605
+ intentTapLeafScript: this.offchainTapscript.forfeit(),
448
606
  isUnrolled: false,
449
607
  isSpent: false,
450
608
  tapTree: this.offchainTapscript.encode(),
@@ -554,285 +712,31 @@ export class Wallet {
554
712
  this.makeDeleteIntentSignature(params.inputs),
555
713
  ]);
556
714
  const intentId = await this.safeRegisterIntent(intent);
715
+ const topics = [
716
+ ...signingPublicKeys,
717
+ ...params.inputs.map((input) => `${input.txid}:${input.vout}`),
718
+ ];
719
+ const handler = this.createBatchHandler(intentId, params.inputs, session);
557
720
  const abortController = new AbortController();
558
- // listen to settlement events
559
721
  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
- }
722
+ const stream = this.arkProvider.getEventStream(abortController.signal, topics);
723
+ return await Batch.join(stream, handler, {
724
+ abortController,
725
+ skipVtxoTreeSigning: !hasOffchainOutputs,
726
+ eventCallback: eventCallback
727
+ ? (event) => Promise.resolve(eventCallback(event))
728
+ : undefined,
729
+ });
700
730
  }
701
731
  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
- }
732
+ // delete the intent to not be stuck in the queue
733
+ await this.arkProvider.deleteIntent(deleteIntent).catch(() => { });
711
734
  throw error;
712
735
  }
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");
736
+ finally {
737
+ // close the stream
738
+ abortController.abort();
821
739
  }
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
740
  }
837
741
  async handleSettlementFinalizationEvent(event, inputs, forfeitOutputScript, connectorsGraph) {
838
742
  // the signed forfeits transactions to submit
@@ -922,9 +826,98 @@ export class Wallet {
922
826
  : undefined);
923
827
  }
924
828
  }
829
+ /**
830
+ * @implements Batch.Handler interface.
831
+ * @param intentId - The intent ID.
832
+ * @param inputs - The inputs of the intent.
833
+ * @param session - The musig2 signing session, if not provided, the signing will be skipped.
834
+ */
835
+ createBatchHandler(intentId, inputs, session) {
836
+ let sweepTapTreeRoot;
837
+ return {
838
+ onBatchStarted: async (event) => {
839
+ const utf8IntentId = new TextEncoder().encode(intentId);
840
+ const intentIdHash = sha256(utf8IntentId);
841
+ const intentIdHashStr = hex.encode(intentIdHash);
842
+ let skip = true;
843
+ // check if our intent ID hash matches any in the event
844
+ for (const idHash of event.intentIdHashes) {
845
+ if (idHash === intentIdHashStr) {
846
+ if (!this.arkProvider) {
847
+ throw new Error("Ark provider not configured");
848
+ }
849
+ await this.arkProvider.confirmRegistration(intentId);
850
+ skip = false;
851
+ }
852
+ }
853
+ if (skip) {
854
+ return { skip };
855
+ }
856
+ const sweepTapscript = CSVMultisigTapscript.encode({
857
+ timelock: {
858
+ value: event.batchExpiry,
859
+ type: event.batchExpiry >= 512n ? "seconds" : "blocks",
860
+ },
861
+ pubkeys: [this.forfeitPubkey],
862
+ }).script;
863
+ sweepTapTreeRoot = tapLeafHash(sweepTapscript);
864
+ return { skip: false };
865
+ },
866
+ onTreeSigningStarted: async (event, vtxoTree) => {
867
+ if (!session) {
868
+ return { skip: true };
869
+ }
870
+ if (!sweepTapTreeRoot) {
871
+ throw new Error("Sweep tap tree root not set");
872
+ }
873
+ const xOnlyPublicKeys = event.cosignersPublicKeys.map((k) => k.slice(2));
874
+ const signerPublicKey = await session.getPublicKey();
875
+ const xonlySignerPublicKey = signerPublicKey.subarray(1);
876
+ if (!xOnlyPublicKeys.includes(hex.encode(xonlySignerPublicKey))) {
877
+ // not a cosigner, skip the signing
878
+ return { skip: true };
879
+ }
880
+ // validate the unsigned vtxo tree
881
+ const commitmentTx = Transaction.fromPSBT(base64.decode(event.unsignedCommitmentTx));
882
+ validateVtxoTxGraph(vtxoTree, commitmentTx, sweepTapTreeRoot);
883
+ // TODO check if our registered outputs are in the vtxo tree
884
+ const sharedOutput = commitmentTx.getOutput(0);
885
+ if (!sharedOutput?.amount) {
886
+ throw new Error("Shared output not found");
887
+ }
888
+ await session.init(vtxoTree, sweepTapTreeRoot, sharedOutput.amount);
889
+ const pubkey = hex.encode(await session.getPublicKey());
890
+ const nonces = await session.getNonces();
891
+ await this.arkProvider.submitTreeNonces(event.id, pubkey, nonces);
892
+ return { skip: false };
893
+ },
894
+ onTreeNonces: async (event) => {
895
+ if (!session) {
896
+ return { fullySigned: true }; // Signing complete (no signing needed)
897
+ }
898
+ const { hasAllNonces } = await session.aggregatedNonces(event.txid, event.nonces);
899
+ // wait to receive and aggregate all nonces before sending signatures
900
+ if (!hasAllNonces)
901
+ return { fullySigned: false };
902
+ const signatures = await session.sign();
903
+ const pubkey = hex.encode(await session.getPublicKey());
904
+ await this.arkProvider.submitTreeSignatures(event.id, pubkey, signatures);
905
+ return { fullySigned: true };
906
+ },
907
+ onBatchFinalization: async (event, _, connectorTree) => {
908
+ if (!this.forfeitOutputScript) {
909
+ throw new Error("Forfeit output script not set");
910
+ }
911
+ if (connectorTree) {
912
+ validateConnectorsTxGraph(event.commitmentTx, connectorTree);
913
+ }
914
+ await this.handleSettlementFinalizationEvent(event, inputs, this.forfeitOutputScript, connectorTree);
915
+ },
916
+ };
917
+ }
925
918
  async safeRegisterIntent(intent) {
926
919
  try {
927
- return this.arkProvider.registerIntent(intent);
920
+ return await this.arkProvider.registerIntent(intent);
928
921
  }
929
922
  catch (error) {
930
923
  // catch the "already registered by another intent" error
@@ -952,12 +945,11 @@ export class Wallet {
952
945
  expire_at: 0,
953
946
  cosigners_public_keys: cosignerPubKeys,
954
947
  };
955
- const encodedMessage = JSON.stringify(message, null, 0);
956
- const proof = Intent.create(encodedMessage, inputs, outputs);
948
+ const proof = Intent.create(message, inputs, outputs);
957
949
  const signedProof = await this.identity.sign(proof);
958
950
  return {
959
951
  proof: base64.encode(signedProof.toPSBT()),
960
- message: encodedMessage,
952
+ message,
961
953
  };
962
954
  }
963
955
  async makeDeleteIntentSignature(coins) {
@@ -966,12 +958,11 @@ export class Wallet {
966
958
  type: "delete",
967
959
  expire_at: 0,
968
960
  };
969
- const encodedMessage = JSON.stringify(message, null, 0);
970
- const proof = Intent.create(encodedMessage, inputs, []);
961
+ const proof = Intent.create(message, inputs, []);
971
962
  const signedProof = await this.identity.sign(proof);
972
963
  return {
973
964
  proof: base64.encode(signedProof.toPSBT()),
974
- message: encodedMessage,
965
+ message,
975
966
  };
976
967
  }
977
968
  async makeGetPendingTxIntentSignature(vtxos) {
@@ -980,12 +971,11 @@ export class Wallet {
980
971
  type: "get-pending-tx",
981
972
  expire_at: 0,
982
973
  };
983
- const encodedMessage = JSON.stringify(message, null, 0);
984
- const proof = Intent.create(encodedMessage, inputs, []);
974
+ const proof = Intent.create(message, inputs, []);
985
975
  const signedProof = await this.identity.sign(proof);
986
976
  return {
987
977
  proof: base64.encode(signedProof.toPSBT()),
988
- message: encodedMessage,
978
+ message,
989
979
  };
990
980
  }
991
981
  /**
@@ -1039,7 +1029,7 @@ export class Wallet {
1039
1029
  const inputs = [];
1040
1030
  for (const input of coins) {
1041
1031
  const vtxoScript = VtxoScript.decode(input.tapTree);
1042
- const sequence = getSequence(input);
1032
+ const sequence = getSequence(input.intentTapLeafScript);
1043
1033
  const unknown = [VtxoTaprootTree.encode(input.tapTree)];
1044
1034
  if (input.extraWitness) {
1045
1035
  unknown.push(ConditionWitness.encode(input.extraWitness));
@@ -1060,15 +1050,21 @@ export class Wallet {
1060
1050
  }
1061
1051
  }
1062
1052
  Wallet.MIN_FEE_RATE = 1; // sats/vbyte
1063
- function getSequence(coin) {
1053
+ export function getSequence(tapLeafScript) {
1064
1054
  let sequence = undefined;
1065
1055
  try {
1066
- const scriptWithLeafVersion = coin.intentTapLeafScript[1];
1056
+ const scriptWithLeafVersion = tapLeafScript[1];
1067
1057
  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) });
1058
+ try {
1059
+ const params = CSVMultisigTapscript.decode(script).params;
1060
+ sequence = bip68.encode(params.timelock.type === "blocks"
1061
+ ? { blocks: Number(params.timelock.value) }
1062
+ : { seconds: Number(params.timelock.value) });
1063
+ }
1064
+ catch {
1065
+ const params = CLTVMultisigTapscript.decode(script).params;
1066
+ sequence = Number(params.absoluteTimelock);
1067
+ }
1072
1068
  }
1073
1069
  catch { }
1074
1070
  return sequence;