@arkade-os/sdk 0.4.16 → 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 -199
  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 +84 -202
  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 -201
  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 +87 -205
  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 -12
  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;