@arkade-os/sdk 0.4.18 → 0.4.20

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 (60) hide show
  1. package/dist/cjs/contracts/contractWatcher.js +7 -1
  2. package/dist/cjs/contracts/handlers/default.js +10 -3
  3. package/dist/cjs/contracts/handlers/helpers.js +47 -5
  4. package/dist/cjs/contracts/handlers/vhtlc.js +4 -2
  5. package/dist/cjs/identity/descriptor.js +98 -0
  6. package/dist/cjs/identity/descriptorProvider.js +2 -0
  7. package/dist/cjs/identity/index.js +15 -1
  8. package/dist/cjs/identity/seedIdentity.js +91 -6
  9. package/dist/cjs/identity/serialize.js +166 -0
  10. package/dist/cjs/identity/staticDescriptorProvider.js +65 -0
  11. package/dist/cjs/index.js +6 -3
  12. package/dist/cjs/providers/ark.js +45 -34
  13. package/dist/cjs/providers/electrum.js +663 -0
  14. package/dist/cjs/providers/indexer.js +5 -1
  15. package/dist/cjs/providers/utils.js +4 -0
  16. package/dist/cjs/wallet/ramps.js +1 -1
  17. package/dist/cjs/wallet/serviceWorker/wallet-message-handler.js +10 -0
  18. package/dist/cjs/wallet/serviceWorker/wallet.js +137 -91
  19. package/dist/cjs/wallet/vtxo-manager.js +133 -17
  20. package/dist/cjs/wallet/wallet.js +80 -19
  21. package/dist/cjs/worker/messageBus.js +200 -56
  22. package/dist/esm/contracts/contractWatcher.js +7 -1
  23. package/dist/esm/contracts/handlers/default.js +10 -3
  24. package/dist/esm/contracts/handlers/helpers.js +47 -5
  25. package/dist/esm/contracts/handlers/vhtlc.js +4 -2
  26. package/dist/esm/identity/descriptor.js +92 -0
  27. package/dist/esm/identity/descriptorProvider.js +1 -0
  28. package/dist/esm/identity/index.js +6 -1
  29. package/dist/esm/identity/seedIdentity.js +89 -6
  30. package/dist/esm/identity/serialize.js +159 -0
  31. package/dist/esm/identity/staticDescriptorProvider.js +61 -0
  32. package/dist/esm/index.js +2 -1
  33. package/dist/esm/providers/ark.js +46 -35
  34. package/dist/esm/providers/electrum.js +658 -0
  35. package/dist/esm/providers/indexer.js +6 -2
  36. package/dist/esm/providers/utils.js +3 -0
  37. package/dist/esm/wallet/ramps.js +1 -1
  38. package/dist/esm/wallet/serviceWorker/wallet-message-handler.js +10 -0
  39. package/dist/esm/wallet/serviceWorker/wallet.js +137 -91
  40. package/dist/esm/wallet/vtxo-manager.js +133 -17
  41. package/dist/esm/wallet/wallet.js +80 -19
  42. package/dist/esm/worker/messageBus.js +201 -57
  43. package/dist/types/contracts/handlers/default.d.ts +1 -1
  44. package/dist/types/contracts/handlers/helpers.d.ts +1 -1
  45. package/dist/types/contracts/types.d.ts +11 -3
  46. package/dist/types/identity/descriptor.d.ts +35 -0
  47. package/dist/types/identity/descriptorProvider.d.ts +28 -0
  48. package/dist/types/identity/index.d.ts +7 -1
  49. package/dist/types/identity/seedIdentity.d.ts +41 -4
  50. package/dist/types/identity/serialize.d.ts +84 -0
  51. package/dist/types/identity/staticDescriptorProvider.d.ts +18 -0
  52. package/dist/types/index.d.ts +4 -2
  53. package/dist/types/providers/electrum.d.ts +212 -0
  54. package/dist/types/providers/utils.d.ts +1 -0
  55. package/dist/types/wallet/serviceWorker/wallet-message-handler.d.ts +11 -2
  56. package/dist/types/wallet/serviceWorker/wallet.d.ts +27 -10
  57. package/dist/types/wallet/vtxo-manager.d.ts +15 -0
  58. package/dist/types/wallet/wallet.d.ts +3 -0
  59. package/dist/types/worker/messageBus.d.ts +68 -8
  60. package/package.json +3 -2
@@ -139,7 +139,7 @@ export class Ramps {
139
139
  weight: 0,
140
140
  birth: vtxo.createdAt,
141
141
  expiry: vtxo.virtualStatus.batchExpiry
142
- ? new Date(vtxo.virtualStatus.batchExpiry * 1000)
142
+ ? new Date(vtxo.virtualStatus.batchExpiry)
143
143
  : undefined,
144
144
  });
145
145
  if (inputFee.satoshis >= vtxo.value) {
@@ -97,6 +97,16 @@ export class WalletMessageHandler {
97
97
  tag: this.messageTag,
98
98
  };
99
99
  }
100
+ // Flows that surrender control to the Ark server and the other participants
101
+ // in a batch round: quiet gaps between protocol events can easily exceed
102
+ // the bus-level messageTimeoutMs. Liveness is covered out-of-band by the
103
+ // page-side PING / MESSAGE_BUS_NOT_INITIALIZED path triggered by concurrent
104
+ // short requests (GET_STATUS, GET_BALANCE, ...).
105
+ isLongRunning(message) {
106
+ return (message.type === "SETTLE" ||
107
+ message.type === "RECOVER_VTXOS" ||
108
+ message.type === "RENEW_VTXOS");
109
+ }
100
110
  async handleMessage(message) {
101
111
  const id = message.id;
102
112
  if (message.type === "INIT_WALLET") {
@@ -1,4 +1,5 @@
1
1
  import { hex } from "@scure/base";
2
+ import { serializeReadonlyIdentity, serializeSigningIdentity, } from '../../identity/index.js';
2
3
  import { setupServiceWorker } from '../../worker/browser/utils.js';
3
4
  import { IndexedDBContractRepository, IndexedDBWalletRepository, } from '../../repositories/index.js';
4
5
  import { DEFAULT_MESSAGE_TAG, } from './wallet-message-handler.js';
@@ -33,7 +34,10 @@ export const DEFAULT_MESSAGE_TIMEOUTS = {
33
34
  GET_EXPIRED_BOARDING_UTXOS: 20000,
34
35
  GET_RECOVERABLE_BALANCE: 20000,
35
36
  RELOAD_WALLET: 20000,
36
- // Transactions — need more headroom
37
+ // Transactions — need more headroom.
38
+ // SETTLE / RECOVER_VTXOS / RENEW_VTXOS go through the streaming path and
39
+ // are treated as long-running on both sides of the bus: the values below
40
+ // are retained only for type completeness and are never enforced.
37
41
  SEND_BITCOIN: 50000,
38
42
  SEND: 50000,
39
43
  SETTLE: 50000,
@@ -78,9 +82,12 @@ function getRequestDedupKey(request) {
78
82
  const { id, tag, ...rest } = request;
79
83
  return JSON.stringify(rest);
80
84
  }
81
- const isPrivateKeyIdentity = (identity) => {
82
- return typeof identity.toHex === "function";
83
- };
85
+ function isSigningCapable(identity) {
86
+ const candidate = identity;
87
+ return (typeof candidate.signMessage === "function" &&
88
+ typeof candidate.sign === "function" &&
89
+ typeof candidate.signerSession === "function");
90
+ }
84
91
  class ServiceWorkerReadonlyAssetManager {
85
92
  constructor(sendMessage, messageTag) {
86
93
  this.sendMessage = sendMessage;
@@ -196,55 +203,54 @@ export class ServiceWorkerReadonlyWallet {
196
203
  const messageTag = options.walletUpdaterTag ?? DEFAULT_MESSAGE_TAG;
197
204
  // Create the wallet instance
198
205
  const wallet = new ServiceWorkerReadonlyWallet(options.serviceWorker, options.identity, walletRepository, contractRepository, messageTag);
206
+ const serializedWallet = await serializeReadonlyIdentity(options.identity);
207
+ // INIT_WALLET retains the legacy `key` payload for wire compatibility
208
+ // with older workers; the current handler does not read it.
199
209
  const publicKey = await options.identity
200
210
  .compressedPublicKey()
201
211
  .then(hex.encode);
202
- const initConfig = {
212
+ const initWalletPayload = {
203
213
  key: { publicKey },
204
214
  arkServerUrl: options.arkServerUrl,
205
215
  arkServerPublicKey: options.arkServerPublicKey,
206
216
  delegatorUrl: options.delegatorUrl,
207
217
  };
208
- // Bootstrap the MessageBus in the service worker
209
- await initializeMessageBus(options.serviceWorker, {
210
- wallet: initConfig.key,
218
+ // Precompute the merged timeout map so page-side waiting and
219
+ // worker-side enforcement are derived from the same source.
220
+ const messageTimeouts = options.messageTimeouts
221
+ ? {
222
+ ...DEFAULT_MESSAGE_TIMEOUTS,
223
+ ...options.messageTimeouts,
224
+ }
225
+ : DEFAULT_MESSAGE_TIMEOUTS;
226
+ const busInitConfig = {
227
+ wallet: serializedWallet,
211
228
  arkServer: {
212
- url: initConfig.arkServerUrl,
213
- publicKey: initConfig.arkServerPublicKey,
229
+ url: options.arkServerUrl,
230
+ publicKey: options.arkServerPublicKey,
214
231
  },
215
- delegatorUrl: initConfig.delegatorUrl,
232
+ delegatorUrl: options.delegatorUrl,
216
233
  indexerUrl: options.indexerUrl,
217
234
  esploraUrl: options.esploraUrl,
218
- timeoutMs: options.messageBusTimeoutMs,
219
235
  watcherConfig: options.watcherConfig,
220
- }, options.messageBusTimeoutMs);
236
+ messageTimeouts,
237
+ };
238
+ // Bootstrap the MessageBus in the service worker
239
+ await initializeMessageBus(options.serviceWorker, { ...busInitConfig, timeoutMs: options.messageBusTimeoutMs }, options.messageBusTimeoutMs);
221
240
  // Initialize the wallet handler
222
241
  const initMessage = {
223
242
  tag: messageTag,
224
243
  type: "INIT_WALLET",
225
244
  id: getRandomId(),
226
- payload: initConfig,
245
+ payload: initWalletPayload,
227
246
  };
228
247
  await wallet.sendMessage(initMessage);
229
- wallet.initConfig = {
230
- wallet: initConfig.key,
231
- arkServer: {
232
- url: initConfig.arkServerUrl,
233
- publicKey: initConfig.arkServerPublicKey,
234
- },
235
- delegatorUrl: initConfig.delegatorUrl,
236
- indexerUrl: options.indexerUrl,
237
- esploraUrl: options.esploraUrl,
238
- watcherConfig: options.watcherConfig,
239
- };
240
- wallet.initWalletPayload = initConfig;
248
+ // Persist the full init config (including messageTimeouts) so
249
+ // reinitialize() re-sends the same map to a restarted worker.
250
+ wallet.initConfig = busInitConfig;
251
+ wallet.initWalletPayload = initWalletPayload;
241
252
  wallet.messageBusTimeoutMs = options.messageBusTimeoutMs;
242
- if (options.messageTimeouts) {
243
- wallet.messageTimeouts = {
244
- ...DEFAULT_MESSAGE_TIMEOUTS,
245
- ...options.messageTimeouts,
246
- };
247
- }
253
+ wallet.messageTimeouts = messageTimeouts;
248
254
  return wallet;
249
255
  }
250
256
  /**
@@ -303,24 +309,16 @@ export class ServiceWorkerReadonlyWallet {
303
309
  }
304
310
  // Like sendMessageDirect but supports streaming responses: intermediate
305
311
  // messages are forwarded via onEvent while the promise resolves on the
306
- // first response for which isComplete returns true. The timeout resets
307
- // on every intermediate event so long-running but progressing operations
308
- // don't time out prematurely.
309
- sendMessageStreaming(request, onEvent, isComplete, timeoutMs) {
312
+ // first response for which isComplete returns true. No inactivity deadline:
313
+ // settlement-class flows surrender control to remote peers and can sit
314
+ // idle for long stretches between protocol events. Service-worker death
315
+ // is detected out-of-band via concurrent short requests that surface
316
+ // MESSAGE_BUS_NOT_INITIALIZED.
317
+ sendMessageStreaming(request, onEvent, isComplete) {
310
318
  return new Promise((resolve, reject) => {
311
- const resetTimeout = () => {
312
- clearTimeout(timeoutId);
313
- timeoutId = setTimeout(() => {
314
- cleanup();
315
- reject(new ServiceWorkerTimeoutError(`Service worker message timed out (${request.type})`));
316
- }, timeoutMs);
317
- };
318
319
  const cleanup = () => {
319
- clearTimeout(timeoutId);
320
320
  navigator.serviceWorker.removeEventListener("message", messageHandler);
321
321
  };
322
- let timeoutId;
323
- resetTimeout();
324
322
  const messageHandler = (event) => {
325
323
  const response = event.data;
326
324
  if (request.id !== response.id)
@@ -335,7 +333,6 @@ export class ServiceWorkerReadonlyWallet {
335
333
  resolve(response);
336
334
  }
337
335
  else {
338
- resetTimeout();
339
336
  onEvent(response);
340
337
  }
341
338
  };
@@ -425,11 +422,10 @@ export class ServiceWorkerReadonlyWallet {
425
422
  await this.reinitialize();
426
423
  }
427
424
  }
428
- const timeoutMs = this.getTimeoutForRequest(request);
429
425
  const maxRetries = 2;
430
426
  for (let attempt = 0;; attempt++) {
431
427
  try {
432
- return await this.sendMessageStreaming(request, onEvent, isComplete, timeoutMs);
428
+ return await this.sendMessageStreaming(request, onEvent, isComplete);
433
429
  }
434
430
  catch (error) {
435
431
  if (!isMessageBusNotInitializedError(error) ||
@@ -440,19 +436,68 @@ export class ServiceWorkerReadonlyWallet {
440
436
  }
441
437
  }
442
438
  }
439
+ /**
440
+ * Produce a serialized envelope for the wallet's identity. The base
441
+ * class always emits a readonly envelope; `ServiceWorkerWallet`
442
+ * overrides to emit a signing envelope.
443
+ */
444
+ async serializeIdentity() {
445
+ return serializeReadonlyIdentity(this.identity);
446
+ }
447
+ /**
448
+ * Return the cached init config, or rebuild one from live instance
449
+ * state when the cache was never populated. Recovery path for
450
+ * SDK-factory-created wallets; manual constructor bypasses do not
451
+ * retain enough state here and will hit the "never initialized" throw.
452
+ */
453
+ async buildInitConfig() {
454
+ if (this.initConfig)
455
+ return this.initConfig;
456
+ if (!this.arkServerUrl) {
457
+ throw new Error("Cannot re-initialize: wallet was not initialized via the SDK factory");
458
+ }
459
+ const wallet = await this.serializeIdentity();
460
+ this.initConfig = {
461
+ wallet,
462
+ arkServer: {
463
+ url: this.arkServerUrl,
464
+ publicKey: this.arkServerPublicKey,
465
+ },
466
+ delegatorUrl: this.delegatorUrl,
467
+ indexerUrl: this.indexerUrl,
468
+ esploraUrl: this.esploraUrl,
469
+ watcherConfig: this.watcherConfig,
470
+ settlementConfig: this.settlementConfig,
471
+ };
472
+ return this.initConfig;
473
+ }
474
+ /** Minimal INIT_WALLET payload used on reinitialize when the cache is gone. */
475
+ buildInitWalletPayload() {
476
+ if (this.initWalletPayload)
477
+ return this.initWalletPayload;
478
+ if (!this.arkServerUrl) {
479
+ throw new Error("Cannot re-initialize: wallet was not initialized via the SDK factory");
480
+ }
481
+ this.initWalletPayload = {
482
+ // `key` is deprecated and ignored by the current handler.
483
+ key: {},
484
+ arkServerUrl: this.arkServerUrl,
485
+ arkServerPublicKey: this.arkServerPublicKey,
486
+ };
487
+ return this.initWalletPayload;
488
+ }
443
489
  async reinitialize() {
444
490
  if (this.reinitPromise)
445
491
  return this.reinitPromise;
446
492
  this.reinitPromise = (async () => {
447
- if (!this.initConfig || !this.initWalletPayload) {
448
- throw new Error("Cannot re-initialize: missing configuration");
449
- }
450
- await initializeMessageBus(this.serviceWorker, this.initConfig, this.messageBusTimeoutMs);
493
+ const config = await this.buildInitConfig();
494
+ const payload = this.buildInitWalletPayload();
495
+ await initializeMessageBus(this.serviceWorker, config, this.messageBusTimeoutMs);
451
496
  const initMessage = {
452
497
  tag: this.messageTag,
453
498
  type: "INIT_WALLET",
454
499
  id: getRandomId(),
455
- payload: this.initWalletPayload,
500
+ payload,
456
501
  };
457
502
  await this.sendMessageDirect(initMessage, this.getTimeoutForRequest(initMessage));
458
503
  })().finally(() => {
@@ -814,71 +859,72 @@ export class ServiceWorkerWallet extends ServiceWorkerReadonlyWallet {
814
859
  get assetManager() {
815
860
  return this._assetManager;
816
861
  }
862
+ async serializeIdentity() {
863
+ return serializeSigningIdentity(this.identity);
864
+ }
817
865
  static async create(options) {
818
866
  const walletRepository = options.storage?.walletRepository ??
819
867
  new IndexedDBWalletRepository();
820
868
  const contractRepository = options.storage?.contractRepository ??
821
869
  new IndexedDBContractRepository();
822
- // Extract identity and check if it can expose private key
823
- const identity = isPrivateKeyIdentity(options.identity)
824
- ? options.identity
825
- : null;
826
- if (!identity) {
827
- throw new Error("ServiceWorkerWallet.create() requires a Identity that can expose a single private key");
870
+ if (!isSigningCapable(options.identity)) {
871
+ throw new Error("ServiceWorkerWallet.create() requires a signing Identity; got a ReadonlyIdentity");
828
872
  }
829
- // Extract private key for service worker initialization
830
- const privateKey = identity.toHex();
873
+ const identity = options.identity;
874
+ const serializedWallet = serializeSigningIdentity(identity);
831
875
  const messageTag = options.walletUpdaterTag ?? DEFAULT_MESSAGE_TAG;
832
876
  // Create the wallet instance
833
877
  const wallet = new ServiceWorkerWallet(options.serviceWorker, identity, walletRepository, contractRepository, messageTag, !!options.delegatorUrl);
834
- const initConfig = {
835
- key: { privateKey },
878
+ // INIT_WALLET retains the legacy `key` payload for wire compatibility
879
+ // with older workers; the current handler does not read it, and only
880
+ // SingleKey-style identities can populate it. Kept optional so seed /
881
+ // mnemonic identities simply omit it.
882
+ const legacyPrivateKey = serializedWallet.type === "single-key"
883
+ ? serializedWallet.privateKey
884
+ : null;
885
+ const initWalletPayload = {
886
+ key: legacyPrivateKey ? { privateKey: legacyPrivateKey } : {},
836
887
  arkServerUrl: options.arkServerUrl,
837
888
  arkServerPublicKey: options.arkServerPublicKey,
838
889
  delegatorUrl: options.delegatorUrl,
839
890
  };
840
- await initializeMessageBus(options.serviceWorker, {
841
- wallet: initConfig.key,
891
+ // Precompute the merged timeout map so page-side waiting and
892
+ // worker-side enforcement are derived from the same source.
893
+ const messageTimeouts = options.messageTimeouts
894
+ ? {
895
+ ...DEFAULT_MESSAGE_TIMEOUTS,
896
+ ...options.messageTimeouts,
897
+ }
898
+ : DEFAULT_MESSAGE_TIMEOUTS;
899
+ const busInitConfig = {
900
+ wallet: serializedWallet,
842
901
  arkServer: {
843
- url: initConfig.arkServerUrl,
844
- publicKey: initConfig.arkServerPublicKey,
902
+ url: options.arkServerUrl,
903
+ publicKey: options.arkServerPublicKey,
845
904
  },
846
- delegatorUrl: initConfig.delegatorUrl,
905
+ delegatorUrl: options.delegatorUrl,
847
906
  indexerUrl: options.indexerUrl,
848
907
  esploraUrl: options.esploraUrl,
849
- timeoutMs: options.messageBusTimeoutMs,
850
908
  settlementConfig: options.settlementConfig,
851
909
  watcherConfig: options.watcherConfig,
852
- }, options.messageBusTimeoutMs);
910
+ messageTimeouts,
911
+ };
912
+ await initializeMessageBus(options.serviceWorker, { ...busInitConfig, timeoutMs: options.messageBusTimeoutMs }, options.messageBusTimeoutMs);
853
913
  // Initialize the service worker with the config
854
914
  const initMessage = {
855
915
  tag: messageTag,
856
916
  type: "INIT_WALLET",
857
917
  id: getRandomId(),
858
- payload: initConfig,
918
+ payload: initWalletPayload,
859
919
  };
860
920
  // Initialize the service worker
861
921
  await wallet.sendMessage(initMessage);
862
- wallet.initConfig = {
863
- wallet: initConfig.key,
864
- arkServer: {
865
- url: initConfig.arkServerUrl,
866
- publicKey: initConfig.arkServerPublicKey,
867
- },
868
- delegatorUrl: initConfig.delegatorUrl,
869
- indexerUrl: options.indexerUrl,
870
- esploraUrl: options.esploraUrl,
871
- settlementConfig: options.settlementConfig,
872
- watcherConfig: options.watcherConfig,
873
- };
874
- wallet.initWalletPayload = initConfig;
922
+ // Persist the full init config (including messageTimeouts) so
923
+ // reinitialize() re-sends the same map to a restarted worker.
924
+ wallet.initConfig = busInitConfig;
925
+ wallet.initWalletPayload = initWalletPayload;
875
926
  wallet.messageBusTimeoutMs = options.messageBusTimeoutMs;
876
- if (options.messageTimeouts) {
877
- wallet.messageTimeouts = {
878
- ...DEFAULT_MESSAGE_TIMEOUTS,
879
- ...options.messageTimeouts,
880
- };
881
- }
927
+ wallet.messageTimeouts = messageTimeouts;
882
928
  return wallet;
883
929
  }
884
930
  /**
@@ -5,6 +5,8 @@ import { hex } from "@scure/base";
5
5
  import { getSequence } from '../script/base.js';
6
6
  import { Transaction } from '../utils/transaction.js';
7
7
  import { TxWeightEstimator } from '../utils/txSizeEstimator.js';
8
+ import { Estimator } from '../arkfee/index.js';
9
+ import { ArkAddress } from '../script/address.js';
8
10
  /**
9
11
  * Return whether a wallet exposes the properties required for boarding input sweep operations.
10
12
  *
@@ -14,6 +16,7 @@ import { TxWeightEstimator } from '../utils/txSizeEstimator.js';
14
16
  function isSweepCapable(wallet) {
15
17
  return ("boardingTapscript" in wallet &&
16
18
  "onchainProvider" in wallet &&
19
+ "arkProvider" in wallet &&
17
20
  "network" in wallet);
18
21
  }
19
22
  /**
@@ -24,7 +27,7 @@ function isSweepCapable(wallet) {
24
27
  */
25
28
  function assertSweepCapable(wallet) {
26
29
  if (!isSweepCapable(wallet)) {
27
- throw new Error("Boarding UTXO sweep requires a Wallet instance with boardingTapscript, onchainProvider, and network");
30
+ throw new Error("Boarding UTXO sweep requires a Wallet instance with boardingTapscript, onchainProvider, arkProvider, and network");
28
31
  }
29
32
  }
30
33
  /**
@@ -227,6 +230,11 @@ export class VtxoManager {
227
230
  // because they now ride on the same settle intent.
228
231
  this.lastPeriodicSettleTimestamp = 0;
229
232
  this.consecutivePeriodicSettleFailures = 0;
233
+ // Throttle for the VTXO_ALREADY_SPENT -> refreshVtxos() reconciliation.
234
+ // The server's authoritative view says our local cache is stale, so we
235
+ // trigger a full refresh to advance the global sync cursor. Rate-limit
236
+ // to guard against a buggy indexer cycling us into a refresh storm.
237
+ this.lastVtxoSpentRefreshTimestamp = 0;
230
238
  // Normalize: prefer settlementConfig, fall back to renewalConfig, default to enabled
231
239
  if (settlementConfig !== undefined) {
232
240
  this.settlementConfig = settlementConfig;
@@ -617,6 +625,10 @@ export class VtxoManager {
617
625
  getOnchainProvider() {
618
626
  return this.getSweepWallet().onchainProvider;
619
627
  }
628
+ /** Returns the Ark provider for intent fee and server info lookups. */
629
+ getArkProvider() {
630
+ return this.getSweepWallet().arkProvider;
631
+ }
620
632
  /** Returns the Bitcoin network configuration from the wallet. */
621
633
  getNetwork() {
622
634
  return this.getSweepWallet().network;
@@ -662,7 +674,6 @@ export class VtxoManager {
662
674
  return;
663
675
  }
664
676
  if (e.message.includes("VTXO_ALREADY_REGISTERED") ||
665
- e.message.includes("VTXO_ALREADY_SPENT") ||
666
677
  e.message.includes("duplicated input")) {
667
678
  // Virtual output is already being used in a concurrent
668
679
  // user-initiated operation. Skip silently — the
@@ -670,6 +681,14 @@ export class VtxoManager {
670
681
  // renewal will retry on the next cycle.
671
682
  return;
672
683
  }
684
+ if (e.message.includes("VTXO_ALREADY_SPENT")) {
685
+ // Our local VTXO cache is stale vs. the
686
+ // server's authoritative view. Trigger a
687
+ // throttled refresh to reconcile, then skip
688
+ // — the next cycle will see fresh data.
689
+ void this.maybeRefreshAfterVtxoSpent();
690
+ return;
691
+ }
673
692
  }
674
693
  console.error("Error renewing VTXOs:", e);
675
694
  });
@@ -687,6 +706,39 @@ export class VtxoManager {
687
706
  return undefined;
688
707
  }
689
708
  }
709
+ /**
710
+ * VTXO_ALREADY_SPENT means the server's authoritative view of VTXO state
711
+ * is ahead of ours — cross-instance race, pre-lock snapshot drift, or an
712
+ * SSE gap left stale data in the local cache. Silent-swallowing guarantees
713
+ * the same error on the next cycle because nothing reconciles the cache,
714
+ * so instead we trigger a full refreshVtxos() to advance the global sync
715
+ * cursor. Throttled to prevent a buggy indexer from causing a refresh
716
+ * storm.
717
+ */
718
+ maybeRefreshAfterVtxoSpent() {
719
+ if (this.vtxoSpentRefreshPromise) {
720
+ return this.vtxoSpentRefreshPromise;
721
+ }
722
+ const now = Date.now();
723
+ if (now - this.lastVtxoSpentRefreshTimestamp <
724
+ VtxoManager.VTXO_SPENT_REFRESH_COOLDOWN_MS) {
725
+ return Promise.resolve();
726
+ }
727
+ this.lastVtxoSpentRefreshTimestamp = now;
728
+ this.vtxoSpentRefreshPromise = (async () => {
729
+ try {
730
+ const contractManager = await this.wallet.getContractManager();
731
+ await contractManager.refreshVtxos();
732
+ }
733
+ catch (e) {
734
+ console.error("Error refreshing VTXOs after VTXO_ALREADY_SPENT:", e);
735
+ }
736
+ finally {
737
+ this.vtxoSpentRefreshPromise = undefined;
738
+ }
739
+ })();
740
+ return this.vtxoSpentRefreshPromise;
741
+ }
690
742
  /** Computes the next poll delay, applying exponential backoff on failures. */
691
743
  getNextPollDelay() {
692
744
  if (this.settlementConfig === false)
@@ -819,7 +871,8 @@ export class VtxoManager {
819
871
  catch (e) {
820
872
  throw e instanceof Error ? e : new Error(String(e));
821
873
  }
822
- const unsettledBoarding = boardingUtxos.filter((u) => !this.knownBoardingUtxos.has(`${u.txid}:${u.vout}`) &&
874
+ const unsettledBoarding = boardingUtxos.filter((u) => u.status.confirmed &&
875
+ !this.knownBoardingUtxos.has(`${u.txid}:${u.vout}`) &&
823
876
  !expiredSet.has(`${u.txid}:${u.vout}`));
824
877
  // Collect near-expiry VTXOs unless the event-driven path is mid-renewal.
825
878
  // Skipping when renewalInProgress avoids double-submitting the same VTXOs.
@@ -845,13 +898,54 @@ export class VtxoManager {
845
898
  return;
846
899
  }
847
900
  const dustAmount = getDustAmount(this.wallet);
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;
851
- if (totalAmount < dustAmount)
901
+ // Fetch server intent-fee config so each input/output can be priced.
902
+ // Without this, settle sends `outputAmount = sum(inputs)` and the
903
+ // server rejects with INTENT_INSUFFICIENT_FEE whenever the operator
904
+ // charges non-zero intent fees.
905
+ const { fees } = await this.getArkProvider().getInfo();
906
+ const estimator = new Estimator(fees.intentFee);
907
+ let totalAmount = 0n;
908
+ const filteredBoarding = [];
909
+ for (const u of unsettledBoarding) {
910
+ const inputFee = estimator.evalOnchainInput({
911
+ amount: BigInt(u.value),
912
+ });
913
+ if (inputFee.value >= BigInt(u.value)) {
914
+ // Fee exceeds input value — including it would drain the output.
915
+ continue;
916
+ }
917
+ filteredBoarding.push(u);
918
+ totalAmount += BigInt(u.value) - BigInt(inputFee.satoshis);
919
+ }
920
+ const filteredVtxos = [];
921
+ for (const v of expiringVtxos) {
922
+ const inputFee = estimator.evalOffchainInput({
923
+ amount: BigInt(v.value),
924
+ type: v.virtualStatus.state === "swept" ? "recoverable" : "vtxo",
925
+ weight: 0,
926
+ birth: v.createdAt,
927
+ expiry: v.virtualStatus.batchExpiry
928
+ ? new Date(v.virtualStatus.batchExpiry)
929
+ : undefined,
930
+ });
931
+ if (inputFee.satoshis >= v.value) {
932
+ continue;
933
+ }
934
+ filteredVtxos.push(v);
935
+ totalAmount += BigInt(v.value) - BigInt(inputFee.satoshis);
936
+ }
937
+ if (filteredBoarding.length === 0 && filteredVtxos.length === 0) {
852
938
  return;
939
+ }
853
940
  const arkAddress = await this.wallet.getAddress();
854
- const includesVtxos = expiringVtxos.length > 0;
941
+ const outputFee = estimator.evalOffchainOutput({
942
+ amount: totalAmount,
943
+ script: hex.encode(ArkAddress.decode(arkAddress).pkScript),
944
+ });
945
+ totalAmount -= BigInt(outputFee.satoshis);
946
+ if (totalAmount < dustAmount)
947
+ return;
948
+ const includesVtxos = filteredVtxos.length > 0;
855
949
  // Block the event-driven renewal path while this settle is in flight
856
950
  // when VTXOs are part of the intent. Mirrors renewVtxos()'s guard so
857
951
  // the two paths can't race on the same VTXO inputs.
@@ -859,16 +953,34 @@ export class VtxoManager {
859
953
  this.renewalInProgress = true;
860
954
  }
861
955
  let success = false;
956
+ let staleCacheSkip = false;
862
957
  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}`);
958
+ try {
959
+ await this.wallet.settle({
960
+ inputs: [...filteredBoarding, ...filteredVtxos],
961
+ outputs: [{ address: arkAddress, amount: totalAmount }],
962
+ });
963
+ // Mark boarding inputs as known only after successful settle.
964
+ for (const u of filteredBoarding) {
965
+ this.knownBoardingUtxos.add(`${u.txid}:${u.vout}`);
966
+ }
967
+ success = true;
968
+ }
969
+ catch (e) {
970
+ if (e instanceof Error &&
971
+ e.message.includes("VTXO_ALREADY_SPENT")) {
972
+ // Local VTXO cache is stale vs. the server's
973
+ // authoritative view — not a transient failure.
974
+ // Trigger a throttled refresh and skip this cycle
975
+ // without bumping the failure counter, so the next
976
+ // poll can retry once the cache reconciles.
977
+ staleCacheSkip = true;
978
+ void this.maybeRefreshAfterVtxoSpent();
979
+ }
980
+ else {
981
+ throw e;
982
+ }
870
983
  }
871
- success = true;
872
984
  }
873
985
  finally {
874
986
  this.lastPeriodicSettleTimestamp = Date.now();
@@ -883,7 +995,10 @@ export class VtxoManager {
883
995
  if (success) {
884
996
  this.consecutivePeriodicSettleFailures = 0;
885
997
  }
886
- else {
998
+ else if (!staleCacheSkip) {
999
+ // Don't bump on stale-cache skip: it's not a transient
1000
+ // failure, and the next cycle should try immediately
1001
+ // after the refresh lands.
887
1002
  this.consecutivePeriodicSettleFailures++;
888
1003
  }
889
1004
  }
@@ -920,3 +1035,4 @@ VtxoManager.MAX_BACKOFF_MS = 5 * 60 * 1000; // 5 minutes
920
1035
  VtxoManager.RENEWAL_COOLDOWN_MS = 30000; // 30 seconds
921
1036
  VtxoManager.PERIODIC_SETTLE_COOLDOWN_MS = 30000;
922
1037
  VtxoManager.PERIODIC_SETTLE_MAX_BACKOFF_MS = 5 * 60 * 1000;
1038
+ VtxoManager.VTXO_SPENT_REFRESH_COOLDOWN_MS = 30000;