@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
@@ -12,7 +12,7 @@ import { validateConnectorsTxGraph, validateVtxoTxGraph, } from '../tree/validat
12
12
  import { validateBatchRecipients } from './validation.js';
13
13
  import { isBatchSignable } from '../identity/index.js';
14
14
  import { isExpired, isRecoverable, isSpendable, isSubdust, TxType, } from './index.js';
15
- import { createAssetPacket, selectedCoinsToAssetInputs, selectCoinsWithAsset, } from './asset.js';
15
+ import { createAssetPacket, selectCoinsWithAsset, selectedCoinsToAssetInputs, } from './asset.js';
16
16
  import { VtxoScript } from '../script/base.js';
17
17
  import { CSVMultisigTapscript } from '../script/tapscript.js';
18
18
  import { buildOffchainTx, combineTapscriptSigs, hasBoardingTxExpired, isValidArkAddress, } from '../utils/arkTransaction.js';
@@ -20,7 +20,7 @@ import { DEFAULT_RENEWAL_CONFIG, DEFAULT_SETTLEMENT_CONFIG, VtxoManager, } from
20
20
  import { ArkNote } from '../arknote/index.js';
21
21
  import { Intent } from '../intent/index.js';
22
22
  import { RestIndexerProvider } from '../providers/indexer.js';
23
- import { extendCoin, extendVirtualCoin, validateRecipients } from './utils.js';
23
+ import { extendCoin, validateRecipients } from './utils.js';
24
24
  import { ArkError } from '../providers/errors.js';
25
25
  import { Batch } from './batch.js';
26
26
  import { Estimator } from '../arkfee/index.js';
@@ -33,7 +33,7 @@ import { IndexedDBContractRepository, IndexedDBWalletRepository, } from '../repo
33
33
  import { ContractManager } from '../contracts/contractManager.js';
34
34
  import { contractHandlers } from '../contracts/handlers/index.js';
35
35
  import { timelockToSequence } from '../contracts/handlers/helpers.js';
36
- import { advanceSyncCursors, clearSyncCursors, computeSyncWindow, cursorCutoff, getAllSyncCursors, updateWalletState, } from '../utils/syncCursors.js';
36
+ import { clearSyncCursor, updateWalletState } from '../utils/syncCursors.js';
37
37
  // Hardcoded unilateral exit delay for mainnet (~7 days in seconds).
38
38
  // Pinned here so that address derivation stays stable for existing mainnet
39
39
  // wallets even after the server lowers the delay it advertises.
@@ -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.
@@ -299,8 +305,13 @@ export class ReadonlyWallet {
299
305
  async getVtxos(filter) {
300
306
  const f = filter ?? { withRecoverable: true, withUnrolled: false };
301
307
  const contractManager = await this.getContractManager();
302
- const contractsWithVtxos = await contractManager.getContractsWithVtxos();
303
- return contractsWithVtxos.flatMap(({ vtxos }) => vtxos.filter((vtxo) => {
308
+ const vtxos = await contractManager.getContractsWithVtxos();
309
+ return vtxos
310
+ .flatMap((_) => _.vtxos)
311
+ .filter((vtxo) => {
312
+ if (this._pendingSpendOutpoints.has(`${vtxo.txid}:${vtxo.vout}`)) {
313
+ return false;
314
+ }
304
315
  if (isSpendable(vtxo)) {
305
316
  if (!f.withRecoverable &&
306
317
  (isRecoverable(vtxo) || isExpired(vtxo))) {
@@ -309,17 +320,15 @@ export class ReadonlyWallet {
309
320
  return true;
310
321
  }
311
322
  return !!(f.withUnrolled && vtxo.isUnrolled);
312
- }));
323
+ });
313
324
  }
314
325
  /**
315
326
  * Return wallet transaction history derived from Arkade state and boarding transactions.
316
327
  */
317
328
  async getTransactionHistory() {
318
- // Delta-sync virtual outputs into cache, then build history from the cache.
319
- const { isDelta, fetchedExtended, address } = await this.syncVtxos();
320
- const allVtxos = isDelta
321
- ? await this.walletRepository.getVtxos(address)
322
- : fetchedExtended;
329
+ const contractManager = await this.getContractManager();
330
+ const response = await contractManager.getContractsWithVtxos();
331
+ const allVtxos = response.flatMap((_) => _.vtxos);
323
332
  const { boardingTxs, commitmentsToIgnore } = await this.getBoardingTxs();
324
333
  const getTxCreatedAt = (txid) => this.indexerProvider
325
334
  .getVtxos({ outpoints: [{ txid, vout: 0 }] })
@@ -327,166 +336,11 @@ export class ReadonlyWallet {
327
336
  return buildTransactionHistory(allVtxos, boardingTxs, commitmentsToIgnore, getTxCreatedAt);
328
337
  }
329
338
  /**
330
- * Delta-sync wallet virtual outputs: fetch only changed virtual outputs since the last
331
- * cursor, or do a full bootstrap when no cursor exists. Upserts
332
- * the result into the cache and advances the sync cursors.
333
- *
334
- * Concurrent calls are deduplicated: if a sync is already in flight,
335
- * subsequent callers receive the same promise instead of triggering
336
- * a second network round-trip.
337
- */
338
- syncVtxos() {
339
- if (this._syncVtxosInflight)
340
- return this._syncVtxosInflight;
341
- const p = this.doSyncVtxos().finally(() => {
342
- this._syncVtxosInflight = undefined;
343
- });
344
- this._syncVtxosInflight = p;
345
- return p;
346
- }
347
- async doSyncVtxos() {
348
- const address = await this.getAddress();
349
- // Batch cursor read with script map to avoid extra async hops
350
- // before the fetch (background operations may run between hops).
351
- const [scriptMap, cursors] = await Promise.all([
352
- this.getScriptMap(),
353
- getAllSyncCursors(this.walletRepository),
354
- ]);
355
- const allScripts = [...scriptMap.keys()];
356
- // Partition scripts into bootstrap (no cursor) and delta (has cursor).
357
- const bootstrapScripts = [];
358
- const deltaScripts = [];
359
- for (const s of allScripts) {
360
- if (cursors[s] === undefined) {
361
- bootstrapScripts.push(s);
362
- }
363
- else {
364
- deltaScripts.push(s);
365
- }
366
- }
367
- const requestStartedAt = Date.now();
368
- const allVtxos = [];
369
- const extendWithScript = (vtxo) => {
370
- const vtxoScript = vtxo.script
371
- ? scriptMap.get(vtxo.script)
372
- : undefined;
373
- if (!vtxoScript)
374
- return undefined;
375
- return {
376
- ...vtxo,
377
- forfeitTapLeafScript: vtxoScript.forfeit(),
378
- intentTapLeafScript: vtxoScript.forfeit(),
379
- tapTree: vtxoScript.encode(),
380
- };
381
- };
382
- // Full fetch for scripts with no cursor.
383
- if (bootstrapScripts.length > 0) {
384
- const response = await this.indexerProvider.getVtxos({
385
- scripts: bootstrapScripts,
386
- });
387
- allVtxos.push(...response.vtxos);
388
- }
389
- // Delta fetch for scripts with an existing cursor.
390
- let hasDelta = false;
391
- if (deltaScripts.length > 0) {
392
- const minCursor = Math.min(...deltaScripts.map((s) => cursors[s]));
393
- const window = computeSyncWindow(minCursor);
394
- if (window) {
395
- hasDelta = true;
396
- const response = await this.indexerProvider.getVtxos({
397
- scripts: deltaScripts,
398
- after: window.after,
399
- });
400
- allVtxos.push(...response.vtxos);
401
- }
402
- }
403
- // Extend every fetched virtual output and upsert into the cache.
404
- const fetchedExtended = [];
405
- for (const vtxo of allVtxos) {
406
- const extended = extendWithScript(vtxo);
407
- if (extended)
408
- fetchedExtended.push(extended);
409
- }
410
- // Save virtual outputs first, then advance cursors only on success.
411
- const cutoff = cursorCutoff(requestStartedAt);
412
- await this.walletRepository.saveVtxos(address, fetchedExtended);
413
- await advanceSyncCursors(this.walletRepository, Object.fromEntries(allScripts.map((s) => [s, cutoff])));
414
- // Delta-sync reconciliation: full re-fetch for delta scripts.
415
- //
416
- // The delta fetch (above) only returns virtual outputs changed after the
417
- // cursor, so it can miss preconfirmed virtual outputs that were consumed
418
- // by a round between syncs. Rather than layering targeted
419
- // queries (pendingOnly, spendableOnly) with pagination guards
420
- // and set algebra, we perform a single unfiltered re-fetch for
421
- // delta scripts. This is slightly more data over the wire but
422
- // gives us complete, authoritative state in one call and keeps
423
- // the reconciliation logic simple.
424
- //
425
- // Any cached non-spent virtual output that is absent from the full
426
- // result set is marked spent; any virtual output whose state changed
427
- // (e.g. preconfirmed → settled) is updated in place.
428
- if (hasDelta) {
429
- const { vtxos: fullVtxos, page: fullPage } = await this.indexerProvider.getVtxos({
430
- scripts: deltaScripts,
431
- });
432
- // Reconciliation is best-effort: if the response is
433
- // paginated we don't have a complete picture, so we skip
434
- // rather than act on partial data. Wallets with enough
435
- // virtual outputs to exceed a single page rely solely on the
436
- // cursor-based delta mechanism for state updates.
437
- const fullSetComplete = !fullPage || fullPage.total <= 1;
438
- if (fullSetComplete) {
439
- const fullOutpoints = new Map(fullVtxos.map((v) => [`${v.txid}:${v.vout}`, v]));
440
- const deltaScriptSet = new Set(deltaScripts);
441
- const cachedVtxos = await this.walletRepository.getVtxos(address);
442
- const reconciledExtended = [];
443
- for (const cached of cachedVtxos) {
444
- if (!cached.script ||
445
- !deltaScriptSet.has(cached.script) ||
446
- cached.isSpent) {
447
- continue;
448
- }
449
- const outpoint = `${cached.txid}:${cached.vout}`;
450
- const fresh = fullOutpoints.get(outpoint);
451
- if (!fresh) {
452
- // Server no longer knows about this virtual output —
453
- // it was spent between syncs.
454
- reconciledExtended.push({
455
- ...cached,
456
- isSpent: true,
457
- });
458
- continue;
459
- }
460
- const extended = extendWithScript(fresh);
461
- if (extended &&
462
- extended.virtualStatus.state !==
463
- cached.virtualStatus.state) {
464
- // State transitioned (e.g. preconfirmed →
465
- // settled) — update the cached entry.
466
- reconciledExtended.push(extended);
467
- }
468
- }
469
- if (reconciledExtended.length > 0) {
470
- console.warn(`[ark-sdk] delta sync: reconciled ${reconciledExtended.length} stale VTXO(s) via full re-fetch`);
471
- await this.walletRepository.saveVtxos(address, reconciledExtended);
472
- }
473
- }
474
- else {
475
- console.warn("[ark-sdk] delta sync: skipping reconciliation — full re-fetch was paginated");
476
- }
477
- }
478
- return {
479
- isDelta: hasDelta || bootstrapScripts.length === 0,
480
- fetchedExtended,
481
- address,
482
- };
483
- }
484
- /**
485
- * Clear all virtual output sync cursors, forcing a full re-bootstrap on next sync.
339
+ * Clear the global VTXO sync cursor, forcing a full re-bootstrap on next sync.
486
340
  * Useful for recovery after indexer reprocessing or debugging.
487
341
  */
488
- async clearSyncCursors() {
489
- await clearSyncCursors(this.walletRepository);
342
+ async clearSyncCursor() {
343
+ await clearSyncCursor(this.walletRepository);
490
344
  }
491
345
  /**
492
346
  * Build a transaction history view for the wallet's boarding address.
@@ -529,6 +383,7 @@ export class ReadonlyWallet {
529
383
  createdAt: tx.status.confirmed
530
384
  ? new Date(tx.status.block_time * 1000)
531
385
  : new Date(0),
386
+ script: hex.encode(this.boardingTapscript.pkScript),
532
387
  });
533
388
  }
534
389
  }
@@ -618,22 +473,41 @@ export class ReadonlyWallet {
618
473
  await this.indexerProvider?.unsubscribeForScripts(subscriptionId);
619
474
  };
620
475
  // Handle subscription updates asynchronously without blocking.
621
- // Note: subscription covers all wallet scripts (default + delegate),
622
- // but we can't determine which script each virtual output belongs to from the
623
- // subscription event. Virtual outputs are extended with the current offchainTapscript;
624
- // this is for notification/display only not for spending.
625
- // For correct extension metadata, use getVtxos() which queries per-script.
476
+ // Subscription covers all wallet scripts (default + delegate) plus
477
+ // any additional registered contracts. Virtual outputs carry a
478
+ // `script` field from the indexer which the contract manager
479
+ // resolves to the owning contract so the extension uses the
480
+ // correct forfeit/intent tapscripts.
626
481
  (async () => {
627
482
  try {
483
+ const cm = await this.getContractManager();
628
484
  for await (const update of subscription) {
629
- if (update.newVtxos?.length > 0 ||
630
- update.spentVtxos?.length > 0) {
485
+ if (update.newVtxos?.length === 0 &&
486
+ update.spentVtxos?.length === 0) {
487
+ continue;
488
+ }
489
+ // Isolate per-update annotation failures (e.g. a VTXO
490
+ // arriving for a contract we haven't registered yet).
491
+ // Without this a single bad update would kill the
492
+ // for-await loop and silently drop every subsequent
493
+ // subscription event for the session.
494
+ try {
495
+ // Default to `[]` so a one-sided update (e.g.
496
+ // only `newVtxos`) doesn't pass `undefined` into
497
+ // annotateVtxos and throw on `.length`.
498
+ const [newVtxos, spentVtxos] = await Promise.all([
499
+ cm.annotateVtxos(update.newVtxos ?? []),
500
+ cm.annotateVtxos(update.spentVtxos ?? []),
501
+ ]);
631
502
  eventCallback({
632
503
  type: "vtxo",
633
- newVtxos: update.newVtxos.map((vtxo) => extendVirtualCoin(this, vtxo)),
634
- spentVtxos: update.spentVtxos.map((vtxo) => extendVirtualCoin(this, vtxo)),
504
+ newVtxos,
505
+ spentVtxos,
635
506
  });
636
507
  }
508
+ catch (error) {
509
+ console.warn("Dropping subscription update after annotation failed; next sync will reconcile:", error);
510
+ }
637
511
  }
638
512
  }
639
513
  catch (error) {
@@ -889,6 +763,20 @@ export class ReadonlyWallet {
889
763
  * ```
890
764
  */
891
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
+ }
892
780
  _withTxLock(fn) {
893
781
  let release;
894
782
  const lock = new Promise((r) => (release = r));
@@ -903,11 +791,10 @@ export class Wallet extends ReadonlyWallet {
903
791
  }
904
792
  });
905
793
  }
906
- constructor(identity, network, networkName, onchainProvider, arkProvider, indexerProvider, arkServerPublicKey, offchainTapscript, boardingTapscript, serverUnrollScript, forfeitOutputScript, forfeitPubkey, dustAmount, walletRepository, contractRepository,
794
+ constructor(identity, network, onchainProvider, arkProvider, indexerProvider, arkServerPublicKey, offchainTapscript, boardingTapscript, serverUnrollScript, forfeitOutputScript, forfeitPubkey, dustAmount, walletRepository, contractRepository,
907
795
  /** @deprecated Use settlementConfig */
908
796
  renewalConfig, delegatorProvider, watcherConfig, settlementConfig) {
909
797
  super(identity, network, onchainProvider, indexerProvider, arkServerPublicKey, offchainTapscript, boardingTapscript, dustAmount, walletRepository, contractRepository, delegatorProvider, watcherConfig);
910
- this.networkName = networkName;
911
798
  this.arkProvider = arkProvider;
912
799
  this.serverUnrollScript = serverUnrollScript;
913
800
  this.forfeitOutputScript = forfeitOutputScript;
@@ -1027,7 +914,7 @@ export class Wallet extends ReadonlyWallet {
1027
914
  const forfeitPubkey = hex.decode(setup.info.forfeitPubkey).slice(1);
1028
915
  const forfeitAddress = Address(setup.network).decode(setup.info.forfeitAddress);
1029
916
  const forfeitOutputScript = OutScript.encode(forfeitAddress);
1030
- 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);
917
+ 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);
1031
918
  await wallet.getVtxoManager();
1032
919
  return wallet;
1033
920
  }
@@ -1105,9 +992,15 @@ export class Wallet extends ReadonlyWallet {
1105
992
  amount: BigInt(selected.changeAmount),
1106
993
  });
1107
994
  }
1108
- const { arkTxid, signedCheckpointTxs } = await this.buildAndSubmitOffchainTx(selected.inputs, outputs);
1109
- await this.updateDbAfterOffchainTx(selected.inputs, arkTxid, signedCheckpointTxs, params.amount, selected.changeAmount, selected.changeAmount > 0n ? outputs.length - 1 : 0);
1110
- 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
+ }
1111
1004
  });
1112
1005
  }
1113
1006
  return this.send({
@@ -1153,7 +1046,8 @@ export class Wallet extends ReadonlyWallet {
1153
1046
  const tip = await this.onchainProvider.getChainTip();
1154
1047
  chainTipHeight = tip.height;
1155
1048
  }
1156
- 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));
1157
1051
  const filteredBoardingUtxos = [];
1158
1052
  for (const utxo of boardingUtxos) {
1159
1053
  const inputFee = estimator.evalOnchainInput({
@@ -1288,11 +1182,29 @@ export class Wallet extends ReadonlyWallet {
1288
1182
  ...params.inputs.map((input) => `${input.txid}:${input.vout}`),
1289
1183
  ];
1290
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);
1291
1190
  try {
1292
- const stream = this.arkProvider.getEventStream(abortController.signal, topics);
1293
- const intentId = await this.safeRegisterIntent(intent);
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
+ })();
1205
+ const intentId = await this.safeRegisterIntent(intent, params.inputs);
1294
1206
  const handler = this.createBatchHandler(intentId, params.inputs, recipients, session);
1295
- const commitmentTxid = await Batch.join(stream, handler, {
1207
+ const commitmentTxid = await Batch.join(primedStream, handler, {
1296
1208
  abortController,
1297
1209
  skipVtxoTreeSigning: !hasOffchainOutputs,
1298
1210
  eventCallback: eventCallback
@@ -1303,28 +1215,41 @@ export class Wallet extends ReadonlyWallet {
1303
1215
  return commitmentTxid;
1304
1216
  }
1305
1217
  catch (error) {
1306
- // delete the intent to not be stuck in the queue
1307
- await this.arkProvider.deleteIntent(deleteIntent).catch(() => { });
1218
+ // delete the intent to not be stuck in the queue. If deletion fails
1219
+ // the intent stays on the server and the next settle will hit
1220
+ // "duplicated input" in safeRegisterIntent — surface the failure
1221
+ // rather than silently swallowing it.
1222
+ const inputIds = params.inputs
1223
+ .map((i) => `${i.txid}:${i.vout}`)
1224
+ .join(",");
1225
+ await this.arkProvider.deleteIntent(deleteIntent).catch((e) => {
1226
+ 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);
1227
+ });
1308
1228
  throw error;
1309
1229
  }
1310
1230
  finally {
1311
- // 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).
1312
1238
  abortController.abort();
1239
+ await stream?.return?.().catch(() => { });
1313
1240
  }
1314
1241
  }
1315
1242
  async handleSettlementFinalizationEvent(event, inputs, forfeitOutputScript, connectorsGraph) {
1316
1243
  // the signed forfeits transactions to submit
1317
1244
  const signedForfeits = [];
1318
- const vtxos = await this.getVtxos();
1245
+ const isVtxo = (input) => "virtualStatus" in input;
1319
1246
  let settlementPsbt = Transaction.fromPSBT(base64.decode(event.commitmentTx));
1320
1247
  let hasBoardingUtxos = false;
1321
1248
  let connectorIndex = 0;
1322
1249
  const connectorsLeaves = connectorsGraph?.leaves() || [];
1323
1250
  for (const input of inputs) {
1324
- // check if the input is an offchain "virtual" coin
1325
- const vtxo = vtxos.find((vtxo) => vtxo.txid === input.txid && vtxo.vout === input.vout);
1326
1251
  // boarding input, we need to sign the settlement tx
1327
- if (!vtxo) {
1252
+ if (!isVtxo(input)) {
1328
1253
  for (let i = 0; i < settlementPsbt.inputsLength; i++) {
1329
1254
  const settlementInput = settlementPsbt.getInput(i);
1330
1255
  if (!settlementInput.txid ||
@@ -1348,7 +1273,7 @@ export class Wallet extends ReadonlyWallet {
1348
1273
  }
1349
1274
  continue;
1350
1275
  }
1351
- if (isRecoverable(vtxo) || isSubdust(vtxo, this.dustAmount)) {
1276
+ if (isRecoverable(input) || isSubdust(input, this.dustAmount)) {
1352
1277
  // recoverable or subdust coin, we don't need to create a forfeit tx
1353
1278
  continue;
1354
1279
  }
@@ -1375,7 +1300,7 @@ export class Wallet extends ReadonlyWallet {
1375
1300
  txid: input.txid,
1376
1301
  index: input.vout,
1377
1302
  witnessUtxo: {
1378
- amount: BigInt(vtxo.value),
1303
+ amount: BigInt(input.value),
1379
1304
  script: VtxoScript.decode(input.tapTree).pkScript,
1380
1305
  },
1381
1306
  sighashType: SigHash.DEFAULT,
@@ -1494,7 +1419,7 @@ export class Wallet extends ReadonlyWallet {
1494
1419
  },
1495
1420
  };
1496
1421
  }
1497
- async safeRegisterIntent(intent) {
1422
+ async safeRegisterIntent(intent, inputs) {
1498
1423
  try {
1499
1424
  return await this.arkProvider.registerIntent(intent);
1500
1425
  }
@@ -1503,11 +1428,13 @@ export class Wallet extends ReadonlyWallet {
1503
1428
  if (error instanceof ArkError &&
1504
1429
  error.code === 0 &&
1505
1430
  error.message.includes("duplicated input")) {
1506
- // delete all intents spending one of the wallet coins
1507
- const allSpendableCoins = await this.getVtxos({
1508
- withRecoverable: true,
1509
- });
1510
- const deleteIntent = await this.makeDeleteIntentSignature(allSpendableCoins);
1431
+ // Clear any queued intent spending these exact inputs. The
1432
+ // previous implementation signed a proof over getVtxos() only,
1433
+ // which misses boarding UTXOs — the most common trigger for
1434
+ // "duplicated input" on the auto-settle path. Signing the
1435
+ // caller's own inputs keeps the proof surgical and correct
1436
+ // regardless of whether the stuck input is a VTXO or boarding.
1437
+ const deleteIntent = await this.makeDeleteIntentSignature(inputs);
1511
1438
  await this.arkProvider.deleteIntent(deleteIntent);
1512
1439
  // try again
1513
1440
  return this.arkProvider.registerIntent(intent);
@@ -1575,9 +1502,7 @@ export class Wallet extends ReadonlyWallet {
1575
1502
  scripts: allScripts,
1576
1503
  });
1577
1504
  for (const vtxo of fetchedVtxos) {
1578
- const vtxoScript = vtxo.script
1579
- ? scriptMap.get(vtxo.script)
1580
- : undefined;
1505
+ const vtxoScript = scriptMap.get(vtxo.script);
1581
1506
  if (!vtxoScript)
1582
1507
  continue;
1583
1508
  if (vtxo.virtualStatus.state === "swept" ||
@@ -1804,9 +1729,17 @@ export class Wallet extends ReadonlyWallet {
1804
1729
  outputs.push(Extension.create([assetPacket]).txOut());
1805
1730
  }
1806
1731
  const sentAmount = recipients.reduce((sum, r) => sum + r.amount, 0);
1807
- const { arkTxid, signedCheckpointTxs } = await this.buildAndSubmitOffchainTx(selectedCoins, outputs);
1808
- await this.updateDbAfterOffchainTx(selectedCoins, arkTxid, signedCheckpointTxs, sentAmount, BigInt(changeAmount), changeReceiver ? changeIndex : 0, changeReceiver?.assets);
1809
- 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
+ }
1810
1743
  }
1811
1744
  /**
1812
1745
  * Build an offchain transaction from the given inputs and outputs,
@@ -1880,8 +1813,9 @@ export class Wallet extends ReadonlyWallet {
1880
1813
  console.warn(`updateDbAfterOffchainTx: inputs length (${inputs.length}) differs from signedCheckpointTxs length (${signedCheckpointTxs.length})`);
1881
1814
  }
1882
1815
  const safeLength = Math.min(inputs.length, signedCheckpointTxs.length);
1883
- for (const [inputIndex, input] of inputs.entries()) {
1884
- const vtxo = extendVirtualCoin(this, input);
1816
+ const cm = await this.getContractManager();
1817
+ const annotatedInputs = await cm.annotateVtxos(inputs);
1818
+ for (const [inputIndex, vtxo] of annotatedInputs.entries()) {
1885
1819
  if (inputIndex < safeLength &&
1886
1820
  signedCheckpointTxs[inputIndex]) {
1887
1821
  const checkpoint = Transaction.fromPSBT(base64.decode(signedCheckpointTxs[inputIndex]));
@@ -1941,6 +1875,7 @@ export class Wallet extends ReadonlyWallet {
1941
1875
  confirmed: false,
1942
1876
  },
1943
1877
  assets: changeAssets,
1878
+ script: hex.encode(this.offchainTapscript.pkScript),
1944
1879
  };
1945
1880
  }
1946
1881
  await this.walletRepository.saveVtxos(addr, changeVtxo ? [...spentVtxos, changeVtxo] : spentVtxos);
@@ -1971,10 +1906,14 @@ export class Wallet extends ReadonlyWallet {
1971
1906
  const inputArkTxIds = new Set();
1972
1907
  const boardingUtxoToRemove = new Set();
1973
1908
  const isVtxo = (input) => "virtualStatus" in input;
1909
+ const vtxoInputs = inputs.filter(isVtxo);
1910
+ const cm = await this.getContractManager();
1911
+ const annotatedVtxos = await cm.annotateVtxos(vtxoInputs);
1912
+ const annotatedByKey = new Map(annotatedVtxos.map((v) => [`${v.txid}:${v.vout}`, v]));
1974
1913
  for (const input of inputs) {
1975
1914
  if (isVtxo(input)) {
1976
1915
  // virtual output = mark it settled
1977
- const vtxo = extendVirtualCoin(this, input);
1916
+ const vtxo = annotatedByKey.get(`${input.txid}:${input.vout}`);
1978
1917
  if (vtxo.arkTxId) {
1979
1918
  inputArkTxIds.add(vtxo.arkTxId);
1980
1919
  }
@@ -2060,7 +1999,7 @@ export function selectVirtualCoins(coins, targetAmount) {
2060
1999
  */
2061
2000
  export async function waitForIncomingFunds(wallet) {
2062
2001
  let stopFunc;
2063
- const promise = new Promise((resolve) => {
2002
+ return new Promise((resolve) => {
2064
2003
  wallet
2065
2004
  .notifyIncomingFunds((coins) => {
2066
2005
  resolve(coins);
@@ -2071,5 +2010,4 @@ export async function waitForIncomingFunds(wallet) {
2071
2010
  stopFunc = stop;
2072
2011
  });
2073
2012
  });
2074
- return promise;
2075
2013
  }
@@ -5,27 +5,24 @@ export const CONTRACT_POLL_TASK_TYPE = "contract-poll";
5
5
  *
6
6
  * Replicates the polling subset of @see ContractManager.initialize:
7
7
  * 1. Load all contracts from the contract repository.
8
- * 2. Mark expired active contracts as inactive.
9
- * 3. Paginated fetch of spendable VTXOs from the indexer.
10
- * 4. Extend each VTXO with tapscript data.
11
- * 5. Save to the wallet repository.
8
+ * 2. Paginated fetch of every VTXO (including spent) from the indexer.
9
+ * 3. Extend each VTXO with tapscript data.
10
+ * 4. Save to the wallet repository.
11
+ *
12
+ * NOTE: the indexer query deliberately omits `spendableOnly`. Every
13
+ * repository implements `saveVtxos` as an upsert with no batch delete,
14
+ * so filtering to spendable-only would leave VTXOs that became spent
15
+ * between polls marked as spendable forever. Fetching the full set lets
16
+ * the upsert overwrite stale records with their latest state.
12
17
  */
13
18
  export const contractPollProcessor = {
14
19
  taskType: CONTRACT_POLL_TASK_TYPE,
15
20
  async execute(item, deps) {
16
21
  const { contractRepository, walletRepository, indexerProvider, extendVtxo, } = deps;
17
22
  const contracts = await contractRepository.getContracts();
18
- const now = Date.now();
19
23
  let contractsProcessed = 0;
20
24
  let vtxosSaved = 0;
21
25
  for (const contract of contracts) {
22
- // Mark expired active contracts as inactive
23
- if (contract.state === "active" &&
24
- contract.expiresAt &&
25
- contract.expiresAt <= now) {
26
- contract.state = "inactive";
27
- await contractRepository.saveContract(contract);
28
- }
29
26
  // Paginated fetch of spendable virtual outputs
30
27
  const pageSize = 100;
31
28
  let pageIndex = 0;
@@ -34,7 +31,6 @@ export const contractPollProcessor = {
34
31
  while (hasMore) {
35
32
  const { vtxos, page } = await indexerProvider.getVtxos({
36
33
  scripts: [contract.script],
37
- spendableOnly: true,
38
34
  pageIndex,
39
35
  pageSize,
40
36
  });
@@ -1,4 +1,4 @@
1
- import { getRandomId, extendVirtualCoin, extendVtxoFromContract, } from '../../wallet/utils.js';
1
+ import { getRandomId, extendVirtualCoinForContract } from '../../wallet/utils.js';
2
2
  /**
3
3
  * Run all pending tasks from the queue through matching processors.
4
4
  *
@@ -62,17 +62,8 @@ export async function runTasks(queue, processors, deps) {
62
62
  * can build deps without depending on Expo.
63
63
  */
64
64
  export function createTaskDependencies(options) {
65
- const { walletRepository, contractRepository, indexerProvider, arkProvider, offchainTapscript, } = options;
66
65
  return {
67
- walletRepository,
68
- contractRepository,
69
- indexerProvider,
70
- arkProvider,
71
- extendVtxo: (vtxo, contract) => {
72
- if (contract) {
73
- return extendVtxoFromContract(vtxo, contract);
74
- }
75
- return extendVirtualCoin({ offchainTapscript }, vtxo);
76
- },
66
+ ...options,
67
+ extendVtxo: (vtxo, contract) => extendVirtualCoinForContract(vtxo, contract),
77
68
  };
78
69
  }