@arkade-os/sdk 0.4.7 → 0.4.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 (39) hide show
  1. package/dist/cjs/contracts/contractManager.js +59 -11
  2. package/dist/cjs/contracts/contractWatcher.js +21 -2
  3. package/dist/cjs/identity/seedIdentity.js +2 -2
  4. package/dist/cjs/index.js +9 -2
  5. package/dist/cjs/providers/expoIndexer.js +1 -0
  6. package/dist/cjs/providers/indexer.js +1 -0
  7. package/dist/cjs/utils/transactionHistory.js +2 -1
  8. package/dist/cjs/wallet/serviceWorker/wallet-message-handler.js +249 -36
  9. package/dist/cjs/wallet/serviceWorker/wallet.js +286 -34
  10. package/dist/cjs/wallet/vtxo-manager.js +123 -86
  11. package/dist/cjs/wallet/wallet.js +140 -68
  12. package/dist/cjs/worker/errors.js +17 -0
  13. package/dist/cjs/worker/messageBus.js +14 -2
  14. package/dist/esm/contracts/contractManager.js +59 -11
  15. package/dist/esm/contracts/contractWatcher.js +21 -2
  16. package/dist/esm/identity/seedIdentity.js +2 -2
  17. package/dist/esm/index.js +3 -2
  18. package/dist/esm/providers/expoIndexer.js +1 -0
  19. package/dist/esm/providers/indexer.js +1 -0
  20. package/dist/esm/utils/transactionHistory.js +2 -1
  21. package/dist/esm/wallet/serviceWorker/wallet-message-handler.js +245 -35
  22. package/dist/esm/wallet/serviceWorker/wallet.js +286 -34
  23. package/dist/esm/wallet/vtxo-manager.js +123 -86
  24. package/dist/esm/wallet/wallet.js +140 -68
  25. package/dist/esm/worker/errors.js +12 -0
  26. package/dist/esm/worker/messageBus.js +14 -2
  27. package/dist/types/contracts/contractManager.d.ts +10 -0
  28. package/dist/types/identity/seedIdentity.d.ts +5 -2
  29. package/dist/types/index.d.ts +5 -4
  30. package/dist/types/repositories/serialization.d.ts +1 -0
  31. package/dist/types/utils/transactionHistory.d.ts +1 -1
  32. package/dist/types/wallet/index.d.ts +2 -0
  33. package/dist/types/wallet/serviceWorker/wallet-message-handler.d.ts +101 -7
  34. package/dist/types/wallet/serviceWorker/wallet.d.ts +16 -0
  35. package/dist/types/wallet/vtxo-manager.d.ts +29 -2
  36. package/dist/types/wallet/wallet.d.ts +10 -0
  37. package/dist/types/worker/errors.d.ts +6 -0
  38. package/dist/types/worker/messageBus.d.ts +6 -0
  39. package/package.json +1 -1
@@ -143,43 +143,6 @@ export function getExpiringAndRecoverableVtxos(vtxos, thresholdMs, dustAmount) {
143
143
  (isSpendable(vtxo) && isExpired(vtxo)) ||
144
144
  isSubdust(vtxo, dustAmount));
145
145
  }
146
- /**
147
- * VtxoManager is a unified class for managing VTXO lifecycle operations including
148
- * recovery of swept/expired VTXOs and renewal to prevent expiration.
149
- *
150
- * Key Features:
151
- * - **Recovery**: Reclaim swept or expired VTXOs back to the wallet
152
- * - **Renewal**: Refresh VTXO expiration time before they expire
153
- * - **Smart subdust handling**: Automatically includes subdust VTXOs when economically viable
154
- * - **Expiry monitoring**: Check for VTXOs that are expiring soon
155
- *
156
- * VTXOs become recoverable when:
157
- * - The Ark server sweeps them (virtualStatus.state === "swept") and they remain spendable
158
- * - They are preconfirmed subdust (to consolidate small amounts without locking liquidity on settled VTXOs)
159
- *
160
- * @example
161
- * ```typescript
162
- * // Initialize with renewal config
163
- * const manager = new VtxoManager(wallet, {
164
- * enabled: true,
165
- * thresholdMs: 86400000
166
- * });
167
- *
168
- * // Check recoverable balance
169
- * const balance = await manager.getRecoverableBalance();
170
- * if (balance.recoverable > 0n) {
171
- * console.log(`Can recover ${balance.recoverable} sats`);
172
- * const txid = await manager.recoverVtxos();
173
- * }
174
- *
175
- * // Check for expiring VTXOs
176
- * const expiring = await manager.getExpiringVtxos();
177
- * if (expiring.length > 0) {
178
- * console.log(`${expiring.length} VTXOs expiring soon`);
179
- * const txid = await manager.renewVtxos();
180
- * }
181
- * ```
182
- */
183
146
  export class VtxoManager {
184
147
  constructor(wallet,
185
148
  /** @deprecated Use settlementConfig instead */
@@ -189,6 +152,12 @@ export class VtxoManager {
189
152
  this.knownBoardingUtxos = new Set();
190
153
  this.sweptBoardingUtxos = new Set();
191
154
  this.pollInProgress = false;
155
+ this.disposed = false;
156
+ this.consecutivePollFailures = 0;
157
+ // Guards against renewal feedback loop: when renewVtxos() settles, the
158
+ // server emits new VTXOs → vtxo_received → renewVtxos() again → infinite loop.
159
+ this.renewalInProgress = false;
160
+ this.lastRenewalTimestamp = 0;
192
161
  // Normalize: prefer settlementConfig, fall back to renewalConfig, default to enabled
193
162
  if (settlementConfig !== undefined) {
194
163
  this.settlementConfig = settlementConfig;
@@ -372,32 +341,43 @@ export class VtxoManager {
372
341
  * ```
373
342
  */
374
343
  async renewVtxos(eventCallback) {
375
- // Get all VTXOs (including recoverable ones)
376
- // Use default threshold to bypass settlementConfig gate (manual API should always work)
377
- const vtxos = await this.getExpiringVtxos(this.settlementConfig !== false &&
378
- this.settlementConfig?.vtxoThreshold !== undefined
379
- ? this.settlementConfig.vtxoThreshold * 1000
380
- : DEFAULT_RENEWAL_CONFIG.thresholdMs);
381
- if (vtxos.length === 0) {
382
- throw new Error("No VTXOs available to renew");
383
- }
384
- const totalAmount = vtxos.reduce((sum, vtxo) => sum + vtxo.value, 0);
385
- // Get dust amount from wallet
386
- const dustAmount = getDustAmount(this.wallet);
387
- // Check if total amount is above dust threshold
388
- if (BigInt(totalAmount) < dustAmount) {
389
- throw new Error(`Total amount ${totalAmount} is below dust threshold ${dustAmount}`);
344
+ if (this.renewalInProgress) {
345
+ throw new Error("Renewal already in progress");
346
+ }
347
+ this.renewalInProgress = true;
348
+ try {
349
+ // Get all VTXOs (including recoverable ones)
350
+ // Use default threshold to bypass settlementConfig gate (manual API should always work)
351
+ const vtxos = await this.getExpiringVtxos(this.settlementConfig !== false &&
352
+ this.settlementConfig?.vtxoThreshold !== undefined
353
+ ? this.settlementConfig.vtxoThreshold * 1000
354
+ : DEFAULT_RENEWAL_CONFIG.thresholdMs);
355
+ if (vtxos.length === 0) {
356
+ throw new Error("No VTXOs available to renew");
357
+ }
358
+ const totalAmount = vtxos.reduce((sum, vtxo) => sum + vtxo.value, 0);
359
+ // Get dust amount from wallet
360
+ const dustAmount = getDustAmount(this.wallet);
361
+ // Check if total amount is above dust threshold
362
+ if (BigInt(totalAmount) < dustAmount) {
363
+ throw new Error(`Total amount ${totalAmount} is below dust threshold ${dustAmount}`);
364
+ }
365
+ const arkAddress = await this.wallet.getAddress();
366
+ const txid = await this.wallet.settle({
367
+ inputs: vtxos,
368
+ outputs: [
369
+ {
370
+ address: arkAddress,
371
+ amount: BigInt(totalAmount),
372
+ },
373
+ ],
374
+ }, eventCallback);
375
+ this.lastRenewalTimestamp = Date.now();
376
+ return txid;
377
+ }
378
+ finally {
379
+ this.renewalInProgress = false;
390
380
  }
391
- const arkAddress = await this.wallet.getAddress();
392
- return this.wallet.settle({
393
- inputs: vtxos,
394
- outputs: [
395
- {
396
- address: arkAddress,
397
- amount: BigInt(totalAmount),
398
- },
399
- ],
400
- }, eventCallback);
401
381
  }
402
382
  // ========== Boarding UTXO Sweep Methods ==========
403
383
  /**
@@ -565,7 +545,11 @@ export class VtxoManager {
565
545
  }
566
546
  // Start polling for boarding UTXOs independently of contract manager
567
547
  // SSE setup. Use a short delay to let the wallet finish construction.
568
- setTimeout(() => this.startBoardingUtxoPoll(), 1000);
548
+ this.startupPollTimeoutId = setTimeout(() => {
549
+ if (this.disposed)
550
+ return;
551
+ this.startBoardingUtxoPoll();
552
+ }, 1000);
569
553
  try {
570
554
  const [delegatorManager, contractManager, destination] = await Promise.all([
571
555
  this.wallet.getDelegatorManager(),
@@ -576,20 +560,33 @@ export class VtxoManager {
576
560
  if (event.type !== "vtxo_received") {
577
561
  return;
578
562
  }
579
- this.renewVtxos().catch((e) => {
580
- if (e instanceof Error) {
581
- if (e.message.includes("No VTXOs available to renew")) {
582
- // Not an error, just no VTXO eligible for renewal.
583
- return;
563
+ const msSinceLastRenewal = Date.now() - this.lastRenewalTimestamp;
564
+ const shouldRenew = !this.renewalInProgress &&
565
+ msSinceLastRenewal >= VtxoManager.RENEWAL_COOLDOWN_MS;
566
+ if (shouldRenew) {
567
+ this.renewVtxos().catch((e) => {
568
+ if (e instanceof Error) {
569
+ if (e.message.includes("No VTXOs available to renew")) {
570
+ // Not an error, just no VTXO eligible for renewal.
571
+ return;
572
+ }
573
+ if (e.message.includes("is below dust threshold")) {
574
+ // Not an error, just below dust threshold.
575
+ // As more VTXOs are received, the threshold will be raised.
576
+ return;
577
+ }
578
+ if (e.message.includes("VTXO_ALREADY_REGISTERED") ||
579
+ e.message.includes("duplicated input")) {
580
+ // VTXO is already being used in a concurrent
581
+ // user-initiated operation. Skip silently — the
582
+ // wallet's tx lock serializes these, but the
583
+ // renewal will retry on the next cycle.
584
+ return;
585
+ }
584
586
  }
585
- if (e.message.includes("is below dust threshold")) {
586
- // Not an error, just below dust threshold.
587
- // As more VTXOs are received, the threshold will be raised.
588
- return;
589
- }
590
- }
591
- console.error("Error renewing VTXOs:", e);
592
- });
587
+ console.error("Error renewing VTXOs:", e);
588
+ });
589
+ }
593
590
  delegatorManager
594
591
  ?.delegate(event.vtxos, destination)
595
592
  .catch((e) => {
@@ -603,19 +600,36 @@ export class VtxoManager {
603
600
  return undefined;
604
601
  }
605
602
  }
603
+ /** Computes the next poll delay, applying exponential backoff on failures. */
604
+ getNextPollDelay() {
605
+ if (this.settlementConfig === false)
606
+ return 0;
607
+ const baseMs = this.settlementConfig.pollIntervalMs ??
608
+ DEFAULT_SETTLEMENT_CONFIG.pollIntervalMs;
609
+ if (this.consecutivePollFailures === 0)
610
+ return baseMs;
611
+ const backoff = Math.min(baseMs * Math.pow(2, this.consecutivePollFailures), VtxoManager.MAX_BACKOFF_MS);
612
+ return backoff;
613
+ }
606
614
  /**
607
615
  * Starts a polling loop that:
608
616
  * 1. Auto-settles new boarding UTXOs into Ark
609
617
  * 2. Sweeps expired boarding UTXOs (when boardingUtxoSweep is enabled)
618
+ *
619
+ * Uses setTimeout chaining (not setInterval) so a slow/blocked poll
620
+ * cannot stack up and the next delay can incorporate backoff.
610
621
  */
611
622
  startBoardingUtxoPoll() {
612
623
  if (this.settlementConfig === false)
613
624
  return;
614
- const intervalMs = this.settlementConfig.pollIntervalMs ??
615
- DEFAULT_SETTLEMENT_CONFIG.pollIntervalMs;
616
- // Run once immediately, then on interval
625
+ // Run once immediately, then schedule next
617
626
  this.pollBoardingUtxos();
618
- this.pollIntervalId = setInterval(() => this.pollBoardingUtxos(), intervalMs);
627
+ }
628
+ schedulePoll() {
629
+ if (this.disposed || this.settlementConfig === false)
630
+ return;
631
+ const delay = this.getNextPollDelay();
632
+ this.pollTimeoutId = setTimeout(() => this.pollBoardingUtxos(), delay);
619
633
  }
620
634
  async pollBoardingUtxos() {
621
635
  // Guard: wallet must support boarding UTXO + sweep operations
@@ -625,6 +639,7 @@ export class VtxoManager {
625
639
  if (this.pollInProgress)
626
640
  return;
627
641
  this.pollInProgress = true;
642
+ let hadError = false;
628
643
  try {
629
644
  // Settle new (unexpired) UTXOs first, then sweep expired ones.
630
645
  // Sequential to avoid racing for the same UTXOs.
@@ -632,6 +647,7 @@ export class VtxoManager {
632
647
  await this.settleBoardingUtxos();
633
648
  }
634
649
  catch (e) {
650
+ hadError = true;
635
651
  console.error("Error auto-settling boarding UTXOs:", e);
636
652
  }
637
653
  const sweepEnabled = this.settlementConfig !== false &&
@@ -644,13 +660,21 @@ export class VtxoManager {
644
660
  catch (e) {
645
661
  if (!(e instanceof Error) ||
646
662
  !e.message.includes("No expired boarding UTXOs")) {
663
+ hadError = true;
647
664
  console.error("Error auto-sweeping boarding UTXOs:", e);
648
665
  }
649
666
  }
650
667
  }
651
668
  }
652
669
  finally {
670
+ if (hadError) {
671
+ this.consecutivePollFailures++;
672
+ }
673
+ else {
674
+ this.consecutivePollFailures = 0;
675
+ }
653
676
  this.pollInProgress = false;
677
+ this.schedulePoll();
654
678
  }
655
679
  }
656
680
  /**
@@ -667,11 +691,17 @@ export class VtxoManager {
667
691
  // accidentally settling expired UTXOs (which would conflict with sweep).
668
692
  let expiredSet;
669
693
  try {
670
- const expired = await this.getExpiredBoardingUtxos();
694
+ const boardingTimelock = this.getBoardingTimelock();
695
+ let chainTipHeight;
696
+ if (boardingTimelock.type === "blocks") {
697
+ const tip = await this.getOnchainProvider().getChainTip();
698
+ chainTipHeight = tip.height;
699
+ }
700
+ const expired = boardingUtxos.filter((utxo) => hasBoardingTxExpired(utxo, boardingTimelock, chainTipHeight));
671
701
  expiredSet = new Set(expired.map((u) => `${u.txid}:${u.vout}`));
672
702
  }
673
- catch {
674
- return;
703
+ catch (e) {
704
+ throw e instanceof Error ? e : new Error(String(e));
675
705
  }
676
706
  const unsettledUtxos = boardingUtxos.filter((u) => !this.knownBoardingUtxos.has(`${u.txid}:${u.vout}`) &&
677
707
  !expiredSet.has(`${u.txid}:${u.vout}`));
@@ -693,9 +723,14 @@ export class VtxoManager {
693
723
  }
694
724
  async dispose() {
695
725
  this.disposePromise ?? (this.disposePromise = (async () => {
696
- if (this.pollIntervalId) {
697
- clearInterval(this.pollIntervalId);
698
- this.pollIntervalId = undefined;
726
+ this.disposed = true;
727
+ if (this.startupPollTimeoutId) {
728
+ clearTimeout(this.startupPollTimeoutId);
729
+ this.startupPollTimeoutId = undefined;
730
+ }
731
+ if (this.pollTimeoutId) {
732
+ clearTimeout(this.pollTimeoutId);
733
+ this.pollTimeoutId = undefined;
699
734
  }
700
735
  const subscription = await this.contractEventsSubscriptionReady;
701
736
  this.contractEventsSubscription = undefined;
@@ -707,3 +742,5 @@ export class VtxoManager {
707
742
  await this.dispose();
708
743
  }
709
744
  }
745
+ VtxoManager.MAX_BACKOFF_MS = 5 * 60 * 1000; // 5 minutes
746
+ VtxoManager.RENEWAL_COOLDOWN_MS = 30000; // 30 seconds
@@ -57,6 +57,19 @@ export class ReadonlyWallet {
57
57
  this.walletRepository = walletRepository;
58
58
  this.contractRepository = contractRepository;
59
59
  this.delegatorProvider = delegatorProvider;
60
+ // Guard: detect identity/server network mismatch for descriptor-based identities.
61
+ // This duplicates the check in setupWalletConfig() so that subclasses
62
+ // bypassing the factory still get the safety net.
63
+ if ("descriptor" in identity) {
64
+ const descriptor = identity.descriptor;
65
+ const identityIsMainnet = !descriptor.includes("tpub");
66
+ const serverIsMainnet = network.bech32 === "bc";
67
+ if (identityIsMainnet !== serverIsMainnet) {
68
+ throw new Error(`Network mismatch: identity uses ${identityIsMainnet ? "mainnet" : "testnet"} derivation ` +
69
+ `but wallet network is ${serverIsMainnet ? "mainnet" : "testnet"}. ` +
70
+ `Create identity with { isMainnet: ${serverIsMainnet} } to match.`);
71
+ }
72
+ }
60
73
  this.watcherConfig = watcherConfig;
61
74
  this._assetManager = new ReadonlyAssetManager(this.indexerProvider);
62
75
  }
@@ -84,6 +97,24 @@ export class ReadonlyWallet {
84
97
  const indexerProvider = config.indexerProvider || new RestIndexerProvider(indexerUrl);
85
98
  const info = await arkProvider.getInfo();
86
99
  const network = getNetwork(info.network);
100
+ // Guard: detect identity/server network mismatch for seed-based identities.
101
+ // A mainnet descriptor (xpub, coin type 0) connected to a testnet server
102
+ // (or vice versa) means wrong derivation path → wrong keys → potential fund loss.
103
+ if ("descriptor" in config.identity) {
104
+ const descriptor = config.identity.descriptor;
105
+ const identityIsMainnet = !descriptor.includes("tpub");
106
+ const serverIsMainnet = info.network === "bitcoin";
107
+ if (identityIsMainnet && !serverIsMainnet) {
108
+ throw new Error(`Network mismatch: identity uses mainnet derivation (coin type 0) ` +
109
+ `but Ark server is on ${info.network}. ` +
110
+ `Create identity with { isMainnet: false } to use testnet derivation.`);
111
+ }
112
+ if (!identityIsMainnet && serverIsMainnet) {
113
+ throw new Error(`Network mismatch: identity uses testnet derivation (coin type 1) ` +
114
+ `but Ark server is on mainnet. ` +
115
+ `Create identity with { isMainnet: true } or omit isMainnet (defaults to mainnet).`);
116
+ }
117
+ }
87
118
  // Extract esploraUrl from provider if not explicitly provided
88
119
  const esploraUrl = config.esploraUrl || ESPLORA_URL[info.network];
89
120
  // Use provided onchainProvider instance or create a new one
@@ -242,27 +273,33 @@ export class ReadonlyWallet {
242
273
  const scriptMap = await this.getScriptMap();
243
274
  const f = filter ?? { withRecoverable: true, withUnrolled: false };
244
275
  const allExtended = [];
245
- // Query each script separately so we can extend VTXOs with the correct tapscript
246
- for (const [scriptHex, vtxoScript] of scriptMap) {
247
- const response = await this.indexerProvider.getVtxos({
248
- scripts: [scriptHex],
249
- });
250
- let vtxos = response.vtxos.filter(isSpendable);
251
- if (!f.withRecoverable) {
252
- vtxos = vtxos.filter((vtxo) => !isRecoverable(vtxo) && !isExpired(vtxo));
253
- }
254
- if (f.withUnrolled) {
255
- const spentVtxos = response.vtxos.filter((vtxo) => !isSpendable(vtxo));
256
- vtxos.push(...spentVtxos.filter((vtxo) => vtxo.isUnrolled));
276
+ // Batch all scripts into a single indexer call
277
+ const allScripts = [...scriptMap.keys()];
278
+ const response = await this.indexerProvider.getVtxos({
279
+ scripts: allScripts,
280
+ });
281
+ for (const vtxo of response.vtxos) {
282
+ const vtxoScript = vtxo.script
283
+ ? scriptMap.get(vtxo.script)
284
+ : undefined;
285
+ if (!vtxoScript)
286
+ continue;
287
+ if (isSpendable(vtxo)) {
288
+ if (!f.withRecoverable &&
289
+ (isRecoverable(vtxo) || isExpired(vtxo))) {
290
+ continue;
291
+ }
257
292
  }
258
- for (const vtxo of vtxos) {
259
- allExtended.push({
260
- ...vtxo,
261
- forfeitTapLeafScript: vtxoScript.forfeit(),
262
- intentTapLeafScript: vtxoScript.forfeit(),
263
- tapTree: vtxoScript.encode(),
264
- });
293
+ else {
294
+ if (!f.withUnrolled || !vtxo.isUnrolled)
295
+ continue;
265
296
  }
297
+ allExtended.push({
298
+ ...vtxo,
299
+ forfeitTapLeafScript: vtxoScript.forfeit(),
300
+ intentTapLeafScript: vtxoScript.forfeit(),
301
+ tapTree: vtxoScript.encode(),
302
+ });
266
303
  }
267
304
  // Update cache with fresh data
268
305
  await this.walletRepository.saveVtxos(address, allExtended);
@@ -274,7 +311,7 @@ export class ReadonlyWallet {
274
311
  const { boardingTxs, commitmentsToIgnore } = await this.getBoardingTxs();
275
312
  const getTxCreatedAt = (txid) => this.indexerProvider
276
313
  .getVtxos({ outpoints: [{ txid, vout: 0 }] })
277
- .then((res) => res.vtxos[0]?.createdAt.getTime() || 0);
314
+ .then((res) => res.vtxos[0]?.createdAt.getTime());
278
315
  return buildTransactionHistory(response.vtxos, boardingTxs, commitmentsToIgnore, getTxCreatedAt);
279
316
  }
280
317
  async getBoardingTxs() {
@@ -658,6 +695,20 @@ export class ReadonlyWallet {
658
695
  * ```
659
696
  */
660
697
  export class Wallet extends ReadonlyWallet {
698
+ _withTxLock(fn) {
699
+ let release;
700
+ const lock = new Promise((r) => (release = r));
701
+ const prev = this._txLock;
702
+ this._txLock = lock;
703
+ return prev.then(async () => {
704
+ try {
705
+ return await fn();
706
+ }
707
+ finally {
708
+ release();
709
+ }
710
+ });
711
+ }
661
712
  constructor(identity, network, networkName, onchainProvider, arkProvider, indexerProvider, arkServerPublicKey, offchainTapscript, boardingTapscript, serverUnrollScript, forfeitOutputScript, forfeitPubkey, dustAmount, walletRepository, contractRepository,
662
713
  /** @deprecated Use settlementConfig */
663
714
  renewalConfig, delegatorProvider, watcherConfig, settlementConfig) {
@@ -667,6 +718,13 @@ export class Wallet extends ReadonlyWallet {
667
718
  this.serverUnrollScript = serverUnrollScript;
668
719
  this.forfeitOutputScript = forfeitOutputScript;
669
720
  this.forfeitPubkey = forfeitPubkey;
721
+ /**
722
+ * Async mutex that serializes all operations submitting VTXOs to the Ark
723
+ * server (`settle`, `send`, `sendBitcoin`). This prevents VtxoManager's
724
+ * background renewal from racing with user-initiated transactions for the
725
+ * same VTXO inputs.
726
+ */
727
+ this._txLock = Promise.resolve();
670
728
  this.identity = identity;
671
729
  // Backwards-compatible: keep renewalConfig populated for any code reading it
672
730
  this.renewalConfig = {
@@ -805,40 +863,42 @@ export class Wallet extends ReadonlyWallet {
805
863
  throw new Error("Invalid Ark address " + params.address);
806
864
  }
807
865
  if (params.selectedVtxos && params.selectedVtxos.length > 0) {
808
- const selectedVtxoSum = params.selectedVtxos
809
- .map((v) => v.value)
810
- .reduce((a, b) => a + b, 0);
811
- if (selectedVtxoSum < params.amount) {
812
- throw new Error("Selected VTXOs do not cover specified amount");
813
- }
814
- const changeAmount = selectedVtxoSum - params.amount;
815
- const selected = {
816
- inputs: params.selectedVtxos,
817
- changeAmount: BigInt(changeAmount),
818
- };
819
- const outputAddress = ArkAddress.decode(params.address);
820
- const outputScript = BigInt(params.amount) < this.dustAmount
821
- ? outputAddress.subdustPkScript
822
- : outputAddress.pkScript;
823
- const outputs = [
824
- {
825
- script: outputScript,
826
- amount: BigInt(params.amount),
827
- },
828
- ];
829
- // add change output if needed
830
- if (selected.changeAmount > 0n) {
831
- const changeOutputScript = selected.changeAmount < this.dustAmount
832
- ? this.arkAddress.subdustPkScript
833
- : this.arkAddress.pkScript;
834
- outputs.push({
835
- script: changeOutputScript,
836
- amount: BigInt(selected.changeAmount),
837
- });
838
- }
839
- const { arkTxid, signedCheckpointTxs } = await this.buildAndSubmitOffchainTx(selected.inputs, outputs);
840
- await this.updateDbAfterOffchainTx(selected.inputs, arkTxid, signedCheckpointTxs, params.amount, selected.changeAmount, selected.changeAmount > 0n ? outputs.length - 1 : 0);
841
- return arkTxid;
866
+ return this._withTxLock(async () => {
867
+ const selectedVtxoSum = params
868
+ .selectedVtxos.map((v) => v.value)
869
+ .reduce((a, b) => a + b, 0);
870
+ if (selectedVtxoSum < params.amount) {
871
+ throw new Error("Selected VTXOs do not cover specified amount");
872
+ }
873
+ const changeAmount = selectedVtxoSum - params.amount;
874
+ const selected = {
875
+ inputs: params.selectedVtxos,
876
+ changeAmount: BigInt(changeAmount),
877
+ };
878
+ const outputAddress = ArkAddress.decode(params.address);
879
+ const outputScript = BigInt(params.amount) < this.dustAmount
880
+ ? outputAddress.subdustPkScript
881
+ : outputAddress.pkScript;
882
+ const outputs = [
883
+ {
884
+ script: outputScript,
885
+ amount: BigInt(params.amount),
886
+ },
887
+ ];
888
+ // add change output if needed
889
+ if (selected.changeAmount > 0n) {
890
+ const changeOutputScript = selected.changeAmount < this.dustAmount
891
+ ? this.arkAddress.subdustPkScript
892
+ : this.arkAddress.pkScript;
893
+ outputs.push({
894
+ script: changeOutputScript,
895
+ amount: BigInt(selected.changeAmount),
896
+ });
897
+ }
898
+ const { arkTxid, signedCheckpointTxs } = await this.buildAndSubmitOffchainTx(selected.inputs, outputs);
899
+ await this.updateDbAfterOffchainTx(selected.inputs, arkTxid, signedCheckpointTxs, params.amount, selected.changeAmount, selected.changeAmount > 0n ? outputs.length - 1 : 0);
900
+ return arkTxid;
901
+ });
842
902
  }
843
903
  return this.send({
844
904
  address: params.address,
@@ -846,6 +906,9 @@ export class Wallet extends ReadonlyWallet {
846
906
  });
847
907
  }
848
908
  async settle(params, eventCallback) {
909
+ return this._withTxLock(() => this._settleImpl(params, eventCallback));
910
+ }
911
+ async _settleImpl(params, eventCallback) {
849
912
  if (params?.inputs) {
850
913
  for (const input of params.inputs) {
851
914
  // validate arknotes inputs
@@ -1281,23 +1344,29 @@ export class Wallet extends ReadonlyWallet {
1281
1344
  async finalizePendingTxs(vtxos) {
1282
1345
  const MAX_INPUTS_PER_INTENT = 20;
1283
1346
  if (!vtxos || vtxos.length === 0) {
1284
- // Query per-script so each VTXO is extended with the correct tapscript
1347
+ // Batch all scripts into a single indexer call
1285
1348
  const scriptMap = await this.getScriptMap();
1286
1349
  const allExtended = [];
1287
- for (const [scriptHex, vtxoScript] of scriptMap) {
1288
- const { vtxos: fetchedVtxos } = await this.indexerProvider.getVtxos({
1289
- scripts: [scriptHex],
1290
- });
1291
- const pending = fetchedVtxos.filter((vtxo) => vtxo.virtualStatus.state !== "swept" &&
1292
- vtxo.virtualStatus.state !== "settled");
1293
- for (const vtxo of pending) {
1294
- allExtended.push({
1295
- ...vtxo,
1296
- forfeitTapLeafScript: vtxoScript.forfeit(),
1297
- intentTapLeafScript: vtxoScript.forfeit(),
1298
- tapTree: vtxoScript.encode(),
1299
- });
1350
+ const allScripts = [...scriptMap.keys()];
1351
+ const { vtxos: fetchedVtxos } = await this.indexerProvider.getVtxos({
1352
+ scripts: allScripts,
1353
+ });
1354
+ for (const vtxo of fetchedVtxos) {
1355
+ const vtxoScript = vtxo.script
1356
+ ? scriptMap.get(vtxo.script)
1357
+ : undefined;
1358
+ if (!vtxoScript)
1359
+ continue;
1360
+ if (vtxo.virtualStatus.state === "swept" ||
1361
+ vtxo.virtualStatus.state === "settled") {
1362
+ continue;
1300
1363
  }
1364
+ allExtended.push({
1365
+ ...vtxo,
1366
+ forfeitTapLeafScript: vtxoScript.forfeit(),
1367
+ intentTapLeafScript: vtxoScript.forfeit(),
1368
+ tapTree: vtxoScript.encode(),
1369
+ });
1301
1370
  }
1302
1371
  if (allExtended.length === 0) {
1303
1372
  return { finalized: [], pending: [] };
@@ -1347,6 +1416,9 @@ export class Wallet extends ReadonlyWallet {
1347
1416
  * ```
1348
1417
  */
1349
1418
  async send(...args) {
1419
+ return this._withTxLock(() => this._sendImpl(...args));
1420
+ }
1421
+ async _sendImpl(...args) {
1350
1422
  if (args.length === 0) {
1351
1423
  throw new Error("At least one receiver is required");
1352
1424
  }
@@ -0,0 +1,12 @@
1
+ export class MessageBusNotInitializedError extends Error {
2
+ constructor() {
3
+ super("MessageBus not initialized");
4
+ this.name = "MessageBusNotInitializedError";
5
+ }
6
+ }
7
+ export class ServiceWorkerTimeoutError extends Error {
8
+ constructor(detail) {
9
+ super(detail);
10
+ this.name = "ServiceWorkerTimeoutError";
11
+ }
12
+ }
@@ -5,6 +5,7 @@ import { RestDelegatorProvider } from '../providers/delegator.js';
5
5
  import { ReadonlySingleKey, SingleKey } from '../identity/index.js';
6
6
  import { ReadonlyWallet, Wallet } from '../wallet/wallet.js';
7
7
  import { hex } from "@scure/base";
8
+ import { MessageBusNotInitializedError, ServiceWorkerTimeoutError, } from './errors.js';
8
9
  export class MessageBus {
9
10
  constructor(walletRepository, contractRepository, { messageHandlers, tickIntervalMs = 10000, messageTimeoutMs = 30000, debug = false, buildServices, }) {
10
11
  this.walletRepository = walletRepository;
@@ -148,8 +149,12 @@ export class MessageBus {
148
149
  identity,
149
150
  arkServerUrl: config.arkServer.url,
150
151
  arkServerPublicKey: config.arkServer.publicKey,
152
+ indexerUrl: config.indexerUrl,
153
+ esploraUrl: config.esploraUrl,
151
154
  storage,
152
155
  delegatorProvider,
156
+ settlementConfig: config.settlementConfig,
157
+ watcherConfig: config.watcherConfig,
153
158
  });
154
159
  return { wallet, arkProvider, readonlyWallet: wallet };
155
160
  }
@@ -159,8 +164,11 @@ export class MessageBus {
159
164
  identity,
160
165
  arkServerUrl: config.arkServer.url,
161
166
  arkServerPublicKey: config.arkServer.publicKey,
167
+ indexerUrl: config.indexerUrl,
168
+ esploraUrl: config.esploraUrl,
162
169
  storage,
163
170
  delegatorProvider,
171
+ watcherConfig: config.watcherConfig,
164
172
  });
165
173
  return { readonlyWallet, arkProvider };
166
174
  }
@@ -180,6 +188,10 @@ export class MessageBus {
180
188
  }
181
189
  async processMessage(event) {
182
190
  const { id, tag, broadcast } = event.data;
191
+ if (tag === "PING") {
192
+ event.source?.postMessage({ id, tag: "PONG" });
193
+ return;
194
+ }
183
195
  if (tag === "INITIALIZE_MESSAGE_BUS") {
184
196
  if (this.debug) {
185
197
  console.log("Init Command received");
@@ -204,7 +216,7 @@ export class MessageBus {
204
216
  event.source?.postMessage({
205
217
  id,
206
218
  tag: tag ?? "unknown",
207
- error: new Error("MessageBus not initialized"),
219
+ error: new MessageBusNotInitializedError(),
208
220
  });
209
221
  return;
210
222
  }
@@ -275,7 +287,7 @@ export class MessageBus {
275
287
  return promise;
276
288
  return new Promise((resolve, reject) => {
277
289
  const timer = self.setTimeout(() => {
278
- reject(new Error(`Message handler timed out after ${this.messageTimeoutMs}ms (${label})`));
290
+ reject(new ServiceWorkerTimeoutError(`Message handler timed out after ${this.messageTimeoutMs}ms (${label})`));
279
291
  }, this.messageTimeoutMs);
280
292
  promise.then((val) => {
281
293
  self.clearTimeout(timer);