@arkade-os/sdk 0.4.16 → 0.4.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +16 -6
  2. package/dist/cjs/contracts/arkcontract.js +0 -2
  3. package/dist/cjs/contracts/contractManager.js +111 -199
  4. package/dist/cjs/contracts/contractWatcher.js +86 -115
  5. package/dist/cjs/repositories/indexedDB/manager.js +6 -3
  6. package/dist/cjs/repositories/indexedDB/schema.js +47 -2
  7. package/dist/cjs/repositories/indexedDB/walletRepository.js +21 -2
  8. package/dist/cjs/repositories/realm/contractRepository.js +0 -4
  9. package/dist/cjs/repositories/realm/index.js +3 -1
  10. package/dist/cjs/repositories/realm/schemas.js +50 -1
  11. package/dist/cjs/repositories/realm/walletRepository.js +8 -4
  12. package/dist/cjs/repositories/scriptFromAddress.js +16 -0
  13. package/dist/cjs/repositories/sqlite/contractRepository.js +2 -6
  14. package/dist/cjs/repositories/sqlite/walletRepository.js +121 -33
  15. package/dist/cjs/utils/syncCursors.js +48 -56
  16. package/dist/cjs/wallet/expo/background.js +0 -13
  17. package/dist/cjs/wallet/expo/wallet.js +1 -6
  18. package/dist/cjs/wallet/serviceWorker/wallet-message-handler.js +16 -7
  19. package/dist/cjs/wallet/serviceWorker/wallet.js +19 -0
  20. package/dist/cjs/wallet/utils.js +41 -10
  21. package/dist/cjs/wallet/vtxo-manager.js +153 -39
  22. package/dist/cjs/wallet/wallet.js +84 -202
  23. package/dist/cjs/worker/expo/processors/contractPollProcessor.js +9 -13
  24. package/dist/cjs/worker/expo/taskRunner.js +2 -11
  25. package/dist/esm/contracts/arkcontract.js +0 -2
  26. package/dist/esm/contracts/contractManager.js +113 -201
  27. package/dist/esm/contracts/contractWatcher.js +86 -115
  28. package/dist/esm/repositories/indexedDB/manager.js +6 -3
  29. package/dist/esm/repositories/indexedDB/schema.js +46 -2
  30. package/dist/esm/repositories/indexedDB/walletRepository.js +21 -2
  31. package/dist/esm/repositories/realm/contractRepository.js +0 -4
  32. package/dist/esm/repositories/realm/index.js +1 -1
  33. package/dist/esm/repositories/realm/schemas.js +48 -0
  34. package/dist/esm/repositories/realm/walletRepository.js +8 -4
  35. package/dist/esm/repositories/scriptFromAddress.js +13 -0
  36. package/dist/esm/repositories/sqlite/contractRepository.js +2 -6
  37. package/dist/esm/repositories/sqlite/walletRepository.js +121 -33
  38. package/dist/esm/utils/syncCursors.js +47 -53
  39. package/dist/esm/wallet/expo/background.js +0 -13
  40. package/dist/esm/wallet/expo/wallet.js +2 -7
  41. package/dist/esm/wallet/serviceWorker/wallet-message-handler.js +17 -8
  42. package/dist/esm/wallet/serviceWorker/wallet.js +19 -0
  43. package/dist/esm/wallet/utils.js +41 -9
  44. package/dist/esm/wallet/vtxo-manager.js +153 -39
  45. package/dist/esm/wallet/wallet.js +87 -205
  46. package/dist/esm/worker/expo/processors/contractPollProcessor.js +9 -13
  47. package/dist/esm/worker/expo/taskRunner.js +3 -12
  48. package/dist/types/contracts/arkcontract.d.ts +0 -2
  49. package/dist/types/contracts/contractManager.d.ts +38 -12
  50. package/dist/types/contracts/contractWatcher.d.ts +22 -21
  51. package/dist/types/contracts/types.d.ts +0 -7
  52. package/dist/types/repositories/indexedDB/manager.d.ts +5 -2
  53. package/dist/types/repositories/indexedDB/schema.d.ts +3 -2
  54. package/dist/types/repositories/realm/index.d.ts +1 -1
  55. package/dist/types/repositories/realm/schemas.d.ts +41 -0
  56. package/dist/types/repositories/scriptFromAddress.d.ts +9 -0
  57. package/dist/types/repositories/serialization.d.ts +1 -1
  58. package/dist/types/repositories/sqlite/walletRepository.d.ts +22 -0
  59. package/dist/types/repositories/walletRepository.d.ts +10 -2
  60. package/dist/types/utils/syncCursors.d.ts +25 -23
  61. package/dist/types/wallet/index.d.ts +1 -1
  62. package/dist/types/wallet/serviceWorker/wallet-message-handler.d.ts +15 -3
  63. package/dist/types/wallet/utils.d.ts +20 -4
  64. package/dist/types/wallet/vtxo-manager.d.ts +16 -6
  65. package/dist/types/wallet/wallet.d.ts +5 -17
  66. package/dist/types/worker/expo/processors/contractPollProcessor.d.ts +9 -4
  67. package/dist/types/worker/expo/taskRunner.d.ts +6 -3
  68. package/package.json +1 -1
@@ -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,11 @@ 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
+ // Hardcoded unilateral exit delay for mainnet (~7 days in seconds).
38
+ // Pinned here so that address derivation stays stable for existing mainnet
39
+ // wallets even after the server lowers the delay it advertises.
40
+ const MAINNET_UNILATERAL_EXIT_DELAY = 605184n;
37
41
  /**
38
42
  * Type guard function to check if an identity has a toReadonly method.
39
43
  */
@@ -129,10 +133,16 @@ export class ReadonlyWallet {
129
133
  throw new Error("invalid exitTimelock");
130
134
  }
131
135
  }
136
+ // On mainnet, pin the unilateral exit delay to the historical value so
137
+ // that addresses derived by existing wallets remain stable even if the
138
+ // server starts advertising a shorter delay.
139
+ const unilateralExitDelay = info.network === "bitcoin"
140
+ ? MAINNET_UNILATERAL_EXIT_DELAY
141
+ : info.unilateralExitDelay;
132
142
  // create unilateral exit timelock
133
143
  const exitTimelock = config.exitTimelock ?? {
134
- value: info.unilateralExitDelay,
135
- type: info.unilateralExitDelay < 512n ? "blocks" : "seconds",
144
+ value: unilateralExitDelay,
145
+ type: unilateralExitDelay < 512n ? "blocks" : "seconds",
136
146
  };
137
147
  // validate boarding timelock passed in config if any
138
148
  if (config.boardingTimelock) {
@@ -287,14 +297,12 @@ export class ReadonlyWallet {
287
297
  * @param filter - Optional flags controlling whether recoverable or unrolled VTXOs are included
288
298
  */
289
299
  async getVtxos(filter) {
290
- const { isDelta, fetchedExtended, address } = await this.syncVtxos();
291
300
  const f = filter ?? { withRecoverable: true, withUnrolled: false };
292
- // For delta syncs, read the full merged set from cache so old
293
- // Virtual outputs that weren't in the delta are still returned.
294
- const vtxos = isDelta
295
- ? await this.walletRepository.getVtxos(address)
296
- : fetchedExtended;
297
- return vtxos.filter((vtxo) => {
301
+ const contractManager = await this.getContractManager();
302
+ const vtxos = await contractManager.getContractsWithVtxos();
303
+ return vtxos
304
+ .flatMap((_) => _.vtxos)
305
+ .filter((vtxo) => {
298
306
  if (isSpendable(vtxo)) {
299
307
  if (!f.withRecoverable &&
300
308
  (isRecoverable(vtxo) || isExpired(vtxo))) {
@@ -309,11 +317,9 @@ export class ReadonlyWallet {
309
317
  * Return wallet transaction history derived from Arkade state and boarding transactions.
310
318
  */
311
319
  async getTransactionHistory() {
312
- // Delta-sync virtual outputs into cache, then build history from the cache.
313
- const { isDelta, fetchedExtended, address } = await this.syncVtxos();
314
- const allVtxos = isDelta
315
- ? await this.walletRepository.getVtxos(address)
316
- : fetchedExtended;
320
+ const contractManager = await this.getContractManager();
321
+ const response = await contractManager.getContractsWithVtxos();
322
+ const allVtxos = response.flatMap((_) => _.vtxos);
317
323
  const { boardingTxs, commitmentsToIgnore } = await this.getBoardingTxs();
318
324
  const getTxCreatedAt = (txid) => this.indexerProvider
319
325
  .getVtxos({ outpoints: [{ txid, vout: 0 }] })
@@ -321,166 +327,11 @@ export class ReadonlyWallet {
321
327
  return buildTransactionHistory(allVtxos, boardingTxs, commitmentsToIgnore, getTxCreatedAt);
322
328
  }
323
329
  /**
324
- * Delta-sync wallet virtual outputs: fetch only changed virtual outputs since the last
325
- * cursor, or do a full bootstrap when no cursor exists. Upserts
326
- * the result into the cache and advances the sync cursors.
327
- *
328
- * Concurrent calls are deduplicated: if a sync is already in flight,
329
- * subsequent callers receive the same promise instead of triggering
330
- * a second network round-trip.
331
- */
332
- syncVtxos() {
333
- if (this._syncVtxosInflight)
334
- return this._syncVtxosInflight;
335
- const p = this.doSyncVtxos().finally(() => {
336
- this._syncVtxosInflight = undefined;
337
- });
338
- this._syncVtxosInflight = p;
339
- return p;
340
- }
341
- async doSyncVtxos() {
342
- const address = await this.getAddress();
343
- // Batch cursor read with script map to avoid extra async hops
344
- // before the fetch (background operations may run between hops).
345
- const [scriptMap, cursors] = await Promise.all([
346
- this.getScriptMap(),
347
- getAllSyncCursors(this.walletRepository),
348
- ]);
349
- const allScripts = [...scriptMap.keys()];
350
- // Partition scripts into bootstrap (no cursor) and delta (has cursor).
351
- const bootstrapScripts = [];
352
- const deltaScripts = [];
353
- for (const s of allScripts) {
354
- if (cursors[s] === undefined) {
355
- bootstrapScripts.push(s);
356
- }
357
- else {
358
- deltaScripts.push(s);
359
- }
360
- }
361
- const requestStartedAt = Date.now();
362
- const allVtxos = [];
363
- const extendWithScript = (vtxo) => {
364
- const vtxoScript = vtxo.script
365
- ? scriptMap.get(vtxo.script)
366
- : undefined;
367
- if (!vtxoScript)
368
- return undefined;
369
- return {
370
- ...vtxo,
371
- forfeitTapLeafScript: vtxoScript.forfeit(),
372
- intentTapLeafScript: vtxoScript.forfeit(),
373
- tapTree: vtxoScript.encode(),
374
- };
375
- };
376
- // Full fetch for scripts with no cursor.
377
- if (bootstrapScripts.length > 0) {
378
- const response = await this.indexerProvider.getVtxos({
379
- scripts: bootstrapScripts,
380
- });
381
- allVtxos.push(...response.vtxos);
382
- }
383
- // Delta fetch for scripts with an existing cursor.
384
- let hasDelta = false;
385
- if (deltaScripts.length > 0) {
386
- const minCursor = Math.min(...deltaScripts.map((s) => cursors[s]));
387
- const window = computeSyncWindow(minCursor);
388
- if (window) {
389
- hasDelta = true;
390
- const response = await this.indexerProvider.getVtxos({
391
- scripts: deltaScripts,
392
- after: window.after,
393
- });
394
- allVtxos.push(...response.vtxos);
395
- }
396
- }
397
- // Extend every fetched virtual output and upsert into the cache.
398
- const fetchedExtended = [];
399
- for (const vtxo of allVtxos) {
400
- const extended = extendWithScript(vtxo);
401
- if (extended)
402
- fetchedExtended.push(extended);
403
- }
404
- // Save virtual outputs first, then advance cursors only on success.
405
- const cutoff = cursorCutoff(requestStartedAt);
406
- await this.walletRepository.saveVtxos(address, fetchedExtended);
407
- await advanceSyncCursors(this.walletRepository, Object.fromEntries(allScripts.map((s) => [s, cutoff])));
408
- // Delta-sync reconciliation: full re-fetch for delta scripts.
409
- //
410
- // The delta fetch (above) only returns virtual outputs changed after the
411
- // cursor, so it can miss preconfirmed virtual outputs that were consumed
412
- // by a round between syncs. Rather than layering targeted
413
- // queries (pendingOnly, spendableOnly) with pagination guards
414
- // and set algebra, we perform a single unfiltered re-fetch for
415
- // delta scripts. This is slightly more data over the wire but
416
- // gives us complete, authoritative state in one call and keeps
417
- // the reconciliation logic simple.
418
- //
419
- // Any cached non-spent virtual output that is absent from the full
420
- // result set is marked spent; any virtual output whose state changed
421
- // (e.g. preconfirmed → settled) is updated in place.
422
- if (hasDelta) {
423
- const { vtxos: fullVtxos, page: fullPage } = await this.indexerProvider.getVtxos({
424
- scripts: deltaScripts,
425
- });
426
- // Reconciliation is best-effort: if the response is
427
- // paginated we don't have a complete picture, so we skip
428
- // rather than act on partial data. Wallets with enough
429
- // virtual outputs to exceed a single page rely solely on the
430
- // cursor-based delta mechanism for state updates.
431
- const fullSetComplete = !fullPage || fullPage.total <= 1;
432
- if (fullSetComplete) {
433
- const fullOutpoints = new Map(fullVtxos.map((v) => [`${v.txid}:${v.vout}`, v]));
434
- const deltaScriptSet = new Set(deltaScripts);
435
- const cachedVtxos = await this.walletRepository.getVtxos(address);
436
- const reconciledExtended = [];
437
- for (const cached of cachedVtxos) {
438
- if (!cached.script ||
439
- !deltaScriptSet.has(cached.script) ||
440
- cached.isSpent) {
441
- continue;
442
- }
443
- const outpoint = `${cached.txid}:${cached.vout}`;
444
- const fresh = fullOutpoints.get(outpoint);
445
- if (!fresh) {
446
- // Server no longer knows about this virtual output —
447
- // it was spent between syncs.
448
- reconciledExtended.push({
449
- ...cached,
450
- isSpent: true,
451
- });
452
- continue;
453
- }
454
- const extended = extendWithScript(fresh);
455
- if (extended &&
456
- extended.virtualStatus.state !==
457
- cached.virtualStatus.state) {
458
- // State transitioned (e.g. preconfirmed →
459
- // settled) — update the cached entry.
460
- reconciledExtended.push(extended);
461
- }
462
- }
463
- if (reconciledExtended.length > 0) {
464
- console.warn(`[ark-sdk] delta sync: reconciled ${reconciledExtended.length} stale VTXO(s) via full re-fetch`);
465
- await this.walletRepository.saveVtxos(address, reconciledExtended);
466
- }
467
- }
468
- else {
469
- console.warn("[ark-sdk] delta sync: skipping reconciliation — full re-fetch was paginated");
470
- }
471
- }
472
- return {
473
- isDelta: hasDelta || bootstrapScripts.length === 0,
474
- fetchedExtended,
475
- address,
476
- };
477
- }
478
- /**
479
- * Clear all virtual output sync cursors, forcing a full re-bootstrap on next sync.
330
+ * Clear the global VTXO sync cursor, forcing a full re-bootstrap on next sync.
480
331
  * Useful for recovery after indexer reprocessing or debugging.
481
332
  */
482
- async clearSyncCursors() {
483
- await clearSyncCursors(this.walletRepository);
333
+ async clearSyncCursor() {
334
+ await clearSyncCursor(this.walletRepository);
484
335
  }
485
336
  /**
486
337
  * Build a transaction history view for the wallet's boarding address.
@@ -523,6 +374,7 @@ export class ReadonlyWallet {
523
374
  createdAt: tx.status.confirmed
524
375
  ? new Date(tx.status.block_time * 1000)
525
376
  : new Date(0),
377
+ script: hex.encode(this.boardingTapscript.pkScript),
526
378
  });
527
379
  }
528
380
  }
@@ -612,22 +464,41 @@ export class ReadonlyWallet {
612
464
  await this.indexerProvider?.unsubscribeForScripts(subscriptionId);
613
465
  };
614
466
  // Handle subscription updates asynchronously without blocking.
615
- // Note: subscription covers all wallet scripts (default + delegate),
616
- // but we can't determine which script each virtual output belongs to from the
617
- // subscription event. Virtual outputs are extended with the current offchainTapscript;
618
- // this is for notification/display only not for spending.
619
- // For correct extension metadata, use getVtxos() which queries per-script.
467
+ // Subscription covers all wallet scripts (default + delegate) plus
468
+ // any additional registered contracts. Virtual outputs carry a
469
+ // `script` field from the indexer which the contract manager
470
+ // resolves to the owning contract so the extension uses the
471
+ // correct forfeit/intent tapscripts.
620
472
  (async () => {
621
473
  try {
474
+ const cm = await this.getContractManager();
622
475
  for await (const update of subscription) {
623
- if (update.newVtxos?.length > 0 ||
624
- update.spentVtxos?.length > 0) {
476
+ if (update.newVtxos?.length === 0 &&
477
+ update.spentVtxos?.length === 0) {
478
+ continue;
479
+ }
480
+ // Isolate per-update annotation failures (e.g. a VTXO
481
+ // arriving for a contract we haven't registered yet).
482
+ // Without this a single bad update would kill the
483
+ // for-await loop and silently drop every subsequent
484
+ // subscription event for the session.
485
+ try {
486
+ // Default to `[]` so a one-sided update (e.g.
487
+ // only `newVtxos`) doesn't pass `undefined` into
488
+ // annotateVtxos and throw on `.length`.
489
+ const [newVtxos, spentVtxos] = await Promise.all([
490
+ cm.annotateVtxos(update.newVtxos ?? []),
491
+ cm.annotateVtxos(update.spentVtxos ?? []),
492
+ ]);
625
493
  eventCallback({
626
494
  type: "vtxo",
627
- newVtxos: update.newVtxos.map((vtxo) => extendVirtualCoin(this, vtxo)),
628
- spentVtxos: update.spentVtxos.map((vtxo) => extendVirtualCoin(this, vtxo)),
495
+ newVtxos,
496
+ spentVtxos,
629
497
  });
630
498
  }
499
+ catch (error) {
500
+ console.warn("Dropping subscription update after annotation failed; next sync will reconcile:", error);
501
+ }
631
502
  }
632
503
  }
633
504
  catch (error) {
@@ -773,7 +644,6 @@ export class ReadonlyWallet {
773
644
  indexerProvider: this.indexerProvider,
774
645
  contractRepository: this.contractRepository,
775
646
  walletRepository: this.walletRepository,
776
- getDefaultAddress: () => this.getAddress(),
777
647
  watcherConfig: this.watcherConfig,
778
648
  });
779
649
  // Register the wallet's current address as a contract
@@ -898,11 +768,10 @@ export class Wallet extends ReadonlyWallet {
898
768
  }
899
769
  });
900
770
  }
901
- constructor(identity, network, networkName, onchainProvider, arkProvider, indexerProvider, arkServerPublicKey, offchainTapscript, boardingTapscript, serverUnrollScript, forfeitOutputScript, forfeitPubkey, dustAmount, walletRepository, contractRepository,
771
+ constructor(identity, network, onchainProvider, arkProvider, indexerProvider, arkServerPublicKey, offchainTapscript, boardingTapscript, serverUnrollScript, forfeitOutputScript, forfeitPubkey, dustAmount, walletRepository, contractRepository,
902
772
  /** @deprecated Use settlementConfig */
903
773
  renewalConfig, delegatorProvider, watcherConfig, settlementConfig) {
904
774
  super(identity, network, onchainProvider, indexerProvider, arkServerPublicKey, offchainTapscript, boardingTapscript, dustAmount, walletRepository, contractRepository, delegatorProvider, watcherConfig);
905
- this.networkName = networkName;
906
775
  this.arkProvider = arkProvider;
907
776
  this.serverUnrollScript = serverUnrollScript;
908
777
  this.forfeitOutputScript = forfeitOutputScript;
@@ -1022,7 +891,7 @@ export class Wallet extends ReadonlyWallet {
1022
891
  const forfeitPubkey = hex.decode(setup.info.forfeitPubkey).slice(1);
1023
892
  const forfeitAddress = Address(setup.network).decode(setup.info.forfeitAddress);
1024
893
  const forfeitOutputScript = OutScript.encode(forfeitAddress);
1025
- 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);
894
+ 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);
1026
895
  await wallet.getVtxoManager();
1027
896
  return wallet;
1028
897
  }
@@ -1285,7 +1154,7 @@ export class Wallet extends ReadonlyWallet {
1285
1154
  const abortController = new AbortController();
1286
1155
  try {
1287
1156
  const stream = this.arkProvider.getEventStream(abortController.signal, topics);
1288
- const intentId = await this.safeRegisterIntent(intent);
1157
+ const intentId = await this.safeRegisterIntent(intent, params.inputs);
1289
1158
  const handler = this.createBatchHandler(intentId, params.inputs, recipients, session);
1290
1159
  const commitmentTxid = await Batch.join(stream, handler, {
1291
1160
  abortController,
@@ -1298,8 +1167,16 @@ export class Wallet extends ReadonlyWallet {
1298
1167
  return commitmentTxid;
1299
1168
  }
1300
1169
  catch (error) {
1301
- // delete the intent to not be stuck in the queue
1302
- await this.arkProvider.deleteIntent(deleteIntent).catch(() => { });
1170
+ // delete the intent to not be stuck in the queue. If deletion fails
1171
+ // the intent stays on the server and the next settle will hit
1172
+ // "duplicated input" in safeRegisterIntent — surface the failure
1173
+ // rather than silently swallowing it.
1174
+ const inputIds = params.inputs
1175
+ .map((i) => `${i.txid}:${i.vout}`)
1176
+ .join(",");
1177
+ await this.arkProvider.deleteIntent(deleteIntent).catch((e) => {
1178
+ 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);
1179
+ });
1303
1180
  throw error;
1304
1181
  }
1305
1182
  finally {
@@ -1489,7 +1366,7 @@ export class Wallet extends ReadonlyWallet {
1489
1366
  },
1490
1367
  };
1491
1368
  }
1492
- async safeRegisterIntent(intent) {
1369
+ async safeRegisterIntent(intent, inputs) {
1493
1370
  try {
1494
1371
  return await this.arkProvider.registerIntent(intent);
1495
1372
  }
@@ -1498,11 +1375,13 @@ export class Wallet extends ReadonlyWallet {
1498
1375
  if (error instanceof ArkError &&
1499
1376
  error.code === 0 &&
1500
1377
  error.message.includes("duplicated input")) {
1501
- // delete all intents spending one of the wallet coins
1502
- const allSpendableCoins = await this.getVtxos({
1503
- withRecoverable: true,
1504
- });
1505
- const deleteIntent = await this.makeDeleteIntentSignature(allSpendableCoins);
1378
+ // Clear any queued intent spending these exact inputs. The
1379
+ // previous implementation signed a proof over getVtxos() only,
1380
+ // which misses boarding UTXOs — the most common trigger for
1381
+ // "duplicated input" on the auto-settle path. Signing the
1382
+ // caller's own inputs keeps the proof surgical and correct
1383
+ // regardless of whether the stuck input is a VTXO or boarding.
1384
+ const deleteIntent = await this.makeDeleteIntentSignature(inputs);
1506
1385
  await this.arkProvider.deleteIntent(deleteIntent);
1507
1386
  // try again
1508
1387
  return this.arkProvider.registerIntent(intent);
@@ -1570,9 +1449,7 @@ export class Wallet extends ReadonlyWallet {
1570
1449
  scripts: allScripts,
1571
1450
  });
1572
1451
  for (const vtxo of fetchedVtxos) {
1573
- const vtxoScript = vtxo.script
1574
- ? scriptMap.get(vtxo.script)
1575
- : undefined;
1452
+ const vtxoScript = scriptMap.get(vtxo.script);
1576
1453
  if (!vtxoScript)
1577
1454
  continue;
1578
1455
  if (vtxo.virtualStatus.state === "swept" ||
@@ -1875,8 +1752,9 @@ export class Wallet extends ReadonlyWallet {
1875
1752
  console.warn(`updateDbAfterOffchainTx: inputs length (${inputs.length}) differs from signedCheckpointTxs length (${signedCheckpointTxs.length})`);
1876
1753
  }
1877
1754
  const safeLength = Math.min(inputs.length, signedCheckpointTxs.length);
1878
- for (const [inputIndex, input] of inputs.entries()) {
1879
- const vtxo = extendVirtualCoin(this, input);
1755
+ const cm = await this.getContractManager();
1756
+ const annotatedInputs = await cm.annotateVtxos(inputs);
1757
+ for (const [inputIndex, vtxo] of annotatedInputs.entries()) {
1880
1758
  if (inputIndex < safeLength &&
1881
1759
  signedCheckpointTxs[inputIndex]) {
1882
1760
  const checkpoint = Transaction.fromPSBT(base64.decode(signedCheckpointTxs[inputIndex]));
@@ -1936,6 +1814,7 @@ export class Wallet extends ReadonlyWallet {
1936
1814
  confirmed: false,
1937
1815
  },
1938
1816
  assets: changeAssets,
1817
+ script: hex.encode(this.offchainTapscript.pkScript),
1939
1818
  };
1940
1819
  }
1941
1820
  await this.walletRepository.saveVtxos(addr, changeVtxo ? [...spentVtxos, changeVtxo] : spentVtxos);
@@ -1966,10 +1845,14 @@ export class Wallet extends ReadonlyWallet {
1966
1845
  const inputArkTxIds = new Set();
1967
1846
  const boardingUtxoToRemove = new Set();
1968
1847
  const isVtxo = (input) => "virtualStatus" in input;
1848
+ const vtxoInputs = inputs.filter(isVtxo);
1849
+ const cm = await this.getContractManager();
1850
+ const annotatedVtxos = await cm.annotateVtxos(vtxoInputs);
1851
+ const annotatedByKey = new Map(annotatedVtxos.map((v) => [`${v.txid}:${v.vout}`, v]));
1969
1852
  for (const input of inputs) {
1970
1853
  if (isVtxo(input)) {
1971
1854
  // virtual output = mark it settled
1972
- const vtxo = extendVirtualCoin(this, input);
1855
+ const vtxo = annotatedByKey.get(`${input.txid}:${input.vout}`);
1973
1856
  if (vtxo.arkTxId) {
1974
1857
  inputArkTxIds.add(vtxo.arkTxId);
1975
1858
  }
@@ -2055,7 +1938,7 @@ export function selectVirtualCoins(coins, targetAmount) {
2055
1938
  */
2056
1939
  export async function waitForIncomingFunds(wallet) {
2057
1940
  let stopFunc;
2058
- const promise = new Promise((resolve) => {
1941
+ return new Promise((resolve) => {
2059
1942
  wallet
2060
1943
  .notifyIncomingFunds((coins) => {
2061
1944
  resolve(coins);
@@ -2066,5 +1949,4 @@ export async function waitForIncomingFunds(wallet) {
2066
1949
  stopFunc = stop;
2067
1950
  });
2068
1951
  });
2069
- return promise;
2070
1952
  }
@@ -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
  }
@@ -74,7 +74,6 @@ export declare function decodeArkContract(encoded: string): ParsedArkContract;
74
74
  export declare function contractFromArkContract(encoded: string, options?: {
75
75
  label?: string;
76
76
  state?: "active" | "inactive";
77
- expiresAt?: number;
78
77
  metadata?: Record<string, unknown>;
79
78
  }): Omit<Contract, "script" | "address"> & {
80
79
  script?: string;
@@ -92,7 +91,6 @@ export declare function contractFromArkContract(encoded: string, options?: {
92
91
  export declare function contractFromArkContractWithAddress(encoded: string, serverPubKey: Uint8Array, addressPrefix: string, options?: {
93
92
  label?: string;
94
93
  state?: "active" | "inactive";
95
- expiresAt?: number;
96
94
  metadata?: Record<string, unknown>;
97
95
  }): Contract;
98
96
  /**