@arkade-os/sdk 0.4.17 → 0.4.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +16 -6
  2. package/dist/cjs/contracts/arkcontract.js +0 -2
  3. package/dist/cjs/contracts/contractManager.js +111 -215
  4. package/dist/cjs/contracts/contractWatcher.js +86 -115
  5. package/dist/cjs/providers/ark.js +36 -33
  6. package/dist/cjs/repositories/indexedDB/manager.js +6 -3
  7. package/dist/cjs/repositories/indexedDB/schema.js +47 -2
  8. package/dist/cjs/repositories/indexedDB/walletRepository.js +21 -2
  9. package/dist/cjs/repositories/realm/contractRepository.js +0 -4
  10. package/dist/cjs/repositories/realm/index.js +3 -1
  11. package/dist/cjs/repositories/realm/schemas.js +50 -1
  12. package/dist/cjs/repositories/realm/walletRepository.js +8 -4
  13. package/dist/cjs/repositories/scriptFromAddress.js +16 -0
  14. package/dist/cjs/repositories/sqlite/contractRepository.js +2 -6
  15. package/dist/cjs/repositories/sqlite/walletRepository.js +121 -33
  16. package/dist/cjs/utils/syncCursors.js +48 -56
  17. package/dist/cjs/wallet/expo/background.js +0 -13
  18. package/dist/cjs/wallet/expo/wallet.js +1 -6
  19. package/dist/cjs/wallet/serviceWorker/wallet-message-handler.js +16 -7
  20. package/dist/cjs/wallet/serviceWorker/wallet.js +19 -0
  21. package/dist/cjs/wallet/utils.js +41 -10
  22. package/dist/cjs/wallet/vtxo-manager.js +222 -40
  23. package/dist/cjs/wallet/wallet.js +149 -211
  24. package/dist/cjs/worker/expo/processors/contractPollProcessor.js +9 -13
  25. package/dist/cjs/worker/expo/taskRunner.js +2 -11
  26. package/dist/esm/contracts/arkcontract.js +0 -2
  27. package/dist/esm/contracts/contractManager.js +113 -217
  28. package/dist/esm/contracts/contractWatcher.js +86 -115
  29. package/dist/esm/providers/ark.js +36 -33
  30. package/dist/esm/repositories/indexedDB/manager.js +6 -3
  31. package/dist/esm/repositories/indexedDB/schema.js +46 -2
  32. package/dist/esm/repositories/indexedDB/walletRepository.js +21 -2
  33. package/dist/esm/repositories/realm/contractRepository.js +0 -4
  34. package/dist/esm/repositories/realm/index.js +1 -1
  35. package/dist/esm/repositories/realm/schemas.js +48 -0
  36. package/dist/esm/repositories/realm/walletRepository.js +8 -4
  37. package/dist/esm/repositories/scriptFromAddress.js +13 -0
  38. package/dist/esm/repositories/sqlite/contractRepository.js +2 -6
  39. package/dist/esm/repositories/sqlite/walletRepository.js +121 -33
  40. package/dist/esm/utils/syncCursors.js +47 -53
  41. package/dist/esm/wallet/expo/background.js +0 -13
  42. package/dist/esm/wallet/expo/wallet.js +2 -7
  43. package/dist/esm/wallet/serviceWorker/wallet-message-handler.js +17 -8
  44. package/dist/esm/wallet/serviceWorker/wallet.js +19 -0
  45. package/dist/esm/wallet/utils.js +41 -9
  46. package/dist/esm/wallet/vtxo-manager.js +222 -40
  47. package/dist/esm/wallet/wallet.js +152 -214
  48. package/dist/esm/worker/expo/processors/contractPollProcessor.js +9 -13
  49. package/dist/esm/worker/expo/taskRunner.js +3 -12
  50. package/dist/types/contracts/arkcontract.d.ts +0 -2
  51. package/dist/types/contracts/contractManager.d.ts +38 -9
  52. package/dist/types/contracts/contractWatcher.d.ts +22 -21
  53. package/dist/types/contracts/types.d.ts +0 -7
  54. package/dist/types/repositories/indexedDB/manager.d.ts +5 -2
  55. package/dist/types/repositories/indexedDB/schema.d.ts +3 -2
  56. package/dist/types/repositories/realm/index.d.ts +1 -1
  57. package/dist/types/repositories/realm/schemas.d.ts +41 -0
  58. package/dist/types/repositories/scriptFromAddress.d.ts +9 -0
  59. package/dist/types/repositories/serialization.d.ts +1 -1
  60. package/dist/types/repositories/sqlite/walletRepository.d.ts +22 -0
  61. package/dist/types/repositories/walletRepository.d.ts +10 -2
  62. package/dist/types/utils/syncCursors.d.ts +25 -23
  63. package/dist/types/wallet/index.d.ts +1 -1
  64. package/dist/types/wallet/serviceWorker/wallet-message-handler.d.ts +15 -3
  65. package/dist/types/wallet/utils.d.ts +20 -4
  66. package/dist/types/wallet/vtxo-manager.d.ts +29 -6
  67. package/dist/types/wallet/wallet.d.ts +8 -17
  68. package/dist/types/worker/expo/processors/contractPollProcessor.d.ts +9 -4
  69. package/dist/types/worker/expo/taskRunner.d.ts +6 -3
  70. package/package.json +1 -1
@@ -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.
@@ -304,8 +310,13 @@ class ReadonlyWallet {
304
310
  async getVtxos(filter) {
305
311
  const f = filter ?? { withRecoverable: true, withUnrolled: false };
306
312
  const contractManager = await this.getContractManager();
307
- const contractsWithVtxos = await contractManager.getContractsWithVtxos();
308
- return contractsWithVtxos.flatMap(({ vtxos }) => vtxos.filter((vtxo) => {
313
+ const vtxos = await contractManager.getContractsWithVtxos();
314
+ return vtxos
315
+ .flatMap((_) => _.vtxos)
316
+ .filter((vtxo) => {
317
+ if (this._pendingSpendOutpoints.has(`${vtxo.txid}:${vtxo.vout}`)) {
318
+ return false;
319
+ }
309
320
  if ((0, _1.isSpendable)(vtxo)) {
310
321
  if (!f.withRecoverable &&
311
322
  ((0, _1.isRecoverable)(vtxo) || (0, _1.isExpired)(vtxo))) {
@@ -314,17 +325,15 @@ class ReadonlyWallet {
314
325
  return true;
315
326
  }
316
327
  return !!(f.withUnrolled && vtxo.isUnrolled);
317
- }));
328
+ });
318
329
  }
319
330
  /**
320
331
  * Return wallet transaction history derived from Arkade state and boarding transactions.
321
332
  */
322
333
  async getTransactionHistory() {
323
- // Delta-sync virtual outputs into cache, then build history from the cache.
324
- const { isDelta, fetchedExtended, address } = await this.syncVtxos();
325
- const allVtxos = isDelta
326
- ? await this.walletRepository.getVtxos(address)
327
- : fetchedExtended;
334
+ const contractManager = await this.getContractManager();
335
+ const response = await contractManager.getContractsWithVtxos();
336
+ const allVtxos = response.flatMap((_) => _.vtxos);
328
337
  const { boardingTxs, commitmentsToIgnore } = await this.getBoardingTxs();
329
338
  const getTxCreatedAt = (txid) => this.indexerProvider
330
339
  .getVtxos({ outpoints: [{ txid, vout: 0 }] })
@@ -332,166 +341,11 @@ class ReadonlyWallet {
332
341
  return (0, transactionHistory_1.buildTransactionHistory)(allVtxos, boardingTxs, commitmentsToIgnore, getTxCreatedAt);
333
342
  }
334
343
  /**
335
- * Delta-sync wallet virtual outputs: fetch only changed virtual outputs since the last
336
- * cursor, or do a full bootstrap when no cursor exists. Upserts
337
- * the result into the cache and advances the sync cursors.
338
- *
339
- * Concurrent calls are deduplicated: if a sync is already in flight,
340
- * subsequent callers receive the same promise instead of triggering
341
- * a second network round-trip.
342
- */
343
- syncVtxos() {
344
- if (this._syncVtxosInflight)
345
- return this._syncVtxosInflight;
346
- const p = this.doSyncVtxos().finally(() => {
347
- this._syncVtxosInflight = undefined;
348
- });
349
- this._syncVtxosInflight = p;
350
- return p;
351
- }
352
- async doSyncVtxos() {
353
- const address = await this.getAddress();
354
- // Batch cursor read with script map to avoid extra async hops
355
- // before the fetch (background operations may run between hops).
356
- const [scriptMap, cursors] = await Promise.all([
357
- this.getScriptMap(),
358
- (0, syncCursors_1.getAllSyncCursors)(this.walletRepository),
359
- ]);
360
- const allScripts = [...scriptMap.keys()];
361
- // Partition scripts into bootstrap (no cursor) and delta (has cursor).
362
- const bootstrapScripts = [];
363
- const deltaScripts = [];
364
- for (const s of allScripts) {
365
- if (cursors[s] === undefined) {
366
- bootstrapScripts.push(s);
367
- }
368
- else {
369
- deltaScripts.push(s);
370
- }
371
- }
372
- const requestStartedAt = Date.now();
373
- const allVtxos = [];
374
- const extendWithScript = (vtxo) => {
375
- const vtxoScript = vtxo.script
376
- ? scriptMap.get(vtxo.script)
377
- : undefined;
378
- if (!vtxoScript)
379
- return undefined;
380
- return {
381
- ...vtxo,
382
- forfeitTapLeafScript: vtxoScript.forfeit(),
383
- intentTapLeafScript: vtxoScript.forfeit(),
384
- tapTree: vtxoScript.encode(),
385
- };
386
- };
387
- // Full fetch for scripts with no cursor.
388
- if (bootstrapScripts.length > 0) {
389
- const response = await this.indexerProvider.getVtxos({
390
- scripts: bootstrapScripts,
391
- });
392
- allVtxos.push(...response.vtxos);
393
- }
394
- // Delta fetch for scripts with an existing cursor.
395
- let hasDelta = false;
396
- if (deltaScripts.length > 0) {
397
- const minCursor = Math.min(...deltaScripts.map((s) => cursors[s]));
398
- const window = (0, syncCursors_1.computeSyncWindow)(minCursor);
399
- if (window) {
400
- hasDelta = true;
401
- const response = await this.indexerProvider.getVtxos({
402
- scripts: deltaScripts,
403
- after: window.after,
404
- });
405
- allVtxos.push(...response.vtxos);
406
- }
407
- }
408
- // Extend every fetched virtual output and upsert into the cache.
409
- const fetchedExtended = [];
410
- for (const vtxo of allVtxos) {
411
- const extended = extendWithScript(vtxo);
412
- if (extended)
413
- fetchedExtended.push(extended);
414
- }
415
- // Save virtual outputs first, then advance cursors only on success.
416
- const cutoff = (0, syncCursors_1.cursorCutoff)(requestStartedAt);
417
- await this.walletRepository.saveVtxos(address, fetchedExtended);
418
- await (0, syncCursors_1.advanceSyncCursors)(this.walletRepository, Object.fromEntries(allScripts.map((s) => [s, cutoff])));
419
- // Delta-sync reconciliation: full re-fetch for delta scripts.
420
- //
421
- // The delta fetch (above) only returns virtual outputs changed after the
422
- // cursor, so it can miss preconfirmed virtual outputs that were consumed
423
- // by a round between syncs. Rather than layering targeted
424
- // queries (pendingOnly, spendableOnly) with pagination guards
425
- // and set algebra, we perform a single unfiltered re-fetch for
426
- // delta scripts. This is slightly more data over the wire but
427
- // gives us complete, authoritative state in one call and keeps
428
- // the reconciliation logic simple.
429
- //
430
- // Any cached non-spent virtual output that is absent from the full
431
- // result set is marked spent; any virtual output whose state changed
432
- // (e.g. preconfirmed → settled) is updated in place.
433
- if (hasDelta) {
434
- const { vtxos: fullVtxos, page: fullPage } = await this.indexerProvider.getVtxos({
435
- scripts: deltaScripts,
436
- });
437
- // Reconciliation is best-effort: if the response is
438
- // paginated we don't have a complete picture, so we skip
439
- // rather than act on partial data. Wallets with enough
440
- // virtual outputs to exceed a single page rely solely on the
441
- // cursor-based delta mechanism for state updates.
442
- const fullSetComplete = !fullPage || fullPage.total <= 1;
443
- if (fullSetComplete) {
444
- const fullOutpoints = new Map(fullVtxos.map((v) => [`${v.txid}:${v.vout}`, v]));
445
- const deltaScriptSet = new Set(deltaScripts);
446
- const cachedVtxos = await this.walletRepository.getVtxos(address);
447
- const reconciledExtended = [];
448
- for (const cached of cachedVtxos) {
449
- if (!cached.script ||
450
- !deltaScriptSet.has(cached.script) ||
451
- cached.isSpent) {
452
- continue;
453
- }
454
- const outpoint = `${cached.txid}:${cached.vout}`;
455
- const fresh = fullOutpoints.get(outpoint);
456
- if (!fresh) {
457
- // Server no longer knows about this virtual output —
458
- // it was spent between syncs.
459
- reconciledExtended.push({
460
- ...cached,
461
- isSpent: true,
462
- });
463
- continue;
464
- }
465
- const extended = extendWithScript(fresh);
466
- if (extended &&
467
- extended.virtualStatus.state !==
468
- cached.virtualStatus.state) {
469
- // State transitioned (e.g. preconfirmed →
470
- // settled) — update the cached entry.
471
- reconciledExtended.push(extended);
472
- }
473
- }
474
- if (reconciledExtended.length > 0) {
475
- console.warn(`[ark-sdk] delta sync: reconciled ${reconciledExtended.length} stale VTXO(s) via full re-fetch`);
476
- await this.walletRepository.saveVtxos(address, reconciledExtended);
477
- }
478
- }
479
- else {
480
- console.warn("[ark-sdk] delta sync: skipping reconciliation — full re-fetch was paginated");
481
- }
482
- }
483
- return {
484
- isDelta: hasDelta || bootstrapScripts.length === 0,
485
- fetchedExtended,
486
- address,
487
- };
488
- }
489
- /**
490
- * Clear all virtual output sync cursors, forcing a full re-bootstrap on next sync.
344
+ * Clear the global VTXO sync cursor, forcing a full re-bootstrap on next sync.
491
345
  * Useful for recovery after indexer reprocessing or debugging.
492
346
  */
493
- async clearSyncCursors() {
494
- await (0, syncCursors_1.clearSyncCursors)(this.walletRepository);
347
+ async clearSyncCursor() {
348
+ await (0, syncCursors_1.clearSyncCursor)(this.walletRepository);
495
349
  }
496
350
  /**
497
351
  * Build a transaction history view for the wallet's boarding address.
@@ -534,6 +388,7 @@ class ReadonlyWallet {
534
388
  createdAt: tx.status.confirmed
535
389
  ? new Date(tx.status.block_time * 1000)
536
390
  : new Date(0),
391
+ script: base_1.hex.encode(this.boardingTapscript.pkScript),
537
392
  });
538
393
  }
539
394
  }
@@ -623,22 +478,41 @@ class ReadonlyWallet {
623
478
  await this.indexerProvider?.unsubscribeForScripts(subscriptionId);
624
479
  };
625
480
  // Handle subscription updates asynchronously without blocking.
626
- // Note: subscription covers all wallet scripts (default + delegate),
627
- // but we can't determine which script each virtual output belongs to from the
628
- // subscription event. Virtual outputs are extended with the current offchainTapscript;
629
- // this is for notification/display only not for spending.
630
- // For correct extension metadata, use getVtxos() which queries per-script.
481
+ // Subscription covers all wallet scripts (default + delegate) plus
482
+ // any additional registered contracts. Virtual outputs carry a
483
+ // `script` field from the indexer which the contract manager
484
+ // resolves to the owning contract so the extension uses the
485
+ // correct forfeit/intent tapscripts.
631
486
  (async () => {
632
487
  try {
488
+ const cm = await this.getContractManager();
633
489
  for await (const update of subscription) {
634
- if (update.newVtxos?.length > 0 ||
635
- update.spentVtxos?.length > 0) {
490
+ if (update.newVtxos?.length === 0 &&
491
+ update.spentVtxos?.length === 0) {
492
+ continue;
493
+ }
494
+ // Isolate per-update annotation failures (e.g. a VTXO
495
+ // arriving for a contract we haven't registered yet).
496
+ // Without this a single bad update would kill the
497
+ // for-await loop and silently drop every subsequent
498
+ // subscription event for the session.
499
+ try {
500
+ // Default to `[]` so a one-sided update (e.g.
501
+ // only `newVtxos`) doesn't pass `undefined` into
502
+ // annotateVtxos and throw on `.length`.
503
+ const [newVtxos, spentVtxos] = await Promise.all([
504
+ cm.annotateVtxos(update.newVtxos ?? []),
505
+ cm.annotateVtxos(update.spentVtxos ?? []),
506
+ ]);
636
507
  eventCallback({
637
508
  type: "vtxo",
638
- newVtxos: update.newVtxos.map((vtxo) => (0, utils_1.extendVirtualCoin)(this, vtxo)),
639
- spentVtxos: update.spentVtxos.map((vtxo) => (0, utils_1.extendVirtualCoin)(this, vtxo)),
509
+ newVtxos,
510
+ spentVtxos,
640
511
  });
641
512
  }
513
+ catch (error) {
514
+ console.warn("Dropping subscription update after annotation failed; next sync will reconcile:", error);
515
+ }
642
516
  }
643
517
  }
644
518
  catch (error) {
@@ -895,6 +769,20 @@ exports.ReadonlyWallet = ReadonlyWallet;
895
769
  * ```
896
770
  */
897
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
+ }
898
786
  _withTxLock(fn) {
899
787
  let release;
900
788
  const lock = new Promise((r) => (release = r));
@@ -909,11 +797,10 @@ class Wallet extends ReadonlyWallet {
909
797
  }
910
798
  });
911
799
  }
912
- constructor(identity, network, networkName, onchainProvider, arkProvider, indexerProvider, arkServerPublicKey, offchainTapscript, boardingTapscript, serverUnrollScript, forfeitOutputScript, forfeitPubkey, dustAmount, walletRepository, contractRepository,
800
+ constructor(identity, network, onchainProvider, arkProvider, indexerProvider, arkServerPublicKey, offchainTapscript, boardingTapscript, serverUnrollScript, forfeitOutputScript, forfeitPubkey, dustAmount, walletRepository, contractRepository,
913
801
  /** @deprecated Use settlementConfig */
914
802
  renewalConfig, delegatorProvider, watcherConfig, settlementConfig) {
915
803
  super(identity, network, onchainProvider, indexerProvider, arkServerPublicKey, offchainTapscript, boardingTapscript, dustAmount, walletRepository, contractRepository, delegatorProvider, watcherConfig);
916
- this.networkName = networkName;
917
804
  this.arkProvider = arkProvider;
918
805
  this.serverUnrollScript = serverUnrollScript;
919
806
  this.forfeitOutputScript = forfeitOutputScript;
@@ -1033,7 +920,7 @@ class Wallet extends ReadonlyWallet {
1033
920
  const forfeitPubkey = base_1.hex.decode(setup.info.forfeitPubkey).slice(1);
1034
921
  const forfeitAddress = (0, btc_signer_1.Address)(setup.network).decode(setup.info.forfeitAddress);
1035
922
  const forfeitOutputScript = btc_signer_1.OutScript.encode(forfeitAddress);
1036
- const wallet = new Wallet(config.identity, setup.network, setup.networkName, setup.onchainProvider, setup.arkProvider, setup.indexerProvider, setup.serverPubKey, setup.offchainTapscript, setup.boardingTapscript, serverUnrollScript, forfeitOutputScript, forfeitPubkey, setup.dustAmount, setup.walletRepository, setup.contractRepository, config.renewalConfig, config.delegatorProvider, config.watcherConfig, config.settlementConfig);
923
+ const wallet = new Wallet(config.identity, setup.network, setup.onchainProvider, setup.arkProvider, setup.indexerProvider, setup.serverPubKey, setup.offchainTapscript, setup.boardingTapscript, serverUnrollScript, forfeitOutputScript, forfeitPubkey, setup.dustAmount, setup.walletRepository, setup.contractRepository, config.renewalConfig, config.delegatorProvider, config.watcherConfig, config.settlementConfig);
1037
924
  await wallet.getVtxoManager();
1038
925
  return wallet;
1039
926
  }
@@ -1111,9 +998,15 @@ class Wallet extends ReadonlyWallet {
1111
998
  amount: BigInt(selected.changeAmount),
1112
999
  });
1113
1000
  }
1114
- const { arkTxid, signedCheckpointTxs } = await this.buildAndSubmitOffchainTx(selected.inputs, outputs);
1115
- await this.updateDbAfterOffchainTx(selected.inputs, arkTxid, signedCheckpointTxs, params.amount, selected.changeAmount, selected.changeAmount > 0n ? outputs.length - 1 : 0);
1116
- 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
+ }
1117
1010
  });
1118
1011
  }
1119
1012
  return this.send({
@@ -1159,7 +1052,8 @@ class Wallet extends ReadonlyWallet {
1159
1052
  const tip = await this.onchainProvider.getChainTip();
1160
1053
  chainTipHeight = tip.height;
1161
1054
  }
1162
- 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));
1163
1057
  const filteredBoardingUtxos = [];
1164
1058
  for (const utxo of boardingUtxos) {
1165
1059
  const inputFee = estimator.evalOnchainInput({
@@ -1294,11 +1188,29 @@ class Wallet extends ReadonlyWallet {
1294
1188
  ...params.inputs.map((input) => `${input.txid}:${input.vout}`),
1295
1189
  ];
1296
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);
1297
1196
  try {
1298
- const stream = this.arkProvider.getEventStream(abortController.signal, topics);
1299
- const intentId = await this.safeRegisterIntent(intent);
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
+ })();
1211
+ const intentId = await this.safeRegisterIntent(intent, params.inputs);
1300
1212
  const handler = this.createBatchHandler(intentId, params.inputs, recipients, session);
1301
- const commitmentTxid = await batch_1.Batch.join(stream, handler, {
1213
+ const commitmentTxid = await batch_1.Batch.join(primedStream, handler, {
1302
1214
  abortController,
1303
1215
  skipVtxoTreeSigning: !hasOffchainOutputs,
1304
1216
  eventCallback: eventCallback
@@ -1309,28 +1221,41 @@ class Wallet extends ReadonlyWallet {
1309
1221
  return commitmentTxid;
1310
1222
  }
1311
1223
  catch (error) {
1312
- // delete the intent to not be stuck in the queue
1313
- await this.arkProvider.deleteIntent(deleteIntent).catch(() => { });
1224
+ // delete the intent to not be stuck in the queue. If deletion fails
1225
+ // the intent stays on the server and the next settle will hit
1226
+ // "duplicated input" in safeRegisterIntent — surface the failure
1227
+ // rather than silently swallowing it.
1228
+ const inputIds = params.inputs
1229
+ .map((i) => `${i.txid}:${i.vout}`)
1230
+ .join(",");
1231
+ await this.arkProvider.deleteIntent(deleteIntent).catch((e) => {
1232
+ console.warn(`Failed to delete intent after settle failure for inputs [${inputIds}]; intent may linger on server and cause 'duplicated input' on next settle`, e);
1233
+ });
1314
1234
  throw error;
1315
1235
  }
1316
1236
  finally {
1317
- // 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).
1318
1244
  abortController.abort();
1245
+ await stream?.return?.().catch(() => { });
1319
1246
  }
1320
1247
  }
1321
1248
  async handleSettlementFinalizationEvent(event, inputs, forfeitOutputScript, connectorsGraph) {
1322
1249
  // the signed forfeits transactions to submit
1323
1250
  const signedForfeits = [];
1324
- const vtxos = await this.getVtxos();
1251
+ const isVtxo = (input) => "virtualStatus" in input;
1325
1252
  let settlementPsbt = btc_signer_1.Transaction.fromPSBT(base_1.base64.decode(event.commitmentTx));
1326
1253
  let hasBoardingUtxos = false;
1327
1254
  let connectorIndex = 0;
1328
1255
  const connectorsLeaves = connectorsGraph?.leaves() || [];
1329
1256
  for (const input of inputs) {
1330
- // check if the input is an offchain "virtual" coin
1331
- const vtxo = vtxos.find((vtxo) => vtxo.txid === input.txid && vtxo.vout === input.vout);
1332
1257
  // boarding input, we need to sign the settlement tx
1333
- if (!vtxo) {
1258
+ if (!isVtxo(input)) {
1334
1259
  for (let i = 0; i < settlementPsbt.inputsLength; i++) {
1335
1260
  const settlementInput = settlementPsbt.getInput(i);
1336
1261
  if (!settlementInput.txid ||
@@ -1354,7 +1279,7 @@ class Wallet extends ReadonlyWallet {
1354
1279
  }
1355
1280
  continue;
1356
1281
  }
1357
- if ((0, _1.isRecoverable)(vtxo) || (0, _1.isSubdust)(vtxo, this.dustAmount)) {
1282
+ if ((0, _1.isRecoverable)(input) || (0, _1.isSubdust)(input, this.dustAmount)) {
1358
1283
  // recoverable or subdust coin, we don't need to create a forfeit tx
1359
1284
  continue;
1360
1285
  }
@@ -1381,7 +1306,7 @@ class Wallet extends ReadonlyWallet {
1381
1306
  txid: input.txid,
1382
1307
  index: input.vout,
1383
1308
  witnessUtxo: {
1384
- amount: BigInt(vtxo.value),
1309
+ amount: BigInt(input.value),
1385
1310
  script: base_2.VtxoScript.decode(input.tapTree).pkScript,
1386
1311
  },
1387
1312
  sighashType: btc_signer_1.SigHash.DEFAULT,
@@ -1500,7 +1425,7 @@ class Wallet extends ReadonlyWallet {
1500
1425
  },
1501
1426
  };
1502
1427
  }
1503
- async safeRegisterIntent(intent) {
1428
+ async safeRegisterIntent(intent, inputs) {
1504
1429
  try {
1505
1430
  return await this.arkProvider.registerIntent(intent);
1506
1431
  }
@@ -1509,11 +1434,13 @@ class Wallet extends ReadonlyWallet {
1509
1434
  if (error instanceof errors_1.ArkError &&
1510
1435
  error.code === 0 &&
1511
1436
  error.message.includes("duplicated input")) {
1512
- // delete all intents spending one of the wallet coins
1513
- const allSpendableCoins = await this.getVtxos({
1514
- withRecoverable: true,
1515
- });
1516
- const deleteIntent = await this.makeDeleteIntentSignature(allSpendableCoins);
1437
+ // Clear any queued intent spending these exact inputs. The
1438
+ // previous implementation signed a proof over getVtxos() only,
1439
+ // which misses boarding UTXOs — the most common trigger for
1440
+ // "duplicated input" on the auto-settle path. Signing the
1441
+ // caller's own inputs keeps the proof surgical and correct
1442
+ // regardless of whether the stuck input is a VTXO or boarding.
1443
+ const deleteIntent = await this.makeDeleteIntentSignature(inputs);
1517
1444
  await this.arkProvider.deleteIntent(deleteIntent);
1518
1445
  // try again
1519
1446
  return this.arkProvider.registerIntent(intent);
@@ -1581,9 +1508,7 @@ class Wallet extends ReadonlyWallet {
1581
1508
  scripts: allScripts,
1582
1509
  });
1583
1510
  for (const vtxo of fetchedVtxos) {
1584
- const vtxoScript = vtxo.script
1585
- ? scriptMap.get(vtxo.script)
1586
- : undefined;
1511
+ const vtxoScript = scriptMap.get(vtxo.script);
1587
1512
  if (!vtxoScript)
1588
1513
  continue;
1589
1514
  if (vtxo.virtualStatus.state === "swept" ||
@@ -1810,9 +1735,17 @@ class Wallet extends ReadonlyWallet {
1810
1735
  outputs.push(extension_1.Extension.create([assetPacket]).txOut());
1811
1736
  }
1812
1737
  const sentAmount = recipients.reduce((sum, r) => sum + r.amount, 0);
1813
- const { arkTxid, signedCheckpointTxs } = await this.buildAndSubmitOffchainTx(selectedCoins, outputs);
1814
- await this.updateDbAfterOffchainTx(selectedCoins, arkTxid, signedCheckpointTxs, sentAmount, BigInt(changeAmount), changeReceiver ? changeIndex : 0, changeReceiver?.assets);
1815
- 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
+ }
1816
1749
  }
1817
1750
  /**
1818
1751
  * Build an offchain transaction from the given inputs and outputs,
@@ -1886,8 +1819,9 @@ class Wallet extends ReadonlyWallet {
1886
1819
  console.warn(`updateDbAfterOffchainTx: inputs length (${inputs.length}) differs from signedCheckpointTxs length (${signedCheckpointTxs.length})`);
1887
1820
  }
1888
1821
  const safeLength = Math.min(inputs.length, signedCheckpointTxs.length);
1889
- for (const [inputIndex, input] of inputs.entries()) {
1890
- const vtxo = (0, utils_1.extendVirtualCoin)(this, input);
1822
+ const cm = await this.getContractManager();
1823
+ const annotatedInputs = await cm.annotateVtxos(inputs);
1824
+ for (const [inputIndex, vtxo] of annotatedInputs.entries()) {
1891
1825
  if (inputIndex < safeLength &&
1892
1826
  signedCheckpointTxs[inputIndex]) {
1893
1827
  const checkpoint = btc_signer_1.Transaction.fromPSBT(base_1.base64.decode(signedCheckpointTxs[inputIndex]));
@@ -1947,6 +1881,7 @@ class Wallet extends ReadonlyWallet {
1947
1881
  confirmed: false,
1948
1882
  },
1949
1883
  assets: changeAssets,
1884
+ script: base_1.hex.encode(this.offchainTapscript.pkScript),
1950
1885
  };
1951
1886
  }
1952
1887
  await this.walletRepository.saveVtxos(addr, changeVtxo ? [...spentVtxos, changeVtxo] : spentVtxos);
@@ -1977,10 +1912,14 @@ class Wallet extends ReadonlyWallet {
1977
1912
  const inputArkTxIds = new Set();
1978
1913
  const boardingUtxoToRemove = new Set();
1979
1914
  const isVtxo = (input) => "virtualStatus" in input;
1915
+ const vtxoInputs = inputs.filter(isVtxo);
1916
+ const cm = await this.getContractManager();
1917
+ const annotatedVtxos = await cm.annotateVtxos(vtxoInputs);
1918
+ const annotatedByKey = new Map(annotatedVtxos.map((v) => [`${v.txid}:${v.vout}`, v]));
1980
1919
  for (const input of inputs) {
1981
1920
  if (isVtxo(input)) {
1982
1921
  // virtual output = mark it settled
1983
- const vtxo = (0, utils_1.extendVirtualCoin)(this, input);
1922
+ const vtxo = annotatedByKey.get(`${input.txid}:${input.vout}`);
1984
1923
  if (vtxo.arkTxId) {
1985
1924
  inputArkTxIds.add(vtxo.arkTxId);
1986
1925
  }
@@ -2067,7 +2006,7 @@ function selectVirtualCoins(coins, targetAmount) {
2067
2006
  */
2068
2007
  async function waitForIncomingFunds(wallet) {
2069
2008
  let stopFunc;
2070
- const promise = new Promise((resolve) => {
2009
+ return new Promise((resolve) => {
2071
2010
  wallet
2072
2011
  .notifyIncomingFunds((coins) => {
2073
2012
  resolve(coins);
@@ -2078,5 +2017,4 @@ async function waitForIncomingFunds(wallet) {
2078
2017
  stopFunc = stop;
2079
2018
  });
2080
2019
  });
2081
- return promise;
2082
2020
  }
@@ -8,27 +8,24 @@ exports.CONTRACT_POLL_TASK_TYPE = "contract-poll";
8
8
  *
9
9
  * Replicates the polling subset of @see ContractManager.initialize:
10
10
  * 1. Load all contracts from the contract repository.
11
- * 2. Mark expired active contracts as inactive.
12
- * 3. Paginated fetch of spendable VTXOs from the indexer.
13
- * 4. Extend each VTXO with tapscript data.
14
- * 5. Save to the wallet repository.
11
+ * 2. Paginated fetch of every VTXO (including spent) from the indexer.
12
+ * 3. Extend each VTXO with tapscript data.
13
+ * 4. Save to the wallet repository.
14
+ *
15
+ * NOTE: the indexer query deliberately omits `spendableOnly`. Every
16
+ * repository implements `saveVtxos` as an upsert with no batch delete,
17
+ * so filtering to spendable-only would leave VTXOs that became spent
18
+ * between polls marked as spendable forever. Fetching the full set lets
19
+ * the upsert overwrite stale records with their latest state.
15
20
  */
16
21
  exports.contractPollProcessor = {
17
22
  taskType: exports.CONTRACT_POLL_TASK_TYPE,
18
23
  async execute(item, deps) {
19
24
  const { contractRepository, walletRepository, indexerProvider, extendVtxo, } = deps;
20
25
  const contracts = await contractRepository.getContracts();
21
- const now = Date.now();
22
26
  let contractsProcessed = 0;
23
27
  let vtxosSaved = 0;
24
28
  for (const contract of contracts) {
25
- // Mark expired active contracts as inactive
26
- if (contract.state === "active" &&
27
- contract.expiresAt &&
28
- contract.expiresAt <= now) {
29
- contract.state = "inactive";
30
- await contractRepository.saveContract(contract);
31
- }
32
29
  // Paginated fetch of spendable virtual outputs
33
30
  const pageSize = 100;
34
31
  let pageIndex = 0;
@@ -37,7 +34,6 @@ exports.contractPollProcessor = {
37
34
  while (hasMore) {
38
35
  const { vtxos, page } = await indexerProvider.getVtxos({
39
36
  scripts: [contract.script],
40
- spendableOnly: true,
41
37
  pageIndex,
42
38
  pageSize,
43
39
  });
@@ -66,17 +66,8 @@ async function runTasks(queue, processors, deps) {
66
66
  * can build deps without depending on Expo.
67
67
  */
68
68
  function createTaskDependencies(options) {
69
- const { walletRepository, contractRepository, indexerProvider, arkProvider, offchainTapscript, } = options;
70
69
  return {
71
- walletRepository,
72
- contractRepository,
73
- indexerProvider,
74
- arkProvider,
75
- extendVtxo: (vtxo, contract) => {
76
- if (contract) {
77
- return (0, utils_1.extendVtxoFromContract)(vtxo, contract);
78
- }
79
- return (0, utils_1.extendVirtualCoin)({ offchainTapscript }, vtxo);
80
- },
70
+ ...options,
71
+ extendVtxo: (vtxo, contract) => (0, utils_1.extendVirtualCoinForContract)(vtxo, contract),
81
72
  };
82
73
  }
@@ -103,7 +103,6 @@ export function contractFromArkContract(encoded, options = {}) {
103
103
  params,
104
104
  state: options.state || "active",
105
105
  createdAt: Date.now(),
106
- expiresAt: options.expiresAt,
107
106
  metadata: options.metadata,
108
107
  };
109
108
  }
@@ -129,7 +128,6 @@ export function contractFromArkContractWithAddress(encoded, serverPubKey, addres
129
128
  address: vtxoScript.address(addressPrefix, serverPubKey).encode(),
130
129
  state: options.state || "active",
131
130
  createdAt: Date.now(),
132
- expiresAt: options.expiresAt,
133
131
  metadata: options.metadata,
134
132
  };
135
133
  }