@arkade-os/sdk 0.4.17 → 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 -215
  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 +72 -195
  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 -217
  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 +75 -198
  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 -9
  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,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.
@@ -299,8 +299,10 @@ export class ReadonlyWallet {
299
299
  async getVtxos(filter) {
300
300
  const f = filter ?? { withRecoverable: true, withUnrolled: false };
301
301
  const contractManager = await this.getContractManager();
302
- const contractsWithVtxos = await contractManager.getContractsWithVtxos();
303
- return contractsWithVtxos.flatMap(({ vtxos }) => vtxos.filter((vtxo) => {
302
+ const vtxos = await contractManager.getContractsWithVtxos();
303
+ return vtxos
304
+ .flatMap((_) => _.vtxos)
305
+ .filter((vtxo) => {
304
306
  if (isSpendable(vtxo)) {
305
307
  if (!f.withRecoverable &&
306
308
  (isRecoverable(vtxo) || isExpired(vtxo))) {
@@ -309,17 +311,15 @@ export class ReadonlyWallet {
309
311
  return true;
310
312
  }
311
313
  return !!(f.withUnrolled && vtxo.isUnrolled);
312
- }));
314
+ });
313
315
  }
314
316
  /**
315
317
  * Return wallet transaction history derived from Arkade state and boarding transactions.
316
318
  */
317
319
  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;
320
+ const contractManager = await this.getContractManager();
321
+ const response = await contractManager.getContractsWithVtxos();
322
+ const allVtxos = response.flatMap((_) => _.vtxos);
323
323
  const { boardingTxs, commitmentsToIgnore } = await this.getBoardingTxs();
324
324
  const getTxCreatedAt = (txid) => this.indexerProvider
325
325
  .getVtxos({ outpoints: [{ txid, vout: 0 }] })
@@ -327,166 +327,11 @@ export class ReadonlyWallet {
327
327
  return buildTransactionHistory(allVtxos, boardingTxs, commitmentsToIgnore, getTxCreatedAt);
328
328
  }
329
329
  /**
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.
330
+ * Clear the global VTXO sync cursor, forcing a full re-bootstrap on next sync.
486
331
  * Useful for recovery after indexer reprocessing or debugging.
487
332
  */
488
- async clearSyncCursors() {
489
- await clearSyncCursors(this.walletRepository);
333
+ async clearSyncCursor() {
334
+ await clearSyncCursor(this.walletRepository);
490
335
  }
491
336
  /**
492
337
  * Build a transaction history view for the wallet's boarding address.
@@ -529,6 +374,7 @@ export class ReadonlyWallet {
529
374
  createdAt: tx.status.confirmed
530
375
  ? new Date(tx.status.block_time * 1000)
531
376
  : new Date(0),
377
+ script: hex.encode(this.boardingTapscript.pkScript),
532
378
  });
533
379
  }
534
380
  }
@@ -618,22 +464,41 @@ export class ReadonlyWallet {
618
464
  await this.indexerProvider?.unsubscribeForScripts(subscriptionId);
619
465
  };
620
466
  // 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.
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.
626
472
  (async () => {
627
473
  try {
474
+ const cm = await this.getContractManager();
628
475
  for await (const update of subscription) {
629
- if (update.newVtxos?.length > 0 ||
630
- 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
+ ]);
631
493
  eventCallback({
632
494
  type: "vtxo",
633
- newVtxos: update.newVtxos.map((vtxo) => extendVirtualCoin(this, vtxo)),
634
- spentVtxos: update.spentVtxos.map((vtxo) => extendVirtualCoin(this, vtxo)),
495
+ newVtxos,
496
+ spentVtxos,
635
497
  });
636
498
  }
499
+ catch (error) {
500
+ console.warn("Dropping subscription update after annotation failed; next sync will reconcile:", error);
501
+ }
637
502
  }
638
503
  }
639
504
  catch (error) {
@@ -903,11 +768,10 @@ export class Wallet extends ReadonlyWallet {
903
768
  }
904
769
  });
905
770
  }
906
- 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,
907
772
  /** @deprecated Use settlementConfig */
908
773
  renewalConfig, delegatorProvider, watcherConfig, settlementConfig) {
909
774
  super(identity, network, onchainProvider, indexerProvider, arkServerPublicKey, offchainTapscript, boardingTapscript, dustAmount, walletRepository, contractRepository, delegatorProvider, watcherConfig);
910
- this.networkName = networkName;
911
775
  this.arkProvider = arkProvider;
912
776
  this.serverUnrollScript = serverUnrollScript;
913
777
  this.forfeitOutputScript = forfeitOutputScript;
@@ -1027,7 +891,7 @@ export class Wallet extends ReadonlyWallet {
1027
891
  const forfeitPubkey = hex.decode(setup.info.forfeitPubkey).slice(1);
1028
892
  const forfeitAddress = Address(setup.network).decode(setup.info.forfeitAddress);
1029
893
  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);
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);
1031
895
  await wallet.getVtxoManager();
1032
896
  return wallet;
1033
897
  }
@@ -1290,7 +1154,7 @@ export class Wallet extends ReadonlyWallet {
1290
1154
  const abortController = new AbortController();
1291
1155
  try {
1292
1156
  const stream = this.arkProvider.getEventStream(abortController.signal, topics);
1293
- const intentId = await this.safeRegisterIntent(intent);
1157
+ const intentId = await this.safeRegisterIntent(intent, params.inputs);
1294
1158
  const handler = this.createBatchHandler(intentId, params.inputs, recipients, session);
1295
1159
  const commitmentTxid = await Batch.join(stream, handler, {
1296
1160
  abortController,
@@ -1303,8 +1167,16 @@ export class Wallet extends ReadonlyWallet {
1303
1167
  return commitmentTxid;
1304
1168
  }
1305
1169
  catch (error) {
1306
- // delete the intent to not be stuck in the queue
1307
- 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
+ });
1308
1180
  throw error;
1309
1181
  }
1310
1182
  finally {
@@ -1494,7 +1366,7 @@ export class Wallet extends ReadonlyWallet {
1494
1366
  },
1495
1367
  };
1496
1368
  }
1497
- async safeRegisterIntent(intent) {
1369
+ async safeRegisterIntent(intent, inputs) {
1498
1370
  try {
1499
1371
  return await this.arkProvider.registerIntent(intent);
1500
1372
  }
@@ -1503,11 +1375,13 @@ export class Wallet extends ReadonlyWallet {
1503
1375
  if (error instanceof ArkError &&
1504
1376
  error.code === 0 &&
1505
1377
  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);
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);
1511
1385
  await this.arkProvider.deleteIntent(deleteIntent);
1512
1386
  // try again
1513
1387
  return this.arkProvider.registerIntent(intent);
@@ -1575,9 +1449,7 @@ export class Wallet extends ReadonlyWallet {
1575
1449
  scripts: allScripts,
1576
1450
  });
1577
1451
  for (const vtxo of fetchedVtxos) {
1578
- const vtxoScript = vtxo.script
1579
- ? scriptMap.get(vtxo.script)
1580
- : undefined;
1452
+ const vtxoScript = scriptMap.get(vtxo.script);
1581
1453
  if (!vtxoScript)
1582
1454
  continue;
1583
1455
  if (vtxo.virtualStatus.state === "swept" ||
@@ -1880,8 +1752,9 @@ export class Wallet extends ReadonlyWallet {
1880
1752
  console.warn(`updateDbAfterOffchainTx: inputs length (${inputs.length}) differs from signedCheckpointTxs length (${signedCheckpointTxs.length})`);
1881
1753
  }
1882
1754
  const safeLength = Math.min(inputs.length, signedCheckpointTxs.length);
1883
- for (const [inputIndex, input] of inputs.entries()) {
1884
- 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()) {
1885
1758
  if (inputIndex < safeLength &&
1886
1759
  signedCheckpointTxs[inputIndex]) {
1887
1760
  const checkpoint = Transaction.fromPSBT(base64.decode(signedCheckpointTxs[inputIndex]));
@@ -1941,6 +1814,7 @@ export class Wallet extends ReadonlyWallet {
1941
1814
  confirmed: false,
1942
1815
  },
1943
1816
  assets: changeAssets,
1817
+ script: hex.encode(this.offchainTapscript.pkScript),
1944
1818
  };
1945
1819
  }
1946
1820
  await this.walletRepository.saveVtxos(addr, changeVtxo ? [...spentVtxos, changeVtxo] : spentVtxos);
@@ -1971,10 +1845,14 @@ export class Wallet extends ReadonlyWallet {
1971
1845
  const inputArkTxIds = new Set();
1972
1846
  const boardingUtxoToRemove = new Set();
1973
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]));
1974
1852
  for (const input of inputs) {
1975
1853
  if (isVtxo(input)) {
1976
1854
  // virtual output = mark it settled
1977
- const vtxo = extendVirtualCoin(this, input);
1855
+ const vtxo = annotatedByKey.get(`${input.txid}:${input.vout}`);
1978
1856
  if (vtxo.arkTxId) {
1979
1857
  inputArkTxIds.add(vtxo.arkTxId);
1980
1858
  }
@@ -2060,7 +1938,7 @@ export function selectVirtualCoins(coins, targetAmount) {
2060
1938
  */
2061
1939
  export async function waitForIncomingFunds(wallet) {
2062
1940
  let stopFunc;
2063
- const promise = new Promise((resolve) => {
1941
+ return new Promise((resolve) => {
2064
1942
  wallet
2065
1943
  .notifyIncomingFunds((coins) => {
2066
1944
  resolve(coins);
@@ -2071,5 +1949,4 @@ export async function waitForIncomingFunds(wallet) {
2071
1949
  stopFunc = stop;
2072
1950
  });
2073
1951
  });
2074
- return promise;
2075
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
  /**
@@ -2,7 +2,7 @@ import { IndexerProvider } from "../providers/indexer";
2
2
  import { WalletRepository } from "../repositories/walletRepository";
3
3
  import { Contract, ContractEventCallback, ContractState, ContractWithVtxos, GetContractsFilter, PathSelection } from "./types";
4
4
  import { ContractWatcherConfig } from "./contractWatcher";
5
- import { VirtualCoin } from "../wallet";
5
+ import { ExtendedVirtualCoin, VirtualCoin } from "../wallet";
6
6
  import { ContractRepository } from "../repositories";
7
7
  export type RefreshVtxosOptions = {
8
8
  scripts?: string[];
@@ -36,6 +36,19 @@ export interface IContractManager extends Disposable {
36
36
  * If no filter is provided, returns all contracts with their virtual outputs.
37
37
  */
38
38
  getContractsWithVtxos(filter?: GetContractsFilter): Promise<ContractWithVtxos[]>;
39
+ /**
40
+ * Stamp raw virtual outputs with the correct per-contract tapscripts
41
+ * (forfeit, intent, tap tree).
42
+ *
43
+ * Resolves each vtxo's `script` to its owning contract via the contract
44
+ * repository and attaches the matching tapscripts. Throws when any vtxo
45
+ * references a script with no registered contract — callers are expected
46
+ * to register the contract before asking for annotation. This is the
47
+ * single shared path that replaces scattered `extendVirtualCoin*` calls
48
+ * in wallet/handler code, and keeps the wallet from silently stamping the
49
+ * default tapscript onto a non-default vtxo.
50
+ */
51
+ annotateVtxos(vtxos: VirtualCoin[]): Promise<ExtendedVirtualCoin[]>;
39
52
  /**
40
53
  * Update mutable contract fields.
41
54
  *
@@ -135,7 +148,7 @@ export type CreateContractParams = Omit<Contract, "createdAt" | "state"> & {
135
148
  * - Create and persist contracts
136
149
  * - Query stored contracts (optionally with their virtual outputs)
137
150
  * - Provide spendable path selection for a contract
138
- * - Emit contract-related events (virtual output received/spent/expired, connection reset)
151
+ * - Emit contract-related events (virtual output received/spent, connection reset)
139
152
  *
140
153
  * Notes:
141
154
  * - Implementations typically start watching automatically during initialization
@@ -181,7 +194,6 @@ export declare class ContractManager implements IContractManager {
181
194
  private initialized;
182
195
  private eventCallbacks;
183
196
  private stopWatcherFn?;
184
- private syncVtxosCallInflight?;
185
197
  private constructor();
186
198
  /**
187
199
  * Static factory method for creating a new ContractManager.
@@ -194,6 +206,17 @@ export declare class ContractManager implements IContractManager {
194
206
  */
195
207
  static create(config: ContractManagerConfig): Promise<ContractManager>;
196
208
  private initialize;
209
+ /**
210
+ * Delta-sync the full watched set and reconcile the pending frontier.
211
+ *
212
+ * Shared recovery path used on initial boot and after a subscription
213
+ * reconnect. `syncContracts({})` scopes to the current watched set
214
+ * (see {@link ContractWatcher.getWatchedContracts}), uses the
215
+ * cursor-derived delta window, and advances the cursor on success.
216
+ * `reconcilePendingFrontier` catches not-yet-finalized virtual
217
+ * outputs that could sit outside any delta window.
218
+ */
219
+ private reconcileWatched;
197
220
  /**
198
221
  * Create and register a new contract.
199
222
  *
@@ -218,6 +241,7 @@ export declare class ContractManager implements IContractManager {
218
241
  */
219
242
  getContracts(filter?: GetContractsFilter): Promise<Contract[]>;
220
243
  getContractsWithVtxos(filter?: GetContractsFilter, pageSize?: number): Promise<ContractWithVtxos[]>;
244
+ annotateVtxos(vtxos: VirtualCoin[]): Promise<ExtendedVirtualCoin[]>;
221
245
  private buildContractsDbFilter;
222
246
  /**
223
247
  * Update a contract.
@@ -281,8 +305,9 @@ export declare class ContractManager implements IContractManager {
281
305
  /**
282
306
  * Force refresh virtual outputs from the indexer.
283
307
  *
284
- * Without options, clears all sync cursors and re-fetches every contract.
308
+ * Without options, re-fetches every contract and advances the global cursor.
285
309
  * With options, narrows the refresh to specific scripts and/or a time window.
310
+ * Subset refreshes (scripts filter) intentionally do not advance the cursor.
286
311
  */
287
312
  refreshVtxos(opts?: RefreshVtxosOptions): Promise<void>;
288
313
  /**
@@ -299,11 +324,16 @@ export declare class ContractManager implements IContractManager {
299
324
  private handleContractEvent;
300
325
  private getVtxosForContracts;
301
326
  /**
302
- * Incrementally sync virtual outputs for the given contracts.
303
- * Uses per-script cursors to fetch only what changed since the last sync.
304
- * Scripts without a cursor are bootstrapped with a full fetch.
327
+ * Sync virtual outputs for the given contracts against the indexer.
328
+ *
329
+ * When `options.contracts` is omitted the sync covers the full
330
+ * watched set (active contracts plus any inactive contracts still
331
+ * holding cached VTXOs) and the global cursor is advanced on
332
+ * success. Passing an explicit subset leaves the cursor alone so a
333
+ * narrow poll can't hide data that other contracts still need to
334
+ * pick up.
305
335
  */
306
- private deltaSyncContracts;
336
+ private syncContracts;
307
337
  /**
308
338
  * Fetch all pending (unfinalized) virtual outputs and upsert them into the
309
339
  * repository. This catches virtual outputs whose state changed outside the delta
@@ -312,7 +342,6 @@ export declare class ContractManager implements IContractManager {
312
342
  private reconcilePendingFrontier;
313
343
  private fetchContractVxosFromIndexer;
314
344
  private fetchContractVtxosBulk;
315
- private fetchContractVtxosPaginated;
316
345
  /**
317
346
  * Dispose of the ContractManager and release all resources.
318
347
  *