@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
@@ -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,15 @@ 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;
189
230
  // Normalize: prefer settlementConfig, fall back to renewalConfig, default to enabled
190
231
  if (settlementConfig !== undefined) {
191
232
  this.settlementConfig = settlementConfig;
@@ -407,10 +448,15 @@ export class VtxoManager {
407
448
  },
408
449
  ],
409
450
  }, eventCallback);
410
- this.lastRenewalTimestamp = Date.now();
411
451
  return txid;
412
452
  }
413
453
  finally {
454
+ // Update cooldown on EVERY attempt (success or failure) so transient
455
+ // settle failures (stream close, connector mismatch, duplicated input)
456
+ // don't allow the next vtxo_received event to re-enter renewal
457
+ // immediately. Without this, a failed settle leaves lastRenewalTimestamp
458
+ // at its previous value and the cooldown check becomes a no-op.
459
+ this.lastRenewalTimestamp = Date.now();
414
460
  this.renewalInProgress = false;
415
461
  }
416
462
  }
@@ -688,33 +734,42 @@ export class VtxoManager {
688
734
  this.pollDone = { promise, resolve: resolve };
689
735
  let hadError = false;
690
736
  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) {
737
+ // Cross-instance guard: in browser / service worker environments,
738
+ // serialize the poll body across tabs and SW contexts so only one
739
+ // of them registers intents per interval. Without this, every tab
740
+ // submits a parallel RegisterIntent for the same boarding input
741
+ // and N-1 of them collide on the server's duplicated-input check,
742
+ // each producing a DeleteIntent RPC. No-op outside the browser.
743
+ await runWithCrossInstanceLock(BOARDING_POLL_LOCK_NAME, async () => {
744
+ // Fetch boarding inputs once for the entire poll cycle so that
745
+ // settle and sweep don't each hit the network independently.
746
+ const boardingUtxos = await this.wallet.getBoardingUtxos();
747
+ // Settle new (unexpired) boarding inputs + any near-expiry
748
+ // VTXOs in a single intent, then sweep expired boarding
749
+ // inputs. Sequential to avoid racing for the same inputs.
707
750
  try {
708
- await this.sweepExpiredBoardingUtxos(boardingUtxos);
751
+ await this.runPeriodicSettle(boardingUtxos);
709
752
  }
710
753
  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);
754
+ hadError = true;
755
+ console.error("Error during periodic settle:", e);
756
+ }
757
+ const sweepEnabled = this.settlementConfig !== false &&
758
+ (this.settlementConfig?.boardingUtxoSweep ??
759
+ DEFAULT_SETTLEMENT_CONFIG.boardingUtxoSweep);
760
+ if (sweepEnabled) {
761
+ try {
762
+ await this.sweepExpiredBoardingUtxos(boardingUtxos);
763
+ }
764
+ catch (e) {
765
+ if (!(e instanceof Error) ||
766
+ !e.message.includes("No expired boarding UTXOs")) {
767
+ hadError = true;
768
+ console.error("Error auto-sweeping boarding UTXOs:", e);
769
+ }
715
770
  }
716
771
  }
717
- }
772
+ });
718
773
  }
719
774
  catch (e) {
720
775
  hadError = true;
@@ -734,13 +789,19 @@ export class VtxoManager {
734
789
  }
735
790
  }
736
791
  /**
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.
792
+ * Auto-settle new (unexpired) boarding inputs AND near-expiry VTXOs into
793
+ * Arkade in a single intent. Skips boarding UTXOs that are already expired
794
+ * (those are handled by sweep) and those already in-flight (tracked in
795
+ * knownBoardingUtxos). If the event-driven renewal path is currently
796
+ * running, VTXOs are omitted from this cycle to avoid double-spending.
797
+ *
798
+ * Failure bookkeeping: after every settle *attempt*, lastPeriodicSettleTimestamp
799
+ * is armed and consecutive failures are counted so the next attempt is
800
+ * blocked by an exponentially growing cooldown (capped). This stops a
801
+ * persistently failing input from producing identical RegisterIntent +
802
+ * DeleteIntent retries on every 60s poll.
742
803
  */
743
- async settleBoardingUtxos(boardingUtxos) {
804
+ async runPeriodicSettle(boardingUtxos) {
744
805
  // Exclude expired boarding inputs — those should be swept, not settled.
745
806
  // If we can't determine expired status, bail out entirely to avoid
746
807
  // accidentally settling expired inputs (which would conflict with sweep).
@@ -758,22 +819,73 @@ export class VtxoManager {
758
819
  catch (e) {
759
820
  throw e instanceof Error ? e : new Error(String(e));
760
821
  }
761
- const unsettledUtxos = boardingUtxos.filter((u) => !this.knownBoardingUtxos.has(`${u.txid}:${u.vout}`) &&
822
+ const unsettledBoarding = boardingUtxos.filter((u) => !this.knownBoardingUtxos.has(`${u.txid}:${u.vout}`) &&
762
823
  !expiredSet.has(`${u.txid}:${u.vout}`));
763
- if (unsettledUtxos.length === 0)
824
+ // Collect near-expiry VTXOs unless the event-driven path is mid-renewal.
825
+ // Skipping when renewalInProgress avoids double-submitting the same VTXOs.
826
+ let expiringVtxos = [];
827
+ if (!this.renewalInProgress) {
828
+ try {
829
+ expiringVtxos = await this.getExpiringVtxos();
830
+ }
831
+ catch (e) {
832
+ // Non-fatal: fall back to boarding-only settle.
833
+ console.error("Error fetching expiring VTXOs:", e);
834
+ }
835
+ }
836
+ if (unsettledBoarding.length === 0 && expiringVtxos.length === 0) {
837
+ return;
838
+ }
839
+ // Respect the cooldown armed by the previous attempt. Cooldown grows
840
+ // exponentially with consecutive failures and is capped by
841
+ // PERIODIC_SETTLE_MAX_BACKOFF_MS.
842
+ const cooldownMs = Math.min(VtxoManager.PERIODIC_SETTLE_COOLDOWN_MS *
843
+ Math.pow(2, this.consecutivePeriodicSettleFailures), VtxoManager.PERIODIC_SETTLE_MAX_BACKOFF_MS);
844
+ if (Date.now() - this.lastPeriodicSettleTimestamp < cooldownMs) {
764
845
  return;
846
+ }
765
847
  const dustAmount = getDustAmount(this.wallet);
766
- const totalAmount = unsettledUtxos.reduce((sum, u) => sum + BigInt(u.value), 0n);
848
+ const boardingTotal = unsettledBoarding.reduce((sum, u) => sum + BigInt(u.value), 0n);
849
+ const vtxoTotal = expiringVtxos.reduce((sum, v) => sum + BigInt(v.value), 0n);
850
+ const totalAmount = boardingTotal + vtxoTotal;
767
851
  if (totalAmount < dustAmount)
768
852
  return;
769
853
  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}`);
854
+ const includesVtxos = expiringVtxos.length > 0;
855
+ // Block the event-driven renewal path while this settle is in flight
856
+ // when VTXOs are part of the intent. Mirrors renewVtxos()'s guard so
857
+ // the two paths can't race on the same VTXO inputs.
858
+ if (includesVtxos) {
859
+ this.renewalInProgress = true;
860
+ }
861
+ let success = false;
862
+ try {
863
+ await this.wallet.settle({
864
+ inputs: [...unsettledBoarding, ...expiringVtxos],
865
+ outputs: [{ address: arkAddress, amount: totalAmount }],
866
+ });
867
+ // Mark boarding inputs as known only after successful settle.
868
+ for (const u of unsettledBoarding) {
869
+ this.knownBoardingUtxos.add(`${u.txid}:${u.vout}`);
870
+ }
871
+ success = true;
872
+ }
873
+ finally {
874
+ this.lastPeriodicSettleTimestamp = Date.now();
875
+ if (includesVtxos) {
876
+ // Match event-path semantics: bump the renewal cooldown
877
+ // whether we succeeded or failed so a failed periodic settle
878
+ // doesn't let the next vtxo_received event re-enter renewal
879
+ // immediately.
880
+ this.lastRenewalTimestamp = Date.now();
881
+ this.renewalInProgress = false;
882
+ }
883
+ if (success) {
884
+ this.consecutivePeriodicSettleFailures = 0;
885
+ }
886
+ else {
887
+ this.consecutivePeriodicSettleFailures++;
888
+ }
777
889
  }
778
890
  }
779
891
  async dispose() {
@@ -806,3 +918,5 @@ export class VtxoManager {
806
918
  }
807
919
  VtxoManager.MAX_BACKOFF_MS = 5 * 60 * 1000; // 5 minutes
808
920
  VtxoManager.RENEWAL_COOLDOWN_MS = 30000; // 30 seconds
921
+ VtxoManager.PERIODIC_SETTLE_COOLDOWN_MS = 30000;
922
+ VtxoManager.PERIODIC_SETTLE_MAX_BACKOFF_MS = 5 * 60 * 1000;