@arkade-os/sdk 0.4.17 → 0.4.19

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 (70) 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/providers/ark.js +36 -33
  6. package/dist/cjs/repositories/indexedDB/manager.js +6 -3
  7. package/dist/cjs/repositories/indexedDB/schema.js +47 -2
  8. package/dist/cjs/repositories/indexedDB/walletRepository.js +21 -2
  9. package/dist/cjs/repositories/realm/contractRepository.js +0 -4
  10. package/dist/cjs/repositories/realm/index.js +3 -1
  11. package/dist/cjs/repositories/realm/schemas.js +50 -1
  12. package/dist/cjs/repositories/realm/walletRepository.js +8 -4
  13. package/dist/cjs/repositories/scriptFromAddress.js +16 -0
  14. package/dist/cjs/repositories/sqlite/contractRepository.js +2 -6
  15. package/dist/cjs/repositories/sqlite/walletRepository.js +121 -33
  16. package/dist/cjs/utils/syncCursors.js +48 -56
  17. package/dist/cjs/wallet/expo/background.js +0 -13
  18. package/dist/cjs/wallet/expo/wallet.js +1 -6
  19. package/dist/cjs/wallet/serviceWorker/wallet-message-handler.js +16 -7
  20. package/dist/cjs/wallet/serviceWorker/wallet.js +19 -0
  21. package/dist/cjs/wallet/utils.js +41 -10
  22. package/dist/cjs/wallet/vtxo-manager.js +222 -40
  23. package/dist/cjs/wallet/wallet.js +149 -211
  24. package/dist/cjs/worker/expo/processors/contractPollProcessor.js +9 -13
  25. package/dist/cjs/worker/expo/taskRunner.js +2 -11
  26. package/dist/esm/contracts/arkcontract.js +0 -2
  27. package/dist/esm/contracts/contractManager.js +113 -217
  28. package/dist/esm/contracts/contractWatcher.js +86 -115
  29. package/dist/esm/providers/ark.js +36 -33
  30. package/dist/esm/repositories/indexedDB/manager.js +6 -3
  31. package/dist/esm/repositories/indexedDB/schema.js +46 -2
  32. package/dist/esm/repositories/indexedDB/walletRepository.js +21 -2
  33. package/dist/esm/repositories/realm/contractRepository.js +0 -4
  34. package/dist/esm/repositories/realm/index.js +1 -1
  35. package/dist/esm/repositories/realm/schemas.js +48 -0
  36. package/dist/esm/repositories/realm/walletRepository.js +8 -4
  37. package/dist/esm/repositories/scriptFromAddress.js +13 -0
  38. package/dist/esm/repositories/sqlite/contractRepository.js +2 -6
  39. package/dist/esm/repositories/sqlite/walletRepository.js +121 -33
  40. package/dist/esm/utils/syncCursors.js +47 -53
  41. package/dist/esm/wallet/expo/background.js +0 -13
  42. package/dist/esm/wallet/expo/wallet.js +2 -7
  43. package/dist/esm/wallet/serviceWorker/wallet-message-handler.js +17 -8
  44. package/dist/esm/wallet/serviceWorker/wallet.js +19 -0
  45. package/dist/esm/wallet/utils.js +41 -9
  46. package/dist/esm/wallet/vtxo-manager.js +222 -40
  47. package/dist/esm/wallet/wallet.js +152 -214
  48. package/dist/esm/worker/expo/processors/contractPollProcessor.js +9 -13
  49. package/dist/esm/worker/expo/taskRunner.js +3 -12
  50. package/dist/types/contracts/arkcontract.d.ts +0 -2
  51. package/dist/types/contracts/contractManager.d.ts +38 -9
  52. package/dist/types/contracts/contractWatcher.d.ts +22 -21
  53. package/dist/types/contracts/types.d.ts +0 -7
  54. package/dist/types/repositories/indexedDB/manager.d.ts +5 -2
  55. package/dist/types/repositories/indexedDB/schema.d.ts +3 -2
  56. package/dist/types/repositories/realm/index.d.ts +1 -1
  57. package/dist/types/repositories/realm/schemas.d.ts +41 -0
  58. package/dist/types/repositories/scriptFromAddress.d.ts +9 -0
  59. package/dist/types/repositories/serialization.d.ts +1 -1
  60. package/dist/types/repositories/sqlite/walletRepository.d.ts +22 -0
  61. package/dist/types/repositories/walletRepository.d.ts +10 -2
  62. package/dist/types/utils/syncCursors.d.ts +25 -23
  63. package/dist/types/wallet/index.d.ts +1 -1
  64. package/dist/types/wallet/serviceWorker/wallet-message-handler.d.ts +15 -3
  65. package/dist/types/wallet/utils.d.ts +20 -4
  66. package/dist/types/wallet/vtxo-manager.d.ts +29 -6
  67. package/dist/types/wallet/wallet.d.ts +8 -17
  68. package/dist/types/worker/expo/processors/contractPollProcessor.d.ts +9 -4
  69. package/dist/types/worker/expo/taskRunner.d.ts +6 -3
  70. package/package.json +1 -1
@@ -27,6 +27,38 @@ function assertSweepCapable(wallet) {
27
27
  throw new Error("Boarding UTXO sweep requires a Wallet instance with boardingTapscript, onchainProvider, and network");
28
28
  }
29
29
  }
30
+ /**
31
+ * Web Locks name used to serialize boarding-poll work across same-origin
32
+ * browser contexts (tabs, service worker). Static because the goal is to
33
+ * deduplicate polls for the *same* wallet — two distinct wallets on the
34
+ * same origin will take turns, which is acceptable.
35
+ */
36
+ const BOARDING_POLL_LOCK_NAME = "arkade-boarding-poll";
37
+ /**
38
+ * Run `fn` under an exclusive Web Lock when the runtime provides one
39
+ * (browser main thread, service worker). In environments without
40
+ * `navigator.locks` (Node, React Native) the callback runs immediately
41
+ * with no coordination.
42
+ *
43
+ * Uses `ifAvailable: true`: if another context already holds the lock,
44
+ * skip this cycle entirely rather than queueing — the other context will
45
+ * do the work and the next poll will re-check.
46
+ */
47
+ async function runWithCrossInstanceLock(name, fn) {
48
+ const locks = typeof globalThis !== "undefined" &&
49
+ typeof globalThis.navigator !== "undefined"
50
+ ? globalThis.navigator.locks
51
+ : undefined;
52
+ if (!locks) {
53
+ await fn();
54
+ return;
55
+ }
56
+ await locks.request(name, { ifAvailable: true, mode: "exclusive" }, async (lock) => {
57
+ if (lock === null)
58
+ return;
59
+ await fn();
60
+ });
61
+ }
30
62
  /** Default renewal threshold in seconds (3 days). */
31
63
  export const DEFAULT_THRESHOLD_SECONDS = 3 * 24 * 60 * 60;
32
64
  /**
@@ -186,6 +218,20 @@ export class VtxoManager {
186
218
  // server emits new VTXOs → vtxo_received → renewVtxos() again → infinite loop.
187
219
  this.renewalInProgress = false;
188
220
  this.lastRenewalTimestamp = 0;
221
+ // Guards against a retry treadmill on the periodic-settle path: a failing
222
+ // settle would otherwise re-submit identical intents on every 60s poll,
223
+ // producing per-minute DeleteIntent RPCs forever. Mirrors the renewal
224
+ // cooldown but with exponential backoff on consecutive failures, so a
225
+ // persistently broken input eventually drops to the backoff cap instead
226
+ // of hammering the server. Shared across boarding + expiring-VTXO work
227
+ // because they now ride on the same settle intent.
228
+ this.lastPeriodicSettleTimestamp = 0;
229
+ this.consecutivePeriodicSettleFailures = 0;
230
+ // Throttle for the VTXO_ALREADY_SPENT -> refreshVtxos() reconciliation.
231
+ // The server's authoritative view says our local cache is stale, so we
232
+ // trigger a full refresh to advance the global sync cursor. Rate-limit
233
+ // to guard against a buggy indexer cycling us into a refresh storm.
234
+ this.lastVtxoSpentRefreshTimestamp = 0;
189
235
  // Normalize: prefer settlementConfig, fall back to renewalConfig, default to enabled
190
236
  if (settlementConfig !== undefined) {
191
237
  this.settlementConfig = settlementConfig;
@@ -407,10 +453,15 @@ export class VtxoManager {
407
453
  },
408
454
  ],
409
455
  }, eventCallback);
410
- this.lastRenewalTimestamp = Date.now();
411
456
  return txid;
412
457
  }
413
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();
414
465
  this.renewalInProgress = false;
415
466
  }
416
467
  }
@@ -616,7 +667,6 @@ export class VtxoManager {
616
667
  return;
617
668
  }
618
669
  if (e.message.includes("VTXO_ALREADY_REGISTERED") ||
619
- e.message.includes("VTXO_ALREADY_SPENT") ||
620
670
  e.message.includes("duplicated input")) {
621
671
  // Virtual output is already being used in a concurrent
622
672
  // user-initiated operation. Skip silently — the
@@ -624,6 +674,14 @@ export class VtxoManager {
624
674
  // renewal will retry on the next cycle.
625
675
  return;
626
676
  }
677
+ if (e.message.includes("VTXO_ALREADY_SPENT")) {
678
+ // Our local VTXO cache is stale vs. the
679
+ // server's authoritative view. Trigger a
680
+ // throttled refresh to reconcile, then skip
681
+ // — the next cycle will see fresh data.
682
+ void this.maybeRefreshAfterVtxoSpent();
683
+ return;
684
+ }
627
685
  }
628
686
  console.error("Error renewing VTXOs:", e);
629
687
  });
@@ -641,6 +699,39 @@ export class VtxoManager {
641
699
  return undefined;
642
700
  }
643
701
  }
702
+ /**
703
+ * VTXO_ALREADY_SPENT means the server's authoritative view of VTXO state
704
+ * is ahead of ours — cross-instance race, pre-lock snapshot drift, or an
705
+ * SSE gap left stale data in the local cache. Silent-swallowing guarantees
706
+ * the same error on the next cycle because nothing reconciles the cache,
707
+ * so instead we trigger a full refreshVtxos() to advance the global sync
708
+ * cursor. Throttled to prevent a buggy indexer from causing a refresh
709
+ * storm.
710
+ */
711
+ maybeRefreshAfterVtxoSpent() {
712
+ if (this.vtxoSpentRefreshPromise) {
713
+ return this.vtxoSpentRefreshPromise;
714
+ }
715
+ const now = Date.now();
716
+ if (now - this.lastVtxoSpentRefreshTimestamp <
717
+ VtxoManager.VTXO_SPENT_REFRESH_COOLDOWN_MS) {
718
+ return Promise.resolve();
719
+ }
720
+ this.lastVtxoSpentRefreshTimestamp = now;
721
+ this.vtxoSpentRefreshPromise = (async () => {
722
+ try {
723
+ const contractManager = await this.wallet.getContractManager();
724
+ await contractManager.refreshVtxos();
725
+ }
726
+ catch (e) {
727
+ console.error("Error refreshing VTXOs after VTXO_ALREADY_SPENT:", e);
728
+ }
729
+ finally {
730
+ this.vtxoSpentRefreshPromise = undefined;
731
+ }
732
+ })();
733
+ return this.vtxoSpentRefreshPromise;
734
+ }
644
735
  /** Computes the next poll delay, applying exponential backoff on failures. */
645
736
  getNextPollDelay() {
646
737
  if (this.settlementConfig === false)
@@ -688,33 +779,42 @@ export class VtxoManager {
688
779
  this.pollDone = { promise, resolve: resolve };
689
780
  let hadError = false;
690
781
  try {
691
- // Fetch boarding inputs once for the entire poll cycle so that
692
- // settle and sweep don't each hit the network independently.
693
- const boardingUtxos = await this.wallet.getBoardingUtxos();
694
- // Settle new (unexpired) boarding inputs first, then sweep expired ones.
695
- // Sequential to avoid racing for the same inputs.
696
- try {
697
- await this.settleBoardingUtxos(boardingUtxos);
698
- }
699
- catch (e) {
700
- hadError = true;
701
- console.error("Error auto-settling boarding UTXOs:", e);
702
- }
703
- const sweepEnabled = this.settlementConfig !== false &&
704
- (this.settlementConfig?.boardingUtxoSweep ??
705
- DEFAULT_SETTLEMENT_CONFIG.boardingUtxoSweep);
706
- if (sweepEnabled) {
782
+ // Cross-instance guard: in browser / service worker environments,
783
+ // serialize the poll body across tabs and SW contexts so only one
784
+ // of them registers intents per interval. Without this, every tab
785
+ // submits a parallel RegisterIntent for the same boarding input
786
+ // and N-1 of them collide on the server's duplicated-input check,
787
+ // each producing a DeleteIntent RPC. No-op outside the browser.
788
+ await runWithCrossInstanceLock(BOARDING_POLL_LOCK_NAME, async () => {
789
+ // Fetch boarding inputs once for the entire poll cycle so that
790
+ // settle and sweep don't each hit the network independently.
791
+ const boardingUtxos = await this.wallet.getBoardingUtxos();
792
+ // Settle new (unexpired) boarding inputs + any near-expiry
793
+ // VTXOs in a single intent, then sweep expired boarding
794
+ // inputs. Sequential to avoid racing for the same inputs.
707
795
  try {
708
- await this.sweepExpiredBoardingUtxos(boardingUtxos);
796
+ await this.runPeriodicSettle(boardingUtxos);
709
797
  }
710
798
  catch (e) {
711
- if (!(e instanceof Error) ||
712
- !e.message.includes("No expired boarding UTXOs")) {
713
- hadError = true;
714
- console.error("Error auto-sweeping boarding UTXOs:", e);
799
+ hadError = true;
800
+ console.error("Error during periodic settle:", e);
801
+ }
802
+ const sweepEnabled = this.settlementConfig !== false &&
803
+ (this.settlementConfig?.boardingUtxoSweep ??
804
+ DEFAULT_SETTLEMENT_CONFIG.boardingUtxoSweep);
805
+ if (sweepEnabled) {
806
+ try {
807
+ await this.sweepExpiredBoardingUtxos(boardingUtxos);
808
+ }
809
+ catch (e) {
810
+ if (!(e instanceof Error) ||
811
+ !e.message.includes("No expired boarding UTXOs")) {
812
+ hadError = true;
813
+ console.error("Error auto-sweeping boarding UTXOs:", e);
814
+ }
715
815
  }
716
816
  }
717
- }
817
+ });
718
818
  }
719
819
  catch (e) {
720
820
  hadError = true;
@@ -734,13 +834,19 @@ export class VtxoManager {
734
834
  }
735
835
  }
736
836
  /**
737
- * Auto-settle new (unexpired) boarding inputs into Arkade.
738
- * Skips UTXOs that are already expired (those are handled by sweep).
739
- * Only settles UTXOs not already in-flight (tracked in knownBoardingUtxos).
740
- * UTXOs are marked as known only after a successful settle, so failed
741
- * attempts will be retried on the next poll.
837
+ * Auto-settle new (unexpired) boarding inputs AND near-expiry VTXOs into
838
+ * Arkade in a single intent. Skips boarding UTXOs that are already expired
839
+ * (those are handled by sweep) and those already in-flight (tracked in
840
+ * knownBoardingUtxos). If the event-driven renewal path is currently
841
+ * running, VTXOs are omitted from this cycle to avoid double-spending.
842
+ *
843
+ * Failure bookkeeping: after every settle *attempt*, lastPeriodicSettleTimestamp
844
+ * is armed and consecutive failures are counted so the next attempt is
845
+ * blocked by an exponentially growing cooldown (capped). This stops a
846
+ * persistently failing input from producing identical RegisterIntent +
847
+ * DeleteIntent retries on every 60s poll.
742
848
  */
743
- async settleBoardingUtxos(boardingUtxos) {
849
+ async runPeriodicSettle(boardingUtxos) {
744
850
  // Exclude expired boarding inputs — those should be swept, not settled.
745
851
  // If we can't determine expired status, bail out entirely to avoid
746
852
  // accidentally settling expired inputs (which would conflict with sweep).
@@ -758,22 +864,95 @@ export class VtxoManager {
758
864
  catch (e) {
759
865
  throw e instanceof Error ? e : new Error(String(e));
760
866
  }
761
- const unsettledUtxos = boardingUtxos.filter((u) => !this.knownBoardingUtxos.has(`${u.txid}:${u.vout}`) &&
867
+ const unsettledBoarding = boardingUtxos.filter((u) => u.status.confirmed &&
868
+ !this.knownBoardingUtxos.has(`${u.txid}:${u.vout}`) &&
762
869
  !expiredSet.has(`${u.txid}:${u.vout}`));
763
- if (unsettledUtxos.length === 0)
870
+ // Collect near-expiry VTXOs unless the event-driven path is mid-renewal.
871
+ // Skipping when renewalInProgress avoids double-submitting the same VTXOs.
872
+ let expiringVtxos = [];
873
+ if (!this.renewalInProgress) {
874
+ try {
875
+ expiringVtxos = await this.getExpiringVtxos();
876
+ }
877
+ catch (e) {
878
+ // Non-fatal: fall back to boarding-only settle.
879
+ console.error("Error fetching expiring VTXOs:", e);
880
+ }
881
+ }
882
+ if (unsettledBoarding.length === 0 && expiringVtxos.length === 0) {
764
883
  return;
884
+ }
885
+ // Respect the cooldown armed by the previous attempt. Cooldown grows
886
+ // exponentially with consecutive failures and is capped by
887
+ // PERIODIC_SETTLE_MAX_BACKOFF_MS.
888
+ const cooldownMs = Math.min(VtxoManager.PERIODIC_SETTLE_COOLDOWN_MS *
889
+ Math.pow(2, this.consecutivePeriodicSettleFailures), VtxoManager.PERIODIC_SETTLE_MAX_BACKOFF_MS);
890
+ if (Date.now() - this.lastPeriodicSettleTimestamp < cooldownMs) {
891
+ return;
892
+ }
765
893
  const dustAmount = getDustAmount(this.wallet);
766
- const totalAmount = unsettledUtxos.reduce((sum, u) => sum + BigInt(u.value), 0n);
894
+ const boardingTotal = unsettledBoarding.reduce((sum, u) => sum + BigInt(u.value), 0n);
895
+ const vtxoTotal = expiringVtxos.reduce((sum, v) => sum + BigInt(v.value), 0n);
896
+ const totalAmount = boardingTotal + vtxoTotal;
767
897
  if (totalAmount < dustAmount)
768
898
  return;
769
899
  const arkAddress = await this.wallet.getAddress();
770
- await this.wallet.settle({
771
- inputs: unsettledUtxos,
772
- outputs: [{ address: arkAddress, amount: totalAmount }],
773
- });
774
- // Mark as known only after successful settle
775
- for (const u of unsettledUtxos) {
776
- this.knownBoardingUtxos.add(`${u.txid}:${u.vout}`);
900
+ const includesVtxos = expiringVtxos.length > 0;
901
+ // Block the event-driven renewal path while this settle is in flight
902
+ // when VTXOs are part of the intent. Mirrors renewVtxos()'s guard so
903
+ // the two paths can't race on the same VTXO inputs.
904
+ if (includesVtxos) {
905
+ this.renewalInProgress = true;
906
+ }
907
+ let success = false;
908
+ let staleCacheSkip = false;
909
+ try {
910
+ try {
911
+ await this.wallet.settle({
912
+ inputs: [...unsettledBoarding, ...expiringVtxos],
913
+ outputs: [{ address: arkAddress, amount: totalAmount }],
914
+ });
915
+ // Mark boarding inputs as known only after successful settle.
916
+ for (const u of unsettledBoarding) {
917
+ this.knownBoardingUtxos.add(`${u.txid}:${u.vout}`);
918
+ }
919
+ success = true;
920
+ }
921
+ catch (e) {
922
+ if (e instanceof Error &&
923
+ e.message.includes("VTXO_ALREADY_SPENT")) {
924
+ // Local VTXO cache is stale vs. the server's
925
+ // authoritative view — not a transient failure.
926
+ // Trigger a throttled refresh and skip this cycle
927
+ // without bumping the failure counter, so the next
928
+ // poll can retry once the cache reconciles.
929
+ staleCacheSkip = true;
930
+ void this.maybeRefreshAfterVtxoSpent();
931
+ }
932
+ else {
933
+ throw e;
934
+ }
935
+ }
936
+ }
937
+ finally {
938
+ this.lastPeriodicSettleTimestamp = Date.now();
939
+ if (includesVtxos) {
940
+ // Match event-path semantics: bump the renewal cooldown
941
+ // whether we succeeded or failed so a failed periodic settle
942
+ // doesn't let the next vtxo_received event re-enter renewal
943
+ // immediately.
944
+ this.lastRenewalTimestamp = Date.now();
945
+ this.renewalInProgress = false;
946
+ }
947
+ if (success) {
948
+ this.consecutivePeriodicSettleFailures = 0;
949
+ }
950
+ else if (!staleCacheSkip) {
951
+ // Don't bump on stale-cache skip: it's not a transient
952
+ // failure, and the next cycle should try immediately
953
+ // after the refresh lands.
954
+ this.consecutivePeriodicSettleFailures++;
955
+ }
777
956
  }
778
957
  }
779
958
  async dispose() {
@@ -806,3 +985,6 @@ export class VtxoManager {
806
985
  }
807
986
  VtxoManager.MAX_BACKOFF_MS = 5 * 60 * 1000; // 5 minutes
808
987
  VtxoManager.RENEWAL_COOLDOWN_MS = 30000; // 30 seconds
988
+ VtxoManager.PERIODIC_SETTLE_COOLDOWN_MS = 30000;
989
+ VtxoManager.PERIODIC_SETTLE_MAX_BACKOFF_MS = 5 * 60 * 1000;
990
+ VtxoManager.VTXO_SPENT_REFRESH_COOLDOWN_MS = 30000;