@arkade-os/sdk 0.4.17 → 0.4.18

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 (68) hide show
  1. package/README.md +16 -6
  2. package/dist/cjs/contracts/arkcontract.js +0 -2
  3. package/dist/cjs/contracts/contractManager.js +111 -215
  4. package/dist/cjs/contracts/contractWatcher.js +86 -115
  5. package/dist/cjs/repositories/indexedDB/manager.js +6 -3
  6. package/dist/cjs/repositories/indexedDB/schema.js +47 -2
  7. package/dist/cjs/repositories/indexedDB/walletRepository.js +21 -2
  8. package/dist/cjs/repositories/realm/contractRepository.js +0 -4
  9. package/dist/cjs/repositories/realm/index.js +3 -1
  10. package/dist/cjs/repositories/realm/schemas.js +50 -1
  11. package/dist/cjs/repositories/realm/walletRepository.js +8 -4
  12. package/dist/cjs/repositories/scriptFromAddress.js +16 -0
  13. package/dist/cjs/repositories/sqlite/contractRepository.js +2 -6
  14. package/dist/cjs/repositories/sqlite/walletRepository.js +121 -33
  15. package/dist/cjs/utils/syncCursors.js +48 -56
  16. package/dist/cjs/wallet/expo/background.js +0 -13
  17. package/dist/cjs/wallet/expo/wallet.js +1 -6
  18. package/dist/cjs/wallet/serviceWorker/wallet-message-handler.js +16 -7
  19. package/dist/cjs/wallet/serviceWorker/wallet.js +19 -0
  20. package/dist/cjs/wallet/utils.js +41 -10
  21. package/dist/cjs/wallet/vtxo-manager.js +153 -39
  22. package/dist/cjs/wallet/wallet.js +72 -195
  23. package/dist/cjs/worker/expo/processors/contractPollProcessor.js +9 -13
  24. package/dist/cjs/worker/expo/taskRunner.js +2 -11
  25. package/dist/esm/contracts/arkcontract.js +0 -2
  26. package/dist/esm/contracts/contractManager.js +113 -217
  27. package/dist/esm/contracts/contractWatcher.js +86 -115
  28. package/dist/esm/repositories/indexedDB/manager.js +6 -3
  29. package/dist/esm/repositories/indexedDB/schema.js +46 -2
  30. package/dist/esm/repositories/indexedDB/walletRepository.js +21 -2
  31. package/dist/esm/repositories/realm/contractRepository.js +0 -4
  32. package/dist/esm/repositories/realm/index.js +1 -1
  33. package/dist/esm/repositories/realm/schemas.js +48 -0
  34. package/dist/esm/repositories/realm/walletRepository.js +8 -4
  35. package/dist/esm/repositories/scriptFromAddress.js +13 -0
  36. package/dist/esm/repositories/sqlite/contractRepository.js +2 -6
  37. package/dist/esm/repositories/sqlite/walletRepository.js +121 -33
  38. package/dist/esm/utils/syncCursors.js +47 -53
  39. package/dist/esm/wallet/expo/background.js +0 -13
  40. package/dist/esm/wallet/expo/wallet.js +2 -7
  41. package/dist/esm/wallet/serviceWorker/wallet-message-handler.js +17 -8
  42. package/dist/esm/wallet/serviceWorker/wallet.js +19 -0
  43. package/dist/esm/wallet/utils.js +41 -9
  44. package/dist/esm/wallet/vtxo-manager.js +153 -39
  45. package/dist/esm/wallet/wallet.js +75 -198
  46. package/dist/esm/worker/expo/processors/contractPollProcessor.js +9 -13
  47. package/dist/esm/worker/expo/taskRunner.js +3 -12
  48. package/dist/types/contracts/arkcontract.d.ts +0 -2
  49. package/dist/types/contracts/contractManager.d.ts +38 -9
  50. package/dist/types/contracts/contractWatcher.d.ts +22 -21
  51. package/dist/types/contracts/types.d.ts +0 -7
  52. package/dist/types/repositories/indexedDB/manager.d.ts +5 -2
  53. package/dist/types/repositories/indexedDB/schema.d.ts +3 -2
  54. package/dist/types/repositories/realm/index.d.ts +1 -1
  55. package/dist/types/repositories/realm/schemas.d.ts +41 -0
  56. package/dist/types/repositories/scriptFromAddress.d.ts +9 -0
  57. package/dist/types/repositories/serialization.d.ts +1 -1
  58. package/dist/types/repositories/sqlite/walletRepository.d.ts +22 -0
  59. package/dist/types/repositories/walletRepository.d.ts +10 -2
  60. package/dist/types/utils/syncCursors.d.ts +25 -23
  61. package/dist/types/wallet/index.d.ts +1 -1
  62. package/dist/types/wallet/serviceWorker/wallet-message-handler.d.ts +15 -3
  63. package/dist/types/wallet/utils.d.ts +20 -4
  64. package/dist/types/wallet/vtxo-manager.d.ts +16 -6
  65. package/dist/types/wallet/wallet.d.ts +5 -17
  66. package/dist/types/worker/expo/processors/contractPollProcessor.d.ts +9 -4
  67. package/dist/types/worker/expo/taskRunner.d.ts +6 -3
  68. package/package.json +1 -1
@@ -32,6 +32,38 @@ function assertSweepCapable(wallet) {
32
32
  throw new Error("Boarding UTXO sweep requires a Wallet instance with boardingTapscript, onchainProvider, and network");
33
33
  }
34
34
  }
35
+ /**
36
+ * Web Locks name used to serialize boarding-poll work across same-origin
37
+ * browser contexts (tabs, service worker). Static because the goal is to
38
+ * deduplicate polls for the *same* wallet — two distinct wallets on the
39
+ * same origin will take turns, which is acceptable.
40
+ */
41
+ const BOARDING_POLL_LOCK_NAME = "arkade-boarding-poll";
42
+ /**
43
+ * Run `fn` under an exclusive Web Lock when the runtime provides one
44
+ * (browser main thread, service worker). In environments without
45
+ * `navigator.locks` (Node, React Native) the callback runs immediately
46
+ * with no coordination.
47
+ *
48
+ * Uses `ifAvailable: true`: if another context already holds the lock,
49
+ * skip this cycle entirely rather than queueing — the other context will
50
+ * do the work and the next poll will re-check.
51
+ */
52
+ async function runWithCrossInstanceLock(name, fn) {
53
+ const locks = typeof globalThis !== "undefined" &&
54
+ typeof globalThis.navigator !== "undefined"
55
+ ? globalThis.navigator.locks
56
+ : undefined;
57
+ if (!locks) {
58
+ await fn();
59
+ return;
60
+ }
61
+ await locks.request(name, { ifAvailable: true, mode: "exclusive" }, async (lock) => {
62
+ if (lock === null)
63
+ return;
64
+ await fn();
65
+ });
66
+ }
35
67
  /** Default renewal threshold in seconds (3 days). */
36
68
  exports.DEFAULT_THRESHOLD_SECONDS = 3 * 24 * 60 * 60;
37
69
  /**
@@ -191,6 +223,15 @@ class VtxoManager {
191
223
  // server emits new VTXOs → vtxo_received → renewVtxos() again → infinite loop.
192
224
  this.renewalInProgress = false;
193
225
  this.lastRenewalTimestamp = 0;
226
+ // Guards against a retry treadmill on the periodic-settle path: a failing
227
+ // settle would otherwise re-submit identical intents on every 60s poll,
228
+ // producing per-minute DeleteIntent RPCs forever. Mirrors the renewal
229
+ // cooldown but with exponential backoff on consecutive failures, so a
230
+ // persistently broken input eventually drops to the backoff cap instead
231
+ // of hammering the server. Shared across boarding + expiring-VTXO work
232
+ // because they now ride on the same settle intent.
233
+ this.lastPeriodicSettleTimestamp = 0;
234
+ this.consecutivePeriodicSettleFailures = 0;
194
235
  // Normalize: prefer settlementConfig, fall back to renewalConfig, default to enabled
195
236
  if (settlementConfig !== undefined) {
196
237
  this.settlementConfig = settlementConfig;
@@ -412,10 +453,15 @@ class VtxoManager {
412
453
  },
413
454
  ],
414
455
  }, eventCallback);
415
- this.lastRenewalTimestamp = Date.now();
416
456
  return txid;
417
457
  }
418
458
  finally {
459
+ // Update cooldown on EVERY attempt (success or failure) so transient
460
+ // settle failures (stream close, connector mismatch, duplicated input)
461
+ // don't allow the next vtxo_received event to re-enter renewal
462
+ // immediately. Without this, a failed settle leaves lastRenewalTimestamp
463
+ // at its previous value and the cooldown check becomes a no-op.
464
+ this.lastRenewalTimestamp = Date.now();
419
465
  this.renewalInProgress = false;
420
466
  }
421
467
  }
@@ -693,33 +739,42 @@ class VtxoManager {
693
739
  this.pollDone = { promise, resolve: resolve };
694
740
  let hadError = false;
695
741
  try {
696
- // Fetch boarding inputs once for the entire poll cycle so that
697
- // settle and sweep don't each hit the network independently.
698
- const boardingUtxos = await this.wallet.getBoardingUtxos();
699
- // Settle new (unexpired) boarding inputs first, then sweep expired ones.
700
- // Sequential to avoid racing for the same inputs.
701
- try {
702
- await this.settleBoardingUtxos(boardingUtxos);
703
- }
704
- catch (e) {
705
- hadError = true;
706
- console.error("Error auto-settling boarding UTXOs:", e);
707
- }
708
- const sweepEnabled = this.settlementConfig !== false &&
709
- (this.settlementConfig?.boardingUtxoSweep ??
710
- exports.DEFAULT_SETTLEMENT_CONFIG.boardingUtxoSweep);
711
- if (sweepEnabled) {
742
+ // Cross-instance guard: in browser / service worker environments,
743
+ // serialize the poll body across tabs and SW contexts so only one
744
+ // of them registers intents per interval. Without this, every tab
745
+ // submits a parallel RegisterIntent for the same boarding input
746
+ // and N-1 of them collide on the server's duplicated-input check,
747
+ // each producing a DeleteIntent RPC. No-op outside the browser.
748
+ await runWithCrossInstanceLock(BOARDING_POLL_LOCK_NAME, async () => {
749
+ // Fetch boarding inputs once for the entire poll cycle so that
750
+ // settle and sweep don't each hit the network independently.
751
+ const boardingUtxos = await this.wallet.getBoardingUtxos();
752
+ // Settle new (unexpired) boarding inputs + any near-expiry
753
+ // VTXOs in a single intent, then sweep expired boarding
754
+ // inputs. Sequential to avoid racing for the same inputs.
712
755
  try {
713
- await this.sweepExpiredBoardingUtxos(boardingUtxos);
756
+ await this.runPeriodicSettle(boardingUtxos);
714
757
  }
715
758
  catch (e) {
716
- if (!(e instanceof Error) ||
717
- !e.message.includes("No expired boarding UTXOs")) {
718
- hadError = true;
719
- console.error("Error auto-sweeping boarding UTXOs:", e);
759
+ hadError = true;
760
+ console.error("Error during periodic settle:", e);
761
+ }
762
+ const sweepEnabled = this.settlementConfig !== false &&
763
+ (this.settlementConfig?.boardingUtxoSweep ??
764
+ exports.DEFAULT_SETTLEMENT_CONFIG.boardingUtxoSweep);
765
+ if (sweepEnabled) {
766
+ try {
767
+ await this.sweepExpiredBoardingUtxos(boardingUtxos);
768
+ }
769
+ catch (e) {
770
+ if (!(e instanceof Error) ||
771
+ !e.message.includes("No expired boarding UTXOs")) {
772
+ hadError = true;
773
+ console.error("Error auto-sweeping boarding UTXOs:", e);
774
+ }
720
775
  }
721
776
  }
722
- }
777
+ });
723
778
  }
724
779
  catch (e) {
725
780
  hadError = true;
@@ -739,13 +794,19 @@ class VtxoManager {
739
794
  }
740
795
  }
741
796
  /**
742
- * Auto-settle new (unexpired) boarding inputs into Arkade.
743
- * Skips UTXOs that are already expired (those are handled by sweep).
744
- * Only settles UTXOs not already in-flight (tracked in knownBoardingUtxos).
745
- * UTXOs are marked as known only after a successful settle, so failed
746
- * attempts will be retried on the next poll.
797
+ * Auto-settle new (unexpired) boarding inputs AND near-expiry VTXOs into
798
+ * Arkade in a single intent. Skips boarding UTXOs that are already expired
799
+ * (those are handled by sweep) and those already in-flight (tracked in
800
+ * knownBoardingUtxos). If the event-driven renewal path is currently
801
+ * running, VTXOs are omitted from this cycle to avoid double-spending.
802
+ *
803
+ * Failure bookkeeping: after every settle *attempt*, lastPeriodicSettleTimestamp
804
+ * is armed and consecutive failures are counted so the next attempt is
805
+ * blocked by an exponentially growing cooldown (capped). This stops a
806
+ * persistently failing input from producing identical RegisterIntent +
807
+ * DeleteIntent retries on every 60s poll.
747
808
  */
748
- async settleBoardingUtxos(boardingUtxos) {
809
+ async runPeriodicSettle(boardingUtxos) {
749
810
  // Exclude expired boarding inputs — those should be swept, not settled.
750
811
  // If we can't determine expired status, bail out entirely to avoid
751
812
  // accidentally settling expired inputs (which would conflict with sweep).
@@ -763,22 +824,73 @@ class VtxoManager {
763
824
  catch (e) {
764
825
  throw e instanceof Error ? e : new Error(String(e));
765
826
  }
766
- const unsettledUtxos = boardingUtxos.filter((u) => !this.knownBoardingUtxos.has(`${u.txid}:${u.vout}`) &&
827
+ const unsettledBoarding = boardingUtxos.filter((u) => !this.knownBoardingUtxos.has(`${u.txid}:${u.vout}`) &&
767
828
  !expiredSet.has(`${u.txid}:${u.vout}`));
768
- if (unsettledUtxos.length === 0)
829
+ // Collect near-expiry VTXOs unless the event-driven path is mid-renewal.
830
+ // Skipping when renewalInProgress avoids double-submitting the same VTXOs.
831
+ let expiringVtxos = [];
832
+ if (!this.renewalInProgress) {
833
+ try {
834
+ expiringVtxos = await this.getExpiringVtxos();
835
+ }
836
+ catch (e) {
837
+ // Non-fatal: fall back to boarding-only settle.
838
+ console.error("Error fetching expiring VTXOs:", e);
839
+ }
840
+ }
841
+ if (unsettledBoarding.length === 0 && expiringVtxos.length === 0) {
842
+ return;
843
+ }
844
+ // Respect the cooldown armed by the previous attempt. Cooldown grows
845
+ // exponentially with consecutive failures and is capped by
846
+ // PERIODIC_SETTLE_MAX_BACKOFF_MS.
847
+ const cooldownMs = Math.min(VtxoManager.PERIODIC_SETTLE_COOLDOWN_MS *
848
+ Math.pow(2, this.consecutivePeriodicSettleFailures), VtxoManager.PERIODIC_SETTLE_MAX_BACKOFF_MS);
849
+ if (Date.now() - this.lastPeriodicSettleTimestamp < cooldownMs) {
769
850
  return;
851
+ }
770
852
  const dustAmount = getDustAmount(this.wallet);
771
- const totalAmount = unsettledUtxos.reduce((sum, u) => sum + BigInt(u.value), 0n);
853
+ const boardingTotal = unsettledBoarding.reduce((sum, u) => sum + BigInt(u.value), 0n);
854
+ const vtxoTotal = expiringVtxos.reduce((sum, v) => sum + BigInt(v.value), 0n);
855
+ const totalAmount = boardingTotal + vtxoTotal;
772
856
  if (totalAmount < dustAmount)
773
857
  return;
774
858
  const arkAddress = await this.wallet.getAddress();
775
- await this.wallet.settle({
776
- inputs: unsettledUtxos,
777
- outputs: [{ address: arkAddress, amount: totalAmount }],
778
- });
779
- // Mark as known only after successful settle
780
- for (const u of unsettledUtxos) {
781
- this.knownBoardingUtxos.add(`${u.txid}:${u.vout}`);
859
+ const includesVtxos = expiringVtxos.length > 0;
860
+ // Block the event-driven renewal path while this settle is in flight
861
+ // when VTXOs are part of the intent. Mirrors renewVtxos()'s guard so
862
+ // the two paths can't race on the same VTXO inputs.
863
+ if (includesVtxos) {
864
+ this.renewalInProgress = true;
865
+ }
866
+ let success = false;
867
+ try {
868
+ await this.wallet.settle({
869
+ inputs: [...unsettledBoarding, ...expiringVtxos],
870
+ outputs: [{ address: arkAddress, amount: totalAmount }],
871
+ });
872
+ // Mark boarding inputs as known only after successful settle.
873
+ for (const u of unsettledBoarding) {
874
+ this.knownBoardingUtxos.add(`${u.txid}:${u.vout}`);
875
+ }
876
+ success = true;
877
+ }
878
+ finally {
879
+ this.lastPeriodicSettleTimestamp = Date.now();
880
+ if (includesVtxos) {
881
+ // Match event-path semantics: bump the renewal cooldown
882
+ // whether we succeeded or failed so a failed periodic settle
883
+ // doesn't let the next vtxo_received event re-enter renewal
884
+ // immediately.
885
+ this.lastRenewalTimestamp = Date.now();
886
+ this.renewalInProgress = false;
887
+ }
888
+ if (success) {
889
+ this.consecutivePeriodicSettleFailures = 0;
890
+ }
891
+ else {
892
+ this.consecutivePeriodicSettleFailures++;
893
+ }
782
894
  }
783
895
  }
784
896
  async dispose() {
@@ -812,3 +924,5 @@ class VtxoManager {
812
924
  exports.VtxoManager = VtxoManager;
813
925
  VtxoManager.MAX_BACKOFF_MS = 5 * 60 * 1000; // 5 minutes
814
926
  VtxoManager.RENEWAL_COOLDOWN_MS = 30000; // 30 seconds
927
+ VtxoManager.PERIODIC_SETTLE_COOLDOWN_MS = 30000;
928
+ VtxoManager.PERIODIC_SETTLE_MAX_BACKOFF_MS = 5 * 60 * 1000;
@@ -304,8 +304,10 @@ class ReadonlyWallet {
304
304
  async getVtxos(filter) {
305
305
  const f = filter ?? { withRecoverable: true, withUnrolled: false };
306
306
  const contractManager = await this.getContractManager();
307
- const contractsWithVtxos = await contractManager.getContractsWithVtxos();
308
- return contractsWithVtxos.flatMap(({ vtxos }) => vtxos.filter((vtxo) => {
307
+ const vtxos = await contractManager.getContractsWithVtxos();
308
+ return vtxos
309
+ .flatMap((_) => _.vtxos)
310
+ .filter((vtxo) => {
309
311
  if ((0, _1.isSpendable)(vtxo)) {
310
312
  if (!f.withRecoverable &&
311
313
  ((0, _1.isRecoverable)(vtxo) || (0, _1.isExpired)(vtxo))) {
@@ -314,17 +316,15 @@ class ReadonlyWallet {
314
316
  return true;
315
317
  }
316
318
  return !!(f.withUnrolled && vtxo.isUnrolled);
317
- }));
319
+ });
318
320
  }
319
321
  /**
320
322
  * Return wallet transaction history derived from Arkade state and boarding transactions.
321
323
  */
322
324
  async getTransactionHistory() {
323
- // Delta-sync virtual outputs into cache, then build history from the cache.
324
- const { isDelta, fetchedExtended, address } = await this.syncVtxos();
325
- const allVtxos = isDelta
326
- ? await this.walletRepository.getVtxos(address)
327
- : fetchedExtended;
325
+ const contractManager = await this.getContractManager();
326
+ const response = await contractManager.getContractsWithVtxos();
327
+ const allVtxos = response.flatMap((_) => _.vtxos);
328
328
  const { boardingTxs, commitmentsToIgnore } = await this.getBoardingTxs();
329
329
  const getTxCreatedAt = (txid) => this.indexerProvider
330
330
  .getVtxos({ outpoints: [{ txid, vout: 0 }] })
@@ -332,166 +332,11 @@ class ReadonlyWallet {
332
332
  return (0, transactionHistory_1.buildTransactionHistory)(allVtxos, boardingTxs, commitmentsToIgnore, getTxCreatedAt);
333
333
  }
334
334
  /**
335
- * Delta-sync wallet virtual outputs: fetch only changed virtual outputs since the last
336
- * cursor, or do a full bootstrap when no cursor exists. Upserts
337
- * the result into the cache and advances the sync cursors.
338
- *
339
- * Concurrent calls are deduplicated: if a sync is already in flight,
340
- * subsequent callers receive the same promise instead of triggering
341
- * a second network round-trip.
342
- */
343
- syncVtxos() {
344
- if (this._syncVtxosInflight)
345
- return this._syncVtxosInflight;
346
- const p = this.doSyncVtxos().finally(() => {
347
- this._syncVtxosInflight = undefined;
348
- });
349
- this._syncVtxosInflight = p;
350
- return p;
351
- }
352
- async doSyncVtxos() {
353
- const address = await this.getAddress();
354
- // Batch cursor read with script map to avoid extra async hops
355
- // before the fetch (background operations may run between hops).
356
- const [scriptMap, cursors] = await Promise.all([
357
- this.getScriptMap(),
358
- (0, syncCursors_1.getAllSyncCursors)(this.walletRepository),
359
- ]);
360
- const allScripts = [...scriptMap.keys()];
361
- // Partition scripts into bootstrap (no cursor) and delta (has cursor).
362
- const bootstrapScripts = [];
363
- const deltaScripts = [];
364
- for (const s of allScripts) {
365
- if (cursors[s] === undefined) {
366
- bootstrapScripts.push(s);
367
- }
368
- else {
369
- deltaScripts.push(s);
370
- }
371
- }
372
- const requestStartedAt = Date.now();
373
- const allVtxos = [];
374
- const extendWithScript = (vtxo) => {
375
- const vtxoScript = vtxo.script
376
- ? scriptMap.get(vtxo.script)
377
- : undefined;
378
- if (!vtxoScript)
379
- return undefined;
380
- return {
381
- ...vtxo,
382
- forfeitTapLeafScript: vtxoScript.forfeit(),
383
- intentTapLeafScript: vtxoScript.forfeit(),
384
- tapTree: vtxoScript.encode(),
385
- };
386
- };
387
- // Full fetch for scripts with no cursor.
388
- if (bootstrapScripts.length > 0) {
389
- const response = await this.indexerProvider.getVtxos({
390
- scripts: bootstrapScripts,
391
- });
392
- allVtxos.push(...response.vtxos);
393
- }
394
- // Delta fetch for scripts with an existing cursor.
395
- let hasDelta = false;
396
- if (deltaScripts.length > 0) {
397
- const minCursor = Math.min(...deltaScripts.map((s) => cursors[s]));
398
- const window = (0, syncCursors_1.computeSyncWindow)(minCursor);
399
- if (window) {
400
- hasDelta = true;
401
- const response = await this.indexerProvider.getVtxos({
402
- scripts: deltaScripts,
403
- after: window.after,
404
- });
405
- allVtxos.push(...response.vtxos);
406
- }
407
- }
408
- // Extend every fetched virtual output and upsert into the cache.
409
- const fetchedExtended = [];
410
- for (const vtxo of allVtxos) {
411
- const extended = extendWithScript(vtxo);
412
- if (extended)
413
- fetchedExtended.push(extended);
414
- }
415
- // Save virtual outputs first, then advance cursors only on success.
416
- const cutoff = (0, syncCursors_1.cursorCutoff)(requestStartedAt);
417
- await this.walletRepository.saveVtxos(address, fetchedExtended);
418
- await (0, syncCursors_1.advanceSyncCursors)(this.walletRepository, Object.fromEntries(allScripts.map((s) => [s, cutoff])));
419
- // Delta-sync reconciliation: full re-fetch for delta scripts.
420
- //
421
- // The delta fetch (above) only returns virtual outputs changed after the
422
- // cursor, so it can miss preconfirmed virtual outputs that were consumed
423
- // by a round between syncs. Rather than layering targeted
424
- // queries (pendingOnly, spendableOnly) with pagination guards
425
- // and set algebra, we perform a single unfiltered re-fetch for
426
- // delta scripts. This is slightly more data over the wire but
427
- // gives us complete, authoritative state in one call and keeps
428
- // the reconciliation logic simple.
429
- //
430
- // Any cached non-spent virtual output that is absent from the full
431
- // result set is marked spent; any virtual output whose state changed
432
- // (e.g. preconfirmed → settled) is updated in place.
433
- if (hasDelta) {
434
- const { vtxos: fullVtxos, page: fullPage } = await this.indexerProvider.getVtxos({
435
- scripts: deltaScripts,
436
- });
437
- // Reconciliation is best-effort: if the response is
438
- // paginated we don't have a complete picture, so we skip
439
- // rather than act on partial data. Wallets with enough
440
- // virtual outputs to exceed a single page rely solely on the
441
- // cursor-based delta mechanism for state updates.
442
- const fullSetComplete = !fullPage || fullPage.total <= 1;
443
- if (fullSetComplete) {
444
- const fullOutpoints = new Map(fullVtxos.map((v) => [`${v.txid}:${v.vout}`, v]));
445
- const deltaScriptSet = new Set(deltaScripts);
446
- const cachedVtxos = await this.walletRepository.getVtxos(address);
447
- const reconciledExtended = [];
448
- for (const cached of cachedVtxos) {
449
- if (!cached.script ||
450
- !deltaScriptSet.has(cached.script) ||
451
- cached.isSpent) {
452
- continue;
453
- }
454
- const outpoint = `${cached.txid}:${cached.vout}`;
455
- const fresh = fullOutpoints.get(outpoint);
456
- if (!fresh) {
457
- // Server no longer knows about this virtual output —
458
- // it was spent between syncs.
459
- reconciledExtended.push({
460
- ...cached,
461
- isSpent: true,
462
- });
463
- continue;
464
- }
465
- const extended = extendWithScript(fresh);
466
- if (extended &&
467
- extended.virtualStatus.state !==
468
- cached.virtualStatus.state) {
469
- // State transitioned (e.g. preconfirmed →
470
- // settled) — update the cached entry.
471
- reconciledExtended.push(extended);
472
- }
473
- }
474
- if (reconciledExtended.length > 0) {
475
- console.warn(`[ark-sdk] delta sync: reconciled ${reconciledExtended.length} stale VTXO(s) via full re-fetch`);
476
- await this.walletRepository.saveVtxos(address, reconciledExtended);
477
- }
478
- }
479
- else {
480
- console.warn("[ark-sdk] delta sync: skipping reconciliation — full re-fetch was paginated");
481
- }
482
- }
483
- return {
484
- isDelta: hasDelta || bootstrapScripts.length === 0,
485
- fetchedExtended,
486
- address,
487
- };
488
- }
489
- /**
490
- * Clear all virtual output sync cursors, forcing a full re-bootstrap on next sync.
335
+ * Clear the global VTXO sync cursor, forcing a full re-bootstrap on next sync.
491
336
  * Useful for recovery after indexer reprocessing or debugging.
492
337
  */
493
- async clearSyncCursors() {
494
- await (0, syncCursors_1.clearSyncCursors)(this.walletRepository);
338
+ async clearSyncCursor() {
339
+ await (0, syncCursors_1.clearSyncCursor)(this.walletRepository);
495
340
  }
496
341
  /**
497
342
  * Build a transaction history view for the wallet's boarding address.
@@ -534,6 +379,7 @@ class ReadonlyWallet {
534
379
  createdAt: tx.status.confirmed
535
380
  ? new Date(tx.status.block_time * 1000)
536
381
  : new Date(0),
382
+ script: base_1.hex.encode(this.boardingTapscript.pkScript),
537
383
  });
538
384
  }
539
385
  }
@@ -623,22 +469,41 @@ class ReadonlyWallet {
623
469
  await this.indexerProvider?.unsubscribeForScripts(subscriptionId);
624
470
  };
625
471
  // Handle subscription updates asynchronously without blocking.
626
- // Note: subscription covers all wallet scripts (default + delegate),
627
- // but we can't determine which script each virtual output belongs to from the
628
- // subscription event. Virtual outputs are extended with the current offchainTapscript;
629
- // this is for notification/display only not for spending.
630
- // For correct extension metadata, use getVtxos() which queries per-script.
472
+ // Subscription covers all wallet scripts (default + delegate) plus
473
+ // any additional registered contracts. Virtual outputs carry a
474
+ // `script` field from the indexer which the contract manager
475
+ // resolves to the owning contract so the extension uses the
476
+ // correct forfeit/intent tapscripts.
631
477
  (async () => {
632
478
  try {
479
+ const cm = await this.getContractManager();
633
480
  for await (const update of subscription) {
634
- if (update.newVtxos?.length > 0 ||
635
- update.spentVtxos?.length > 0) {
481
+ if (update.newVtxos?.length === 0 &&
482
+ update.spentVtxos?.length === 0) {
483
+ continue;
484
+ }
485
+ // Isolate per-update annotation failures (e.g. a VTXO
486
+ // arriving for a contract we haven't registered yet).
487
+ // Without this a single bad update would kill the
488
+ // for-await loop and silently drop every subsequent
489
+ // subscription event for the session.
490
+ try {
491
+ // Default to `[]` so a one-sided update (e.g.
492
+ // only `newVtxos`) doesn't pass `undefined` into
493
+ // annotateVtxos and throw on `.length`.
494
+ const [newVtxos, spentVtxos] = await Promise.all([
495
+ cm.annotateVtxos(update.newVtxos ?? []),
496
+ cm.annotateVtxos(update.spentVtxos ?? []),
497
+ ]);
636
498
  eventCallback({
637
499
  type: "vtxo",
638
- newVtxos: update.newVtxos.map((vtxo) => (0, utils_1.extendVirtualCoin)(this, vtxo)),
639
- spentVtxos: update.spentVtxos.map((vtxo) => (0, utils_1.extendVirtualCoin)(this, vtxo)),
500
+ newVtxos,
501
+ spentVtxos,
640
502
  });
641
503
  }
504
+ catch (error) {
505
+ console.warn("Dropping subscription update after annotation failed; next sync will reconcile:", error);
506
+ }
642
507
  }
643
508
  }
644
509
  catch (error) {
@@ -909,11 +774,10 @@ class Wallet extends ReadonlyWallet {
909
774
  }
910
775
  });
911
776
  }
912
- constructor(identity, network, networkName, onchainProvider, arkProvider, indexerProvider, arkServerPublicKey, offchainTapscript, boardingTapscript, serverUnrollScript, forfeitOutputScript, forfeitPubkey, dustAmount, walletRepository, contractRepository,
777
+ constructor(identity, network, onchainProvider, arkProvider, indexerProvider, arkServerPublicKey, offchainTapscript, boardingTapscript, serverUnrollScript, forfeitOutputScript, forfeitPubkey, dustAmount, walletRepository, contractRepository,
913
778
  /** @deprecated Use settlementConfig */
914
779
  renewalConfig, delegatorProvider, watcherConfig, settlementConfig) {
915
780
  super(identity, network, onchainProvider, indexerProvider, arkServerPublicKey, offchainTapscript, boardingTapscript, dustAmount, walletRepository, contractRepository, delegatorProvider, watcherConfig);
916
- this.networkName = networkName;
917
781
  this.arkProvider = arkProvider;
918
782
  this.serverUnrollScript = serverUnrollScript;
919
783
  this.forfeitOutputScript = forfeitOutputScript;
@@ -1033,7 +897,7 @@ class Wallet extends ReadonlyWallet {
1033
897
  const forfeitPubkey = base_1.hex.decode(setup.info.forfeitPubkey).slice(1);
1034
898
  const forfeitAddress = (0, btc_signer_1.Address)(setup.network).decode(setup.info.forfeitAddress);
1035
899
  const forfeitOutputScript = btc_signer_1.OutScript.encode(forfeitAddress);
1036
- const wallet = 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, config.delegatorProvider, config.watcherConfig, config.settlementConfig);
900
+ const wallet = new Wallet(config.identity, setup.network, setup.onchainProvider, setup.arkProvider, setup.indexerProvider, setup.serverPubKey, setup.offchainTapscript, setup.boardingTapscript, serverUnrollScript, forfeitOutputScript, forfeitPubkey, setup.dustAmount, setup.walletRepository, setup.contractRepository, config.renewalConfig, config.delegatorProvider, config.watcherConfig, config.settlementConfig);
1037
901
  await wallet.getVtxoManager();
1038
902
  return wallet;
1039
903
  }
@@ -1296,7 +1160,7 @@ class Wallet extends ReadonlyWallet {
1296
1160
  const abortController = new AbortController();
1297
1161
  try {
1298
1162
  const stream = this.arkProvider.getEventStream(abortController.signal, topics);
1299
- const intentId = await this.safeRegisterIntent(intent);
1163
+ const intentId = await this.safeRegisterIntent(intent, params.inputs);
1300
1164
  const handler = this.createBatchHandler(intentId, params.inputs, recipients, session);
1301
1165
  const commitmentTxid = await batch_1.Batch.join(stream, handler, {
1302
1166
  abortController,
@@ -1309,8 +1173,16 @@ class Wallet extends ReadonlyWallet {
1309
1173
  return commitmentTxid;
1310
1174
  }
1311
1175
  catch (error) {
1312
- // delete the intent to not be stuck in the queue
1313
- await this.arkProvider.deleteIntent(deleteIntent).catch(() => { });
1176
+ // delete the intent to not be stuck in the queue. If deletion fails
1177
+ // the intent stays on the server and the next settle will hit
1178
+ // "duplicated input" in safeRegisterIntent — surface the failure
1179
+ // rather than silently swallowing it.
1180
+ const inputIds = params.inputs
1181
+ .map((i) => `${i.txid}:${i.vout}`)
1182
+ .join(",");
1183
+ await this.arkProvider.deleteIntent(deleteIntent).catch((e) => {
1184
+ console.warn(`Failed to delete intent after settle failure for inputs [${inputIds}]; intent may linger on server and cause 'duplicated input' on next settle`, e);
1185
+ });
1314
1186
  throw error;
1315
1187
  }
1316
1188
  finally {
@@ -1500,7 +1372,7 @@ class Wallet extends ReadonlyWallet {
1500
1372
  },
1501
1373
  };
1502
1374
  }
1503
- async safeRegisterIntent(intent) {
1375
+ async safeRegisterIntent(intent, inputs) {
1504
1376
  try {
1505
1377
  return await this.arkProvider.registerIntent(intent);
1506
1378
  }
@@ -1509,11 +1381,13 @@ class Wallet extends ReadonlyWallet {
1509
1381
  if (error instanceof errors_1.ArkError &&
1510
1382
  error.code === 0 &&
1511
1383
  error.message.includes("duplicated input")) {
1512
- // delete all intents spending one of the wallet coins
1513
- const allSpendableCoins = await this.getVtxos({
1514
- withRecoverable: true,
1515
- });
1516
- const deleteIntent = await this.makeDeleteIntentSignature(allSpendableCoins);
1384
+ // Clear any queued intent spending these exact inputs. The
1385
+ // previous implementation signed a proof over getVtxos() only,
1386
+ // which misses boarding UTXOs — the most common trigger for
1387
+ // "duplicated input" on the auto-settle path. Signing the
1388
+ // caller's own inputs keeps the proof surgical and correct
1389
+ // regardless of whether the stuck input is a VTXO or boarding.
1390
+ const deleteIntent = await this.makeDeleteIntentSignature(inputs);
1517
1391
  await this.arkProvider.deleteIntent(deleteIntent);
1518
1392
  // try again
1519
1393
  return this.arkProvider.registerIntent(intent);
@@ -1581,9 +1455,7 @@ class Wallet extends ReadonlyWallet {
1581
1455
  scripts: allScripts,
1582
1456
  });
1583
1457
  for (const vtxo of fetchedVtxos) {
1584
- const vtxoScript = vtxo.script
1585
- ? scriptMap.get(vtxo.script)
1586
- : undefined;
1458
+ const vtxoScript = scriptMap.get(vtxo.script);
1587
1459
  if (!vtxoScript)
1588
1460
  continue;
1589
1461
  if (vtxo.virtualStatus.state === "swept" ||
@@ -1886,8 +1758,9 @@ class Wallet extends ReadonlyWallet {
1886
1758
  console.warn(`updateDbAfterOffchainTx: inputs length (${inputs.length}) differs from signedCheckpointTxs length (${signedCheckpointTxs.length})`);
1887
1759
  }
1888
1760
  const safeLength = Math.min(inputs.length, signedCheckpointTxs.length);
1889
- for (const [inputIndex, input] of inputs.entries()) {
1890
- const vtxo = (0, utils_1.extendVirtualCoin)(this, input);
1761
+ const cm = await this.getContractManager();
1762
+ const annotatedInputs = await cm.annotateVtxos(inputs);
1763
+ for (const [inputIndex, vtxo] of annotatedInputs.entries()) {
1891
1764
  if (inputIndex < safeLength &&
1892
1765
  signedCheckpointTxs[inputIndex]) {
1893
1766
  const checkpoint = btc_signer_1.Transaction.fromPSBT(base_1.base64.decode(signedCheckpointTxs[inputIndex]));
@@ -1947,6 +1820,7 @@ class Wallet extends ReadonlyWallet {
1947
1820
  confirmed: false,
1948
1821
  },
1949
1822
  assets: changeAssets,
1823
+ script: base_1.hex.encode(this.offchainTapscript.pkScript),
1950
1824
  };
1951
1825
  }
1952
1826
  await this.walletRepository.saveVtxos(addr, changeVtxo ? [...spentVtxos, changeVtxo] : spentVtxos);
@@ -1977,10 +1851,14 @@ class Wallet extends ReadonlyWallet {
1977
1851
  const inputArkTxIds = new Set();
1978
1852
  const boardingUtxoToRemove = new Set();
1979
1853
  const isVtxo = (input) => "virtualStatus" in input;
1854
+ const vtxoInputs = inputs.filter(isVtxo);
1855
+ const cm = await this.getContractManager();
1856
+ const annotatedVtxos = await cm.annotateVtxos(vtxoInputs);
1857
+ const annotatedByKey = new Map(annotatedVtxos.map((v) => [`${v.txid}:${v.vout}`, v]));
1980
1858
  for (const input of inputs) {
1981
1859
  if (isVtxo(input)) {
1982
1860
  // virtual output = mark it settled
1983
- const vtxo = (0, utils_1.extendVirtualCoin)(this, input);
1861
+ const vtxo = annotatedByKey.get(`${input.txid}:${input.vout}`);
1984
1862
  if (vtxo.arkTxId) {
1985
1863
  inputArkTxIds.add(vtxo.arkTxId);
1986
1864
  }
@@ -2067,7 +1945,7 @@ function selectVirtualCoins(coins, targetAmount) {
2067
1945
  */
2068
1946
  async function waitForIncomingFunds(wallet) {
2069
1947
  let stopFunc;
2070
- const promise = new Promise((resolve) => {
1948
+ return new Promise((resolve) => {
2071
1949
  wallet
2072
1950
  .notifyIncomingFunds((coins) => {
2073
1951
  resolve(coins);
@@ -2078,5 +1956,4 @@ async function waitForIncomingFunds(wallet) {
2078
1956
  stopFunc = stop;
2079
1957
  });
2080
1958
  });
2081
- return promise;
2082
1959
  }