@arkade-os/sdk 0.4.18 → 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.
@@ -231,28 +231,21 @@ class RestArkProvider {
231
231
  const queryParams = topics.length > 0
232
232
  ? `?${topics.map((topic) => `topics=${encodeURIComponent(topic)}`).join("&")}`
233
233
  : "";
234
- // Create first EventSource eagerly so events are buffered
235
- // before the caller starts iterating, preventing race conditions
236
- // where the server emits events before iteration begins.
237
- const eagerEventSource = new EventSource(url + queryParams);
238
- const eagerIterator = (0, utils_1.eventSourceIterator)(eagerEventSource);
234
+ // The EventSource is allocated inside the generator body so that
235
+ // abandoning the returned iterator before iteration starts does not
236
+ // leak the underlying SSE connection. `return()` is overridden below
237
+ // so that closing the generator also closes the connection even when
238
+ // the body is currently suspended at an await point.
239
+ let eventSource = null;
239
240
  // eslint-disable-next-line @typescript-eslint/no-this-alias
240
241
  const self = this;
241
- return (async function* () {
242
- let firstIteration = true;
243
- while (!signal?.aborted) {
244
- const eventSource = firstIteration
245
- ? eagerEventSource
246
- : new EventSource(url + queryParams);
247
- const iterator = firstIteration
248
- ? eagerIterator
249
- : (0, utils_1.eventSourceIterator)(eventSource);
250
- firstIteration = false;
251
- try {
252
- const abortHandler = () => {
253
- eventSource.close();
254
- };
255
- signal?.addEventListener("abort", abortHandler);
242
+ const gen = (async function* () {
243
+ const abortHandler = () => eventSource?.close();
244
+ signal?.addEventListener("abort", abortHandler);
245
+ try {
246
+ while (!signal?.aborted) {
247
+ eventSource = new EventSource(url + queryParams);
248
+ const iterator = (0, utils_1.eventSourceIterator)(eventSource);
256
249
  try {
257
250
  for await (const event of iterator) {
258
251
  if (signal?.aborted)
@@ -270,25 +263,35 @@ class RestArkProvider {
270
263
  }
271
264
  }
272
265
  }
266
+ catch (error) {
267
+ if (error instanceof Error &&
268
+ error.name === "AbortError") {
269
+ break;
270
+ }
271
+ // ignore timeout errors, they're expected when the server is not sending anything for 5 min
272
+ if (isFetchTimeoutError(error)) {
273
+ console.debug("Timeout error ignored");
274
+ continue;
275
+ }
276
+ console.error("Event stream error:", error);
277
+ throw error;
278
+ }
273
279
  finally {
274
- signal?.removeEventListener("abort", abortHandler);
275
280
  eventSource.close();
276
281
  }
277
282
  }
278
- catch (error) {
279
- if (error instanceof Error && error.name === "AbortError") {
280
- break;
281
- }
282
- // ignore timeout errors, they're expected when the server is not sending anything for 5 min
283
- if (isFetchTimeoutError(error)) {
284
- console.debug("Timeout error ignored");
285
- continue;
286
- }
287
- console.error("Event stream error:", error);
288
- throw error;
289
- }
283
+ }
284
+ finally {
285
+ signal?.removeEventListener("abort", abortHandler);
286
+ eventSource?.close();
290
287
  }
291
288
  })();
289
+ const origReturn = gen.return.bind(gen);
290
+ gen.return = (value) => {
291
+ eventSource?.close();
292
+ return origReturn(value);
293
+ };
294
+ return gen;
292
295
  }
293
296
  async *getTransactionsStream(signal) {
294
297
  const url = `${this.serverUrl}/v1/txs`;
@@ -232,6 +232,11 @@ class VtxoManager {
232
232
  // because they now ride on the same settle intent.
233
233
  this.lastPeriodicSettleTimestamp = 0;
234
234
  this.consecutivePeriodicSettleFailures = 0;
235
+ // Throttle for the VTXO_ALREADY_SPENT -> refreshVtxos() reconciliation.
236
+ // The server's authoritative view says our local cache is stale, so we
237
+ // trigger a full refresh to advance the global sync cursor. Rate-limit
238
+ // to guard against a buggy indexer cycling us into a refresh storm.
239
+ this.lastVtxoSpentRefreshTimestamp = 0;
235
240
  // Normalize: prefer settlementConfig, fall back to renewalConfig, default to enabled
236
241
  if (settlementConfig !== undefined) {
237
242
  this.settlementConfig = settlementConfig;
@@ -667,7 +672,6 @@ class VtxoManager {
667
672
  return;
668
673
  }
669
674
  if (e.message.includes("VTXO_ALREADY_REGISTERED") ||
670
- e.message.includes("VTXO_ALREADY_SPENT") ||
671
675
  e.message.includes("duplicated input")) {
672
676
  // Virtual output is already being used in a concurrent
673
677
  // user-initiated operation. Skip silently — the
@@ -675,6 +679,14 @@ class VtxoManager {
675
679
  // renewal will retry on the next cycle.
676
680
  return;
677
681
  }
682
+ if (e.message.includes("VTXO_ALREADY_SPENT")) {
683
+ // Our local VTXO cache is stale vs. the
684
+ // server's authoritative view. Trigger a
685
+ // throttled refresh to reconcile, then skip
686
+ // — the next cycle will see fresh data.
687
+ void this.maybeRefreshAfterVtxoSpent();
688
+ return;
689
+ }
678
690
  }
679
691
  console.error("Error renewing VTXOs:", e);
680
692
  });
@@ -692,6 +704,39 @@ class VtxoManager {
692
704
  return undefined;
693
705
  }
694
706
  }
707
+ /**
708
+ * VTXO_ALREADY_SPENT means the server's authoritative view of VTXO state
709
+ * is ahead of ours — cross-instance race, pre-lock snapshot drift, or an
710
+ * SSE gap left stale data in the local cache. Silent-swallowing guarantees
711
+ * the same error on the next cycle because nothing reconciles the cache,
712
+ * so instead we trigger a full refreshVtxos() to advance the global sync
713
+ * cursor. Throttled to prevent a buggy indexer from causing a refresh
714
+ * storm.
715
+ */
716
+ maybeRefreshAfterVtxoSpent() {
717
+ if (this.vtxoSpentRefreshPromise) {
718
+ return this.vtxoSpentRefreshPromise;
719
+ }
720
+ const now = Date.now();
721
+ if (now - this.lastVtxoSpentRefreshTimestamp <
722
+ VtxoManager.VTXO_SPENT_REFRESH_COOLDOWN_MS) {
723
+ return Promise.resolve();
724
+ }
725
+ this.lastVtxoSpentRefreshTimestamp = now;
726
+ this.vtxoSpentRefreshPromise = (async () => {
727
+ try {
728
+ const contractManager = await this.wallet.getContractManager();
729
+ await contractManager.refreshVtxos();
730
+ }
731
+ catch (e) {
732
+ console.error("Error refreshing VTXOs after VTXO_ALREADY_SPENT:", e);
733
+ }
734
+ finally {
735
+ this.vtxoSpentRefreshPromise = undefined;
736
+ }
737
+ })();
738
+ return this.vtxoSpentRefreshPromise;
739
+ }
695
740
  /** Computes the next poll delay, applying exponential backoff on failures. */
696
741
  getNextPollDelay() {
697
742
  if (this.settlementConfig === false)
@@ -824,7 +869,8 @@ class VtxoManager {
824
869
  catch (e) {
825
870
  throw e instanceof Error ? e : new Error(String(e));
826
871
  }
827
- const unsettledBoarding = boardingUtxos.filter((u) => !this.knownBoardingUtxos.has(`${u.txid}:${u.vout}`) &&
872
+ const unsettledBoarding = boardingUtxos.filter((u) => u.status.confirmed &&
873
+ !this.knownBoardingUtxos.has(`${u.txid}:${u.vout}`) &&
828
874
  !expiredSet.has(`${u.txid}:${u.vout}`));
829
875
  // Collect near-expiry VTXOs unless the event-driven path is mid-renewal.
830
876
  // Skipping when renewalInProgress avoids double-submitting the same VTXOs.
@@ -864,16 +910,34 @@ class VtxoManager {
864
910
  this.renewalInProgress = true;
865
911
  }
866
912
  let success = false;
913
+ let staleCacheSkip = false;
867
914
  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}`);
915
+ try {
916
+ await this.wallet.settle({
917
+ inputs: [...unsettledBoarding, ...expiringVtxos],
918
+ outputs: [{ address: arkAddress, amount: totalAmount }],
919
+ });
920
+ // Mark boarding inputs as known only after successful settle.
921
+ for (const u of unsettledBoarding) {
922
+ this.knownBoardingUtxos.add(`${u.txid}:${u.vout}`);
923
+ }
924
+ success = true;
925
+ }
926
+ catch (e) {
927
+ if (e instanceof Error &&
928
+ e.message.includes("VTXO_ALREADY_SPENT")) {
929
+ // Local VTXO cache is stale vs. the server's
930
+ // authoritative view — not a transient failure.
931
+ // Trigger a throttled refresh and skip this cycle
932
+ // without bumping the failure counter, so the next
933
+ // poll can retry once the cache reconciles.
934
+ staleCacheSkip = true;
935
+ void this.maybeRefreshAfterVtxoSpent();
936
+ }
937
+ else {
938
+ throw e;
939
+ }
875
940
  }
876
- success = true;
877
941
  }
878
942
  finally {
879
943
  this.lastPeriodicSettleTimestamp = Date.now();
@@ -888,7 +952,10 @@ class VtxoManager {
888
952
  if (success) {
889
953
  this.consecutivePeriodicSettleFailures = 0;
890
954
  }
891
- else {
955
+ else if (!staleCacheSkip) {
956
+ // Don't bump on stale-cache skip: it's not a transient
957
+ // failure, and the next cycle should try immediately
958
+ // after the refresh lands.
892
959
  this.consecutivePeriodicSettleFailures++;
893
960
  }
894
961
  }
@@ -926,3 +993,4 @@ VtxoManager.MAX_BACKOFF_MS = 5 * 60 * 1000; // 5 minutes
926
993
  VtxoManager.RENEWAL_COOLDOWN_MS = 30000; // 30 seconds
927
994
  VtxoManager.PERIODIC_SETTLE_COOLDOWN_MS = 30000;
928
995
  VtxoManager.PERIODIC_SETTLE_MAX_BACKOFF_MS = 5 * 60 * 1000;
996
+ VtxoManager.VTXO_SPENT_REFRESH_COOLDOWN_MS = 30000;
@@ -68,6 +68,12 @@ class ReadonlyWallet {
68
68
  this.walletRepository = walletRepository;
69
69
  this.contractRepository = contractRepository;
70
70
  this.delegatorProvider = delegatorProvider;
71
+ // Outpoints ("txid:vout") committed to an in-flight settle/send. Filtered
72
+ // from getVtxos() so concurrent callers (UI, VtxoManager auto-renewal,
73
+ // another send/settle racing the _txLock) can't reselect coins that are
74
+ // already on their way out. The set is in-memory only: a process crash
75
+ // clears it, and a stale entry only hides a VTXO (never spends one).
76
+ this._pendingSpendOutpoints = new Set();
71
77
  // Guard: detect identity/server network mismatch for descriptor-based identities.
72
78
  // This duplicates the check in setupWalletConfig() so that subclasses
73
79
  // bypassing the factory still get the safety net.
@@ -308,6 +314,9 @@ class ReadonlyWallet {
308
314
  return vtxos
309
315
  .flatMap((_) => _.vtxos)
310
316
  .filter((vtxo) => {
317
+ if (this._pendingSpendOutpoints.has(`${vtxo.txid}:${vtxo.vout}`)) {
318
+ return false;
319
+ }
311
320
  if ((0, _1.isSpendable)(vtxo)) {
312
321
  if (!f.withRecoverable &&
313
322
  ((0, _1.isRecoverable)(vtxo) || (0, _1.isExpired)(vtxo))) {
@@ -760,6 +769,20 @@ exports.ReadonlyWallet = ReadonlyWallet;
760
769
  * ```
761
770
  */
762
771
  class Wallet extends ReadonlyWallet {
772
+ _addPendingSpends(inputs) {
773
+ for (const input of inputs) {
774
+ if ("virtualStatus" in input) {
775
+ this._pendingSpendOutpoints.add(`${input.txid}:${input.vout}`);
776
+ }
777
+ }
778
+ }
779
+ _removePendingSpends(inputs) {
780
+ for (const input of inputs) {
781
+ if ("virtualStatus" in input) {
782
+ this._pendingSpendOutpoints.delete(`${input.txid}:${input.vout}`);
783
+ }
784
+ }
785
+ }
763
786
  _withTxLock(fn) {
764
787
  let release;
765
788
  const lock = new Promise((r) => (release = r));
@@ -975,9 +998,15 @@ class Wallet extends ReadonlyWallet {
975
998
  amount: BigInt(selected.changeAmount),
976
999
  });
977
1000
  }
978
- const { arkTxid, signedCheckpointTxs } = await this.buildAndSubmitOffchainTx(selected.inputs, outputs);
979
- await this.updateDbAfterOffchainTx(selected.inputs, arkTxid, signedCheckpointTxs, params.amount, selected.changeAmount, selected.changeAmount > 0n ? outputs.length - 1 : 0);
980
- return arkTxid;
1001
+ this._addPendingSpends(selected.inputs);
1002
+ try {
1003
+ const { arkTxid, signedCheckpointTxs } = await this.buildAndSubmitOffchainTx(selected.inputs, outputs);
1004
+ await this.updateDbAfterOffchainTx(selected.inputs, arkTxid, signedCheckpointTxs, params.amount, selected.changeAmount, selected.changeAmount > 0n ? outputs.length - 1 : 0);
1005
+ return arkTxid;
1006
+ }
1007
+ finally {
1008
+ this._removePendingSpends(selected.inputs);
1009
+ }
981
1010
  });
982
1011
  }
983
1012
  return this.send({
@@ -1023,7 +1052,8 @@ class Wallet extends ReadonlyWallet {
1023
1052
  const tip = await this.onchainProvider.getChainTip();
1024
1053
  chainTipHeight = tip.height;
1025
1054
  }
1026
- const boardingUtxos = (await this.getBoardingUtxos()).filter((utxo) => !(0, arkTransaction_1.hasBoardingTxExpired)(utxo, boardingTimelock, chainTipHeight));
1055
+ const boardingUtxos = (await this.getBoardingUtxos()).filter((utxo) => utxo.status.confirmed &&
1056
+ !(0, arkTransaction_1.hasBoardingTxExpired)(utxo, boardingTimelock, chainTipHeight));
1027
1057
  const filteredBoardingUtxos = [];
1028
1058
  for (const utxo of boardingUtxos) {
1029
1059
  const inputFee = estimator.evalOnchainInput({
@@ -1158,11 +1188,29 @@ class Wallet extends ReadonlyWallet {
1158
1188
  ...params.inputs.map((input) => `${input.txid}:${input.vout}`),
1159
1189
  ];
1160
1190
  const abortController = new AbortController();
1191
+ let stream;
1192
+ // Optimistically hide these inputs from concurrent getVtxos() callers
1193
+ // while the settlement is in flight. Set before safeRegisterIntent so
1194
+ // there's no window between intent registration and coin-visibility.
1195
+ this._addPendingSpends(params.inputs);
1161
1196
  try {
1162
- const stream = this.arkProvider.getEventStream(abortController.signal, topics);
1197
+ stream = this.arkProvider.getEventStream(abortController.signal, topics);
1198
+ // Prime the iterator so the provider opens the SSE subscription
1199
+ // before safeRegisterIntent can trigger server-side batch events.
1200
+ const firstNext = stream.next();
1201
+ // If settle exits before Batch.join consumes the primed result,
1202
+ // keep the orphaned promise from surfacing as an unhandled rejection.
1203
+ void firstNext.catch(() => { });
1204
+ const primedStream = (async function* () {
1205
+ const first = await firstNext;
1206
+ if (!first.done) {
1207
+ yield first.value;
1208
+ }
1209
+ yield* stream;
1210
+ })();
1163
1211
  const intentId = await this.safeRegisterIntent(intent, params.inputs);
1164
1212
  const handler = this.createBatchHandler(intentId, params.inputs, recipients, session);
1165
- const commitmentTxid = await batch_1.Batch.join(stream, handler, {
1213
+ const commitmentTxid = await batch_1.Batch.join(primedStream, handler, {
1166
1214
  abortController,
1167
1215
  skipVtxoTreeSigning: !hasOffchainOutputs,
1168
1216
  eventCallback: eventCallback
@@ -1186,23 +1234,28 @@ class Wallet extends ReadonlyWallet {
1186
1234
  throw error;
1187
1235
  }
1188
1236
  finally {
1189
- // close the stream
1237
+ // Clear state first so a synchronous handler firing from abort()
1238
+ // never observes a stale pending-spend set.
1239
+ this._removePendingSpends(params.inputs);
1240
+ // close the stream — abort() fires the in-body handler if the
1241
+ // generator has started iterating; return() also releases the
1242
+ // eager resource if the body is still suspended or never ran
1243
+ // (e.g. safeRegisterIntent threw before Batch.join was called).
1190
1244
  abortController.abort();
1245
+ await stream?.return?.().catch(() => { });
1191
1246
  }
1192
1247
  }
1193
1248
  async handleSettlementFinalizationEvent(event, inputs, forfeitOutputScript, connectorsGraph) {
1194
1249
  // the signed forfeits transactions to submit
1195
1250
  const signedForfeits = [];
1196
- const vtxos = await this.getVtxos();
1251
+ const isVtxo = (input) => "virtualStatus" in input;
1197
1252
  let settlementPsbt = btc_signer_1.Transaction.fromPSBT(base_1.base64.decode(event.commitmentTx));
1198
1253
  let hasBoardingUtxos = false;
1199
1254
  let connectorIndex = 0;
1200
1255
  const connectorsLeaves = connectorsGraph?.leaves() || [];
1201
1256
  for (const input of inputs) {
1202
- // check if the input is an offchain "virtual" coin
1203
- const vtxo = vtxos.find((vtxo) => vtxo.txid === input.txid && vtxo.vout === input.vout);
1204
1257
  // boarding input, we need to sign the settlement tx
1205
- if (!vtxo) {
1258
+ if (!isVtxo(input)) {
1206
1259
  for (let i = 0; i < settlementPsbt.inputsLength; i++) {
1207
1260
  const settlementInput = settlementPsbt.getInput(i);
1208
1261
  if (!settlementInput.txid ||
@@ -1226,7 +1279,7 @@ class Wallet extends ReadonlyWallet {
1226
1279
  }
1227
1280
  continue;
1228
1281
  }
1229
- if ((0, _1.isRecoverable)(vtxo) || (0, _1.isSubdust)(vtxo, this.dustAmount)) {
1282
+ if ((0, _1.isRecoverable)(input) || (0, _1.isSubdust)(input, this.dustAmount)) {
1230
1283
  // recoverable or subdust coin, we don't need to create a forfeit tx
1231
1284
  continue;
1232
1285
  }
@@ -1253,7 +1306,7 @@ class Wallet extends ReadonlyWallet {
1253
1306
  txid: input.txid,
1254
1307
  index: input.vout,
1255
1308
  witnessUtxo: {
1256
- amount: BigInt(vtxo.value),
1309
+ amount: BigInt(input.value),
1257
1310
  script: base_2.VtxoScript.decode(input.tapTree).pkScript,
1258
1311
  },
1259
1312
  sighashType: btc_signer_1.SigHash.DEFAULT,
@@ -1682,9 +1735,17 @@ class Wallet extends ReadonlyWallet {
1682
1735
  outputs.push(extension_1.Extension.create([assetPacket]).txOut());
1683
1736
  }
1684
1737
  const sentAmount = recipients.reduce((sum, r) => sum + r.amount, 0);
1685
- const { arkTxid, signedCheckpointTxs } = await this.buildAndSubmitOffchainTx(selectedCoins, outputs);
1686
- await this.updateDbAfterOffchainTx(selectedCoins, arkTxid, signedCheckpointTxs, sentAmount, BigInt(changeAmount), changeReceiver ? changeIndex : 0, changeReceiver?.assets);
1687
- return arkTxid;
1738
+ // Optimistically hide selected coins from concurrent getVtxos() while
1739
+ // the offchain tx is in flight.
1740
+ this._addPendingSpends(selectedCoins);
1741
+ try {
1742
+ const { arkTxid, signedCheckpointTxs } = await this.buildAndSubmitOffchainTx(selectedCoins, outputs);
1743
+ await this.updateDbAfterOffchainTx(selectedCoins, arkTxid, signedCheckpointTxs, sentAmount, BigInt(changeAmount), changeReceiver ? changeIndex : 0, changeReceiver?.assets);
1744
+ return arkTxid;
1745
+ }
1746
+ finally {
1747
+ this._removePendingSpends(selectedCoins);
1748
+ }
1688
1749
  }
1689
1750
  /**
1690
1751
  * Build an offchain transaction from the given inputs and outputs,
@@ -227,28 +227,21 @@ export class RestArkProvider {
227
227
  const queryParams = topics.length > 0
228
228
  ? `?${topics.map((topic) => `topics=${encodeURIComponent(topic)}`).join("&")}`
229
229
  : "";
230
- // Create first EventSource eagerly so events are buffered
231
- // before the caller starts iterating, preventing race conditions
232
- // where the server emits events before iteration begins.
233
- const eagerEventSource = new EventSource(url + queryParams);
234
- const eagerIterator = eventSourceIterator(eagerEventSource);
230
+ // The EventSource is allocated inside the generator body so that
231
+ // abandoning the returned iterator before iteration starts does not
232
+ // leak the underlying SSE connection. `return()` is overridden below
233
+ // so that closing the generator also closes the connection even when
234
+ // the body is currently suspended at an await point.
235
+ let eventSource = null;
235
236
  // eslint-disable-next-line @typescript-eslint/no-this-alias
236
237
  const self = this;
237
- return (async function* () {
238
- let firstIteration = true;
239
- while (!signal?.aborted) {
240
- const eventSource = firstIteration
241
- ? eagerEventSource
242
- : new EventSource(url + queryParams);
243
- const iterator = firstIteration
244
- ? eagerIterator
245
- : eventSourceIterator(eventSource);
246
- firstIteration = false;
247
- try {
248
- const abortHandler = () => {
249
- eventSource.close();
250
- };
251
- signal?.addEventListener("abort", abortHandler);
238
+ const gen = (async function* () {
239
+ const abortHandler = () => eventSource?.close();
240
+ signal?.addEventListener("abort", abortHandler);
241
+ try {
242
+ while (!signal?.aborted) {
243
+ eventSource = new EventSource(url + queryParams);
244
+ const iterator = eventSourceIterator(eventSource);
252
245
  try {
253
246
  for await (const event of iterator) {
254
247
  if (signal?.aborted)
@@ -266,25 +259,35 @@ export class RestArkProvider {
266
259
  }
267
260
  }
268
261
  }
262
+ catch (error) {
263
+ if (error instanceof Error &&
264
+ error.name === "AbortError") {
265
+ break;
266
+ }
267
+ // ignore timeout errors, they're expected when the server is not sending anything for 5 min
268
+ if (isFetchTimeoutError(error)) {
269
+ console.debug("Timeout error ignored");
270
+ continue;
271
+ }
272
+ console.error("Event stream error:", error);
273
+ throw error;
274
+ }
269
275
  finally {
270
- signal?.removeEventListener("abort", abortHandler);
271
276
  eventSource.close();
272
277
  }
273
278
  }
274
- catch (error) {
275
- if (error instanceof Error && error.name === "AbortError") {
276
- break;
277
- }
278
- // ignore timeout errors, they're expected when the server is not sending anything for 5 min
279
- if (isFetchTimeoutError(error)) {
280
- console.debug("Timeout error ignored");
281
- continue;
282
- }
283
- console.error("Event stream error:", error);
284
- throw error;
285
- }
279
+ }
280
+ finally {
281
+ signal?.removeEventListener("abort", abortHandler);
282
+ eventSource?.close();
286
283
  }
287
284
  })();
285
+ const origReturn = gen.return.bind(gen);
286
+ gen.return = (value) => {
287
+ eventSource?.close();
288
+ return origReturn(value);
289
+ };
290
+ return gen;
288
291
  }
289
292
  async *getTransactionsStream(signal) {
290
293
  const url = `${this.serverUrl}/v1/txs`;
@@ -227,6 +227,11 @@ export class VtxoManager {
227
227
  // because they now ride on the same settle intent.
228
228
  this.lastPeriodicSettleTimestamp = 0;
229
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;
230
235
  // Normalize: prefer settlementConfig, fall back to renewalConfig, default to enabled
231
236
  if (settlementConfig !== undefined) {
232
237
  this.settlementConfig = settlementConfig;
@@ -662,7 +667,6 @@ export class VtxoManager {
662
667
  return;
663
668
  }
664
669
  if (e.message.includes("VTXO_ALREADY_REGISTERED") ||
665
- e.message.includes("VTXO_ALREADY_SPENT") ||
666
670
  e.message.includes("duplicated input")) {
667
671
  // Virtual output is already being used in a concurrent
668
672
  // user-initiated operation. Skip silently — the
@@ -670,6 +674,14 @@ export class VtxoManager {
670
674
  // renewal will retry on the next cycle.
671
675
  return;
672
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
+ }
673
685
  }
674
686
  console.error("Error renewing VTXOs:", e);
675
687
  });
@@ -687,6 +699,39 @@ export class VtxoManager {
687
699
  return undefined;
688
700
  }
689
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
+ }
690
735
  /** Computes the next poll delay, applying exponential backoff on failures. */
691
736
  getNextPollDelay() {
692
737
  if (this.settlementConfig === false)
@@ -819,7 +864,8 @@ export class VtxoManager {
819
864
  catch (e) {
820
865
  throw e instanceof Error ? e : new Error(String(e));
821
866
  }
822
- const unsettledBoarding = 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}`) &&
823
869
  !expiredSet.has(`${u.txid}:${u.vout}`));
824
870
  // Collect near-expiry VTXOs unless the event-driven path is mid-renewal.
825
871
  // Skipping when renewalInProgress avoids double-submitting the same VTXOs.
@@ -859,16 +905,34 @@ export class VtxoManager {
859
905
  this.renewalInProgress = true;
860
906
  }
861
907
  let success = false;
908
+ let staleCacheSkip = false;
862
909
  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}`);
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
+ }
870
935
  }
871
- success = true;
872
936
  }
873
937
  finally {
874
938
  this.lastPeriodicSettleTimestamp = Date.now();
@@ -883,7 +947,10 @@ export class VtxoManager {
883
947
  if (success) {
884
948
  this.consecutivePeriodicSettleFailures = 0;
885
949
  }
886
- else {
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.
887
954
  this.consecutivePeriodicSettleFailures++;
888
955
  }
889
956
  }
@@ -920,3 +987,4 @@ VtxoManager.MAX_BACKOFF_MS = 5 * 60 * 1000; // 5 minutes
920
987
  VtxoManager.RENEWAL_COOLDOWN_MS = 30000; // 30 seconds
921
988
  VtxoManager.PERIODIC_SETTLE_COOLDOWN_MS = 30000;
922
989
  VtxoManager.PERIODIC_SETTLE_MAX_BACKOFF_MS = 5 * 60 * 1000;
990
+ VtxoManager.VTXO_SPENT_REFRESH_COOLDOWN_MS = 30000;
@@ -63,6 +63,12 @@ export class ReadonlyWallet {
63
63
  this.walletRepository = walletRepository;
64
64
  this.contractRepository = contractRepository;
65
65
  this.delegatorProvider = delegatorProvider;
66
+ // Outpoints ("txid:vout") committed to an in-flight settle/send. Filtered
67
+ // from getVtxos() so concurrent callers (UI, VtxoManager auto-renewal,
68
+ // another send/settle racing the _txLock) can't reselect coins that are
69
+ // already on their way out. The set is in-memory only: a process crash
70
+ // clears it, and a stale entry only hides a VTXO (never spends one).
71
+ this._pendingSpendOutpoints = new Set();
66
72
  // Guard: detect identity/server network mismatch for descriptor-based identities.
67
73
  // This duplicates the check in setupWalletConfig() so that subclasses
68
74
  // bypassing the factory still get the safety net.
@@ -303,6 +309,9 @@ export class ReadonlyWallet {
303
309
  return vtxos
304
310
  .flatMap((_) => _.vtxos)
305
311
  .filter((vtxo) => {
312
+ if (this._pendingSpendOutpoints.has(`${vtxo.txid}:${vtxo.vout}`)) {
313
+ return false;
314
+ }
306
315
  if (isSpendable(vtxo)) {
307
316
  if (!f.withRecoverable &&
308
317
  (isRecoverable(vtxo) || isExpired(vtxo))) {
@@ -754,6 +763,20 @@ export class ReadonlyWallet {
754
763
  * ```
755
764
  */
756
765
  export class Wallet extends ReadonlyWallet {
766
+ _addPendingSpends(inputs) {
767
+ for (const input of inputs) {
768
+ if ("virtualStatus" in input) {
769
+ this._pendingSpendOutpoints.add(`${input.txid}:${input.vout}`);
770
+ }
771
+ }
772
+ }
773
+ _removePendingSpends(inputs) {
774
+ for (const input of inputs) {
775
+ if ("virtualStatus" in input) {
776
+ this._pendingSpendOutpoints.delete(`${input.txid}:${input.vout}`);
777
+ }
778
+ }
779
+ }
757
780
  _withTxLock(fn) {
758
781
  let release;
759
782
  const lock = new Promise((r) => (release = r));
@@ -969,9 +992,15 @@ export class Wallet extends ReadonlyWallet {
969
992
  amount: BigInt(selected.changeAmount),
970
993
  });
971
994
  }
972
- const { arkTxid, signedCheckpointTxs } = await this.buildAndSubmitOffchainTx(selected.inputs, outputs);
973
- await this.updateDbAfterOffchainTx(selected.inputs, arkTxid, signedCheckpointTxs, params.amount, selected.changeAmount, selected.changeAmount > 0n ? outputs.length - 1 : 0);
974
- return arkTxid;
995
+ this._addPendingSpends(selected.inputs);
996
+ try {
997
+ const { arkTxid, signedCheckpointTxs } = await this.buildAndSubmitOffchainTx(selected.inputs, outputs);
998
+ await this.updateDbAfterOffchainTx(selected.inputs, arkTxid, signedCheckpointTxs, params.amount, selected.changeAmount, selected.changeAmount > 0n ? outputs.length - 1 : 0);
999
+ return arkTxid;
1000
+ }
1001
+ finally {
1002
+ this._removePendingSpends(selected.inputs);
1003
+ }
975
1004
  });
976
1005
  }
977
1006
  return this.send({
@@ -1017,7 +1046,8 @@ export class Wallet extends ReadonlyWallet {
1017
1046
  const tip = await this.onchainProvider.getChainTip();
1018
1047
  chainTipHeight = tip.height;
1019
1048
  }
1020
- const boardingUtxos = (await this.getBoardingUtxos()).filter((utxo) => !hasBoardingTxExpired(utxo, boardingTimelock, chainTipHeight));
1049
+ const boardingUtxos = (await this.getBoardingUtxos()).filter((utxo) => utxo.status.confirmed &&
1050
+ !hasBoardingTxExpired(utxo, boardingTimelock, chainTipHeight));
1021
1051
  const filteredBoardingUtxos = [];
1022
1052
  for (const utxo of boardingUtxos) {
1023
1053
  const inputFee = estimator.evalOnchainInput({
@@ -1152,11 +1182,29 @@ export class Wallet extends ReadonlyWallet {
1152
1182
  ...params.inputs.map((input) => `${input.txid}:${input.vout}`),
1153
1183
  ];
1154
1184
  const abortController = new AbortController();
1185
+ let stream;
1186
+ // Optimistically hide these inputs from concurrent getVtxos() callers
1187
+ // while the settlement is in flight. Set before safeRegisterIntent so
1188
+ // there's no window between intent registration and coin-visibility.
1189
+ this._addPendingSpends(params.inputs);
1155
1190
  try {
1156
- const stream = this.arkProvider.getEventStream(abortController.signal, topics);
1191
+ stream = this.arkProvider.getEventStream(abortController.signal, topics);
1192
+ // Prime the iterator so the provider opens the SSE subscription
1193
+ // before safeRegisterIntent can trigger server-side batch events.
1194
+ const firstNext = stream.next();
1195
+ // If settle exits before Batch.join consumes the primed result,
1196
+ // keep the orphaned promise from surfacing as an unhandled rejection.
1197
+ void firstNext.catch(() => { });
1198
+ const primedStream = (async function* () {
1199
+ const first = await firstNext;
1200
+ if (!first.done) {
1201
+ yield first.value;
1202
+ }
1203
+ yield* stream;
1204
+ })();
1157
1205
  const intentId = await this.safeRegisterIntent(intent, params.inputs);
1158
1206
  const handler = this.createBatchHandler(intentId, params.inputs, recipients, session);
1159
- const commitmentTxid = await Batch.join(stream, handler, {
1207
+ const commitmentTxid = await Batch.join(primedStream, handler, {
1160
1208
  abortController,
1161
1209
  skipVtxoTreeSigning: !hasOffchainOutputs,
1162
1210
  eventCallback: eventCallback
@@ -1180,23 +1228,28 @@ export class Wallet extends ReadonlyWallet {
1180
1228
  throw error;
1181
1229
  }
1182
1230
  finally {
1183
- // close the stream
1231
+ // Clear state first so a synchronous handler firing from abort()
1232
+ // never observes a stale pending-spend set.
1233
+ this._removePendingSpends(params.inputs);
1234
+ // close the stream — abort() fires the in-body handler if the
1235
+ // generator has started iterating; return() also releases the
1236
+ // eager resource if the body is still suspended or never ran
1237
+ // (e.g. safeRegisterIntent threw before Batch.join was called).
1184
1238
  abortController.abort();
1239
+ await stream?.return?.().catch(() => { });
1185
1240
  }
1186
1241
  }
1187
1242
  async handleSettlementFinalizationEvent(event, inputs, forfeitOutputScript, connectorsGraph) {
1188
1243
  // the signed forfeits transactions to submit
1189
1244
  const signedForfeits = [];
1190
- const vtxos = await this.getVtxos();
1245
+ const isVtxo = (input) => "virtualStatus" in input;
1191
1246
  let settlementPsbt = Transaction.fromPSBT(base64.decode(event.commitmentTx));
1192
1247
  let hasBoardingUtxos = false;
1193
1248
  let connectorIndex = 0;
1194
1249
  const connectorsLeaves = connectorsGraph?.leaves() || [];
1195
1250
  for (const input of inputs) {
1196
- // check if the input is an offchain "virtual" coin
1197
- const vtxo = vtxos.find((vtxo) => vtxo.txid === input.txid && vtxo.vout === input.vout);
1198
1251
  // boarding input, we need to sign the settlement tx
1199
- if (!vtxo) {
1252
+ if (!isVtxo(input)) {
1200
1253
  for (let i = 0; i < settlementPsbt.inputsLength; i++) {
1201
1254
  const settlementInput = settlementPsbt.getInput(i);
1202
1255
  if (!settlementInput.txid ||
@@ -1220,7 +1273,7 @@ export class Wallet extends ReadonlyWallet {
1220
1273
  }
1221
1274
  continue;
1222
1275
  }
1223
- if (isRecoverable(vtxo) || isSubdust(vtxo, this.dustAmount)) {
1276
+ if (isRecoverable(input) || isSubdust(input, this.dustAmount)) {
1224
1277
  // recoverable or subdust coin, we don't need to create a forfeit tx
1225
1278
  continue;
1226
1279
  }
@@ -1247,7 +1300,7 @@ export class Wallet extends ReadonlyWallet {
1247
1300
  txid: input.txid,
1248
1301
  index: input.vout,
1249
1302
  witnessUtxo: {
1250
- amount: BigInt(vtxo.value),
1303
+ amount: BigInt(input.value),
1251
1304
  script: VtxoScript.decode(input.tapTree).pkScript,
1252
1305
  },
1253
1306
  sighashType: SigHash.DEFAULT,
@@ -1676,9 +1729,17 @@ export class Wallet extends ReadonlyWallet {
1676
1729
  outputs.push(Extension.create([assetPacket]).txOut());
1677
1730
  }
1678
1731
  const sentAmount = recipients.reduce((sum, r) => sum + r.amount, 0);
1679
- const { arkTxid, signedCheckpointTxs } = await this.buildAndSubmitOffchainTx(selectedCoins, outputs);
1680
- await this.updateDbAfterOffchainTx(selectedCoins, arkTxid, signedCheckpointTxs, sentAmount, BigInt(changeAmount), changeReceiver ? changeIndex : 0, changeReceiver?.assets);
1681
- return arkTxid;
1732
+ // Optimistically hide selected coins from concurrent getVtxos() while
1733
+ // the offchain tx is in flight.
1734
+ this._addPendingSpends(selectedCoins);
1735
+ try {
1736
+ const { arkTxid, signedCheckpointTxs } = await this.buildAndSubmitOffchainTx(selectedCoins, outputs);
1737
+ await this.updateDbAfterOffchainTx(selectedCoins, arkTxid, signedCheckpointTxs, sentAmount, BigInt(changeAmount), changeReceiver ? changeIndex : 0, changeReceiver?.assets);
1738
+ return arkTxid;
1739
+ }
1740
+ finally {
1741
+ this._removePendingSpends(selectedCoins);
1742
+ }
1682
1743
  }
1683
1744
  /**
1684
1745
  * Build an offchain transaction from the given inputs and outputs,
@@ -224,6 +224,9 @@ export declare class VtxoManager implements AsyncDisposable, IVtxoManager {
224
224
  private consecutivePeriodicSettleFailures;
225
225
  private static readonly PERIODIC_SETTLE_COOLDOWN_MS;
226
226
  private static readonly PERIODIC_SETTLE_MAX_BACKOFF_MS;
227
+ private lastVtxoSpentRefreshTimestamp;
228
+ private vtxoSpentRefreshPromise?;
229
+ private static readonly VTXO_SPENT_REFRESH_COOLDOWN_MS;
227
230
  constructor(wallet: IWallet,
228
231
  /** @deprecated Use settlementConfig instead */
229
232
  renewalConfig?: RenewalConfig | undefined, settlementConfig?: SettlementConfig | false);
@@ -402,6 +405,16 @@ export declare class VtxoManager implements AsyncDisposable, IVtxoManager {
402
405
  /** Returns the wallet's identity for transaction signing. */
403
406
  private getIdentity;
404
407
  private initializeSubscription;
408
+ /**
409
+ * VTXO_ALREADY_SPENT means the server's authoritative view of VTXO state
410
+ * is ahead of ours — cross-instance race, pre-lock snapshot drift, or an
411
+ * SSE gap left stale data in the local cache. Silent-swallowing guarantees
412
+ * the same error on the next cycle because nothing reconciles the cache,
413
+ * so instead we trigger a full refreshVtxos() to advance the global sync
414
+ * cursor. Throttled to prevent a buggy indexer from causing a refresh
415
+ * storm.
416
+ */
417
+ private maybeRefreshAfterVtxoSpent;
405
418
  /** Computes the next poll delay, applying exponential backoff on failures. */
406
419
  private getNextPollDelay;
407
420
  /**
@@ -44,6 +44,7 @@ export declare class ReadonlyWallet implements IReadonlyWallet {
44
44
  protected readonly watcherConfig?: ReadonlyWalletConfig["watcherConfig"];
45
45
  private readonly _assetManager;
46
46
  private _syncVtxosInflight?;
47
+ protected _pendingSpendOutpoints: Set<string>;
47
48
  get assetManager(): IReadonlyAssetManager;
48
49
  protected constructor(identity: ReadonlyIdentity, network: Network, onchainProvider: OnchainProvider, indexerProvider: IndexerProvider, arkServerPublicKey: Bytes, offchainTapscript: DefaultVtxo.Script | DelegateVtxo.Script, boardingTapscript: DefaultVtxo.Script, dustAmount: bigint, walletRepository: WalletRepository, contractRepository: ContractRepository, delegatorProvider?: DelegatorProvider | undefined, watcherConfig?: ReadonlyWalletConfig["watcherConfig"]);
49
50
  /**
@@ -217,6 +218,8 @@ export declare class Wallet extends ReadonlyWallet implements IWallet {
217
218
  * same VTXO inputs.
218
219
  */
219
220
  private _txLock;
221
+ private _addPendingSpends;
222
+ private _removePendingSpends;
220
223
  private _withTxLock;
221
224
  /** @deprecated Use settlementConfig instead */
222
225
  readonly renewalConfig: Required<Omit<WalletConfig["renewalConfig"], "enabled">> & {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arkade-os/sdk",
3
- "version": "0.4.18",
3
+ "version": "0.4.19",
4
4
  "description": "TypeScript SDK for building Bitcoin wallets using the Arkade protocol",
5
5
  "type": "module",
6
6
  "main": "./dist/cjs/index.js",