@arkade-os/sdk 0.4.10 → 0.4.11

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.
@@ -286,7 +286,7 @@ export class WalletMessageHandler {
286
286
  }
287
287
  case "REFRESH_VTXOS": {
288
288
  const manager = await this.readonlyWallet.getContractManager();
289
- await manager.refreshVtxos();
289
+ await manager.refreshVtxos(message.payload);
290
290
  return this.tagged({
291
291
  id,
292
292
  type: "REFRESH_VTXOS_SUCCESS",
@@ -531,11 +531,13 @@ export class WalletMessageHandler {
531
531
  // Initialize contract manager FIRST — this populates the repository
532
532
  // with full VTXO history for all contracts (one indexer call per contract)
533
533
  await this.ensureContractEventBroadcasting();
534
- // Read VTXOs from repository (now populated by contract manager)
535
- const vtxos = await this.getVtxosFromRepo();
534
+ // Refresh cached data (VTXOs, boarding UTXOs, tx history)
535
+ await this.refreshCachedData();
536
+ // Recover pending transactions (init-only, not on reload).
537
+ // Pending txs only exist if a send was interrupted mid-finalization.
536
538
  if (this.wallet) {
537
539
  try {
538
- // recover pending transactions if possible
540
+ const vtxos = await this.getVtxosFromRepo();
539
541
  const { pending, finalized } = await this.wallet.finalizePendingTxs(vtxos.filter((vtxo) => vtxo.virtualStatus.state !== "swept" &&
540
542
  vtxo.virtualStatus.state !== "settled"));
541
543
  console.info(`Recovered ${finalized.length}/${pending.length} pending transactions: ${finalized.join(", ")}`);
@@ -544,18 +546,10 @@ export class WalletMessageHandler {
544
546
  console.error("Error recovering pending transactions:", error);
545
547
  }
546
548
  }
547
- // Fetch boarding utxos and save using unified repository
548
- const boardingAddress = await this.readonlyWallet.getBoardingAddress();
549
- const coins = await this.readonlyWallet.onchainProvider.getCoins(boardingAddress);
550
- await this.walletRepository.saveUtxos(boardingAddress, coins.map((utxo) => extendCoin(this.readonlyWallet, utxo)));
551
- // Build transaction history from cached VTXOs (no indexer call)
552
- const address = await this.readonlyWallet.getAddress();
553
- const txs = await this.buildTransactionHistoryFromCache(vtxos);
554
- if (txs)
555
- await this.walletRepository.saveTransactions(address, txs);
556
549
  // unsubscribe previous subscription if any
557
550
  if (this.incomingFundsSubscription)
558
551
  this.incomingFundsSubscription();
552
+ const address = await this.readonlyWallet.getAddress();
559
553
  // subscribe for incoming funds and notify all clients when new funds arrive
560
554
  this.incomingFundsSubscription =
561
555
  await this.readonlyWallet.notifyIncomingFunds(async (funds) => {
@@ -608,15 +602,38 @@ export class WalletMessageHandler {
608
602
  }
609
603
  }
610
604
  /**
611
- * Force a full VTXO refresh from the indexer, then re-run bootstrap.
612
- * Used by RELOAD_WALLET to ensure fresh data.
605
+ * Refresh VTXOs, boarding UTXOs, and transaction history from cache.
606
+ * Shared by onWalletInitialized (full bootstrap) and reloadWallet
607
+ * (post-refresh), avoiding duplicate subscriptions and VtxoManager restarts.
608
+ */
609
+ async refreshCachedData() {
610
+ if (!this.readonlyWallet || !this.walletRepository) {
611
+ return;
612
+ }
613
+ // Read VTXOs from repository (now populated by contract manager)
614
+ const vtxos = await this.getVtxosFromRepo();
615
+ // Fetch boarding utxos and save using unified repository
616
+ const boardingAddress = await this.readonlyWallet.getBoardingAddress();
617
+ const coins = await this.readonlyWallet.onchainProvider.getCoins(boardingAddress);
618
+ await this.walletRepository.deleteUtxos(boardingAddress);
619
+ await this.walletRepository.saveUtxos(boardingAddress, coins.map((utxo) => extendCoin(this.readonlyWallet, utxo)));
620
+ // Build transaction history from cached VTXOs (no indexer call)
621
+ const address = await this.readonlyWallet.getAddress();
622
+ const txs = await this.buildTransactionHistoryFromCache(vtxos);
623
+ if (txs)
624
+ await this.walletRepository.saveTransactions(address, txs);
625
+ }
626
+ /**
627
+ * Force a full VTXO refresh from the indexer, then refresh cached data.
628
+ * Used by RELOAD_WALLET to ensure fresh data without re-subscribing
629
+ * to incoming funds or restarting the VtxoManager.
613
630
  */
614
631
  async reloadWallet() {
615
632
  if (!this.readonlyWallet)
616
633
  return;
617
634
  const manager = await this.readonlyWallet.getContractManager();
618
635
  await manager.refreshVtxos();
619
- await this.onWalletInitialized();
636
+ await this.refreshCachedData();
620
637
  }
621
638
  async handleSettle(message) {
622
639
  const wallet = this.requireWallet();
@@ -791,24 +808,37 @@ export class WalletMessageHandler {
791
808
  vtxoCreatedAt.set(vtxo.txid, ts);
792
809
  }
793
810
  }
794
- // getTxCreatedAt resolves the creation timestamp of a transaction.
795
- // buildTransactionHistory calls this for spent-offchain VTXOs with
796
- // no change outputs to determine the time of the sending tx.
797
- // Returns undefined on miss so buildTransactionHistory uses its
798
- // own fallback (vtxo.createdAt + 1) rather than epoch 0.
799
- // The vout:0 lookup in the indexer fallback mirrors the pre-existing
800
- // convention in ReadonlyWallet.getTransactionHistory().
801
- const getTxCreatedAt = async (txid) => {
802
- const cached = vtxoCreatedAt.get(txid);
803
- if (cached !== undefined)
804
- return cached;
805
- if (this.indexerProvider) {
806
- const res = await this.indexerProvider.getVtxos({
807
- outpoints: [{ txid, vout: 0 }],
808
- });
809
- return res.vtxos[0]?.createdAt.getTime();
811
+ // Pre-fetch uncached timestamps in a single batched indexer call.
812
+ // buildTransactionHistory needs these for spent-offchain VTXOs with
813
+ // no change outputs (i.e. arkTxId is set but no VTXO has txid === arkTxId).
814
+ if (this.indexerProvider) {
815
+ const uncachedTxids = new Set();
816
+ for (const vtxo of vtxos) {
817
+ if (vtxo.isSpent &&
818
+ vtxo.arkTxId &&
819
+ !vtxoCreatedAt.has(vtxo.arkTxId) &&
820
+ !vtxos.some((v) => v.txid === vtxo.arkTxId)) {
821
+ uncachedTxids.add(vtxo.arkTxId);
822
+ }
810
823
  }
811
- return undefined;
824
+ if (uncachedTxids.size > 0) {
825
+ const outpoints = [...uncachedTxids].map((txid) => ({
826
+ txid,
827
+ vout: 0,
828
+ }));
829
+ const BATCH_SIZE = 100;
830
+ for (let i = 0; i < outpoints.length; i += BATCH_SIZE) {
831
+ const res = await this.indexerProvider.getVtxos({
832
+ outpoints: outpoints.slice(i, i + BATCH_SIZE),
833
+ });
834
+ for (const v of res.vtxos) {
835
+ vtxoCreatedAt.set(v.txid, v.createdAt.getTime());
836
+ }
837
+ }
838
+ }
839
+ }
840
+ const getTxCreatedAt = async (txid) => {
841
+ return vtxoCreatedAt.get(txid);
812
842
  };
813
843
  return buildTransactionHistory(vtxos, boardingTxs, commitmentsToIgnore, getTxCreatedAt);
814
844
  }
@@ -682,11 +682,12 @@ export class ServiceWorkerReadonlyWallet {
682
682
  navigator.serviceWorker.removeEventListener("message", messageHandler);
683
683
  };
684
684
  },
685
- async refreshVtxos() {
685
+ async refreshVtxos(opts) {
686
686
  const message = {
687
687
  type: "REFRESH_VTXOS",
688
688
  id: getRandomId(),
689
689
  tag: messageTag,
690
+ payload: opts,
690
691
  };
691
692
  await sendContractMessage(message);
692
693
  },
@@ -397,8 +397,8 @@ export class VtxoManager {
397
397
  * }
398
398
  * ```
399
399
  */
400
- async getExpiredBoardingUtxos() {
401
- const boardingUtxos = await this.wallet.getBoardingUtxos();
400
+ async getExpiredBoardingUtxos(prefetchedUtxos) {
401
+ const boardingUtxos = prefetchedUtxos ?? (await this.wallet.getBoardingUtxos());
402
402
  const boardingTimelock = this.getBoardingTimelock();
403
403
  // For block-based timelocks, fetch the chain tip height
404
404
  let chainTipHeight;
@@ -439,14 +439,14 @@ export class VtxoManager {
439
439
  * }
440
440
  * ```
441
441
  */
442
- async sweepExpiredBoardingUtxos() {
442
+ async sweepExpiredBoardingUtxos(prefetchedUtxos) {
443
443
  const sweepEnabled = this.settlementConfig !== false &&
444
444
  (this.settlementConfig?.boardingUtxoSweep ??
445
445
  DEFAULT_SETTLEMENT_CONFIG.boardingUtxoSweep);
446
446
  if (!sweepEnabled) {
447
447
  throw new Error("Boarding UTXO sweep is not enabled in settlementConfig");
448
448
  }
449
- const allExpired = await this.getExpiredBoardingUtxos();
449
+ const allExpired = await this.getExpiredBoardingUtxos(prefetchedUtxos);
450
450
  // Filter out UTXOs already swept (tx broadcast but not yet confirmed)
451
451
  const expiredUtxos = allExpired.filter((u) => !this.sweptBoardingUtxos.has(`${u.txid}:${u.vout}`));
452
452
  if (expiredUtxos.length === 0) {
@@ -635,16 +635,25 @@ export class VtxoManager {
635
635
  // Guard: wallet must support boarding UTXO + sweep operations
636
636
  if (!isSweepCapable(this.wallet))
637
637
  return;
638
- // Skip if a previous poll is still running
638
+ // Skip if disposed or a previous poll is still running
639
+ if (this.disposed)
640
+ return;
639
641
  if (this.pollInProgress)
640
642
  return;
641
643
  this.pollInProgress = true;
644
+ // Create a promise that dispose() can await
645
+ let resolve;
646
+ const promise = new Promise((r) => (resolve = r));
647
+ this.pollDone = { promise, resolve: resolve };
642
648
  let hadError = false;
643
649
  try {
650
+ // Fetch boarding UTXOs once for the entire poll cycle so that
651
+ // settle and sweep don't each hit the network independently.
652
+ const boardingUtxos = await this.wallet.getBoardingUtxos();
644
653
  // Settle new (unexpired) UTXOs first, then sweep expired ones.
645
654
  // Sequential to avoid racing for the same UTXOs.
646
655
  try {
647
- await this.settleBoardingUtxos();
656
+ await this.settleBoardingUtxos(boardingUtxos);
648
657
  }
649
658
  catch (e) {
650
659
  hadError = true;
@@ -655,7 +664,7 @@ export class VtxoManager {
655
664
  DEFAULT_SETTLEMENT_CONFIG.boardingUtxoSweep);
656
665
  if (sweepEnabled) {
657
666
  try {
658
- await this.sweepExpiredBoardingUtxos();
667
+ await this.sweepExpiredBoardingUtxos(boardingUtxos);
659
668
  }
660
669
  catch (e) {
661
670
  if (!(e instanceof Error) ||
@@ -666,6 +675,10 @@ export class VtxoManager {
666
675
  }
667
676
  }
668
677
  }
678
+ catch (e) {
679
+ hadError = true;
680
+ console.error("Error fetching boarding UTXOs:", e);
681
+ }
669
682
  finally {
670
683
  if (hadError) {
671
684
  this.consecutivePollFailures++;
@@ -674,6 +687,8 @@ export class VtxoManager {
674
687
  this.consecutivePollFailures = 0;
675
688
  }
676
689
  this.pollInProgress = false;
690
+ this.pollDone.resolve();
691
+ this.pollDone = undefined;
677
692
  this.schedulePoll();
678
693
  }
679
694
  }
@@ -684,8 +699,7 @@ export class VtxoManager {
684
699
  * UTXOs are marked as known only after a successful settle, so failed
685
700
  * attempts will be retried on the next poll.
686
701
  */
687
- async settleBoardingUtxos() {
688
- const boardingUtxos = await this.wallet.getBoardingUtxos();
702
+ async settleBoardingUtxos(boardingUtxos) {
689
703
  // Exclude expired UTXOs — those should be swept, not settled.
690
704
  // If we can't determine expired status, bail out entirely to avoid
691
705
  // accidentally settling expired UTXOs (which would conflict with sweep).
@@ -732,6 +746,13 @@ export class VtxoManager {
732
746
  clearTimeout(this.pollTimeoutId);
733
747
  this.pollTimeoutId = undefined;
734
748
  }
749
+ // Wait for any in-flight poll to finish (with timeout to avoid hanging)
750
+ if (this.pollDone) {
751
+ let timer;
752
+ const timeout = new Promise((r) => (timer = setTimeout(r, 30000)));
753
+ await Promise.race([this.pollDone.promise, timeout]);
754
+ clearTimeout(timer);
755
+ }
735
756
  const subscription = await this.contractEventsSubscriptionReady;
736
757
  this.contractEventsSubscription = undefined;
737
758
  subscription?.();
@@ -32,6 +32,7 @@ import { IndexedDBContractRepository, IndexedDBWalletRepository, } from '../repo
32
32
  import { ContractManager } from '../contracts/contractManager.js';
33
33
  import { contractHandlers } from '../contracts/handlers/index.js';
34
34
  import { timelockToSequence } from '../contracts/handlers/helpers.js';
35
+ import { advanceSyncCursors, clearSyncCursors, computeSyncWindow, cursorCutoff, getAllSyncCursors, updateWalletState, } from '../utils/syncCursors.js';
35
36
  /**
36
37
  * Type guard function to check if an identity has a toReadonly method.
37
38
  */
@@ -269,61 +270,158 @@ export class ReadonlyWallet {
269
270
  };
270
271
  }
271
272
  async getVtxos(filter) {
272
- const address = await this.getAddress();
273
- const scriptMap = await this.getScriptMap();
273
+ const { isDelta, fetchedExtended, address } = await this.syncVtxos();
274
274
  const f = filter ?? { withRecoverable: true, withUnrolled: false };
275
- const allExtended = [];
276
- // Batch all scripts into a single indexer call
277
- const allScripts = [...scriptMap.keys()];
278
- const response = await this.indexerProvider.getVtxos({
279
- scripts: allScripts,
280
- });
281
- for (const vtxo of response.vtxos) {
282
- const vtxoScript = vtxo.script
283
- ? scriptMap.get(vtxo.script)
284
- : undefined;
285
- if (!vtxoScript)
286
- continue;
275
+ // For delta syncs, read the full merged set from cache so old
276
+ // VTXOs that weren't in the delta are still returned.
277
+ const vtxos = isDelta
278
+ ? await this.walletRepository.getVtxos(address)
279
+ : fetchedExtended;
280
+ return vtxos.filter((vtxo) => {
287
281
  if (isSpendable(vtxo)) {
288
282
  if (!f.withRecoverable &&
289
283
  (isRecoverable(vtxo) || isExpired(vtxo))) {
290
- continue;
284
+ return false;
291
285
  }
286
+ return true;
287
+ }
288
+ return !!(f.withUnrolled && vtxo.isUnrolled);
289
+ });
290
+ }
291
+ async getTransactionHistory() {
292
+ // Delta-sync VTXOs into cache, then build history from the cache.
293
+ const { isDelta, fetchedExtended, address } = await this.syncVtxos();
294
+ const allVtxos = isDelta
295
+ ? await this.walletRepository.getVtxos(address)
296
+ : fetchedExtended;
297
+ const { boardingTxs, commitmentsToIgnore } = await this.getBoardingTxs();
298
+ const getTxCreatedAt = (txid) => this.indexerProvider
299
+ .getVtxos({ outpoints: [{ txid, vout: 0 }] })
300
+ .then((res) => res.vtxos[0]?.createdAt.getTime());
301
+ return buildTransactionHistory(allVtxos, boardingTxs, commitmentsToIgnore, getTxCreatedAt);
302
+ }
303
+ /**
304
+ * Delta-sync wallet VTXOs: fetch only changed VTXOs since the last
305
+ * cursor, or do a full bootstrap when no cursor exists. Upserts
306
+ * the result into the cache and advances the sync cursors.
307
+ */
308
+ async syncVtxos() {
309
+ const address = await this.getAddress();
310
+ // Batch cursor read with script map to avoid extra async hops
311
+ // before the fetch (background operations may run between hops).
312
+ const [scriptMap, cursors] = await Promise.all([
313
+ this.getScriptMap(),
314
+ getAllSyncCursors(this.walletRepository),
315
+ ]);
316
+ const allScripts = [...scriptMap.keys()];
317
+ // Partition scripts into bootstrap (no cursor) and delta (has cursor).
318
+ const bootstrapScripts = [];
319
+ const deltaScripts = [];
320
+ for (const s of allScripts) {
321
+ if (cursors[s] === undefined) {
322
+ bootstrapScripts.push(s);
292
323
  }
293
324
  else {
294
- if (!f.withUnrolled || !vtxo.isUnrolled)
295
- continue;
325
+ deltaScripts.push(s);
326
+ }
327
+ }
328
+ const requestStartedAt = Date.now();
329
+ const allVtxos = [];
330
+ // Full fetch for scripts with no cursor.
331
+ if (bootstrapScripts.length > 0) {
332
+ const response = await this.indexerProvider.getVtxos({
333
+ scripts: bootstrapScripts,
334
+ });
335
+ allVtxos.push(...response.vtxos);
336
+ }
337
+ // Delta fetch for scripts with an existing cursor.
338
+ let hasDelta = false;
339
+ if (deltaScripts.length > 0) {
340
+ const minCursor = Math.min(...deltaScripts.map((s) => cursors[s]));
341
+ const window = computeSyncWindow(minCursor);
342
+ if (window) {
343
+ hasDelta = true;
344
+ const response = await this.indexerProvider.getVtxos({
345
+ scripts: deltaScripts,
346
+ after: window.after,
347
+ });
348
+ allVtxos.push(...response.vtxos);
296
349
  }
297
- allExtended.push({
350
+ }
351
+ // Extend every fetched VTXO and upsert into the cache.
352
+ const fetchedExtended = [];
353
+ for (const vtxo of allVtxos) {
354
+ const vtxoScript = vtxo.script
355
+ ? scriptMap.get(vtxo.script)
356
+ : undefined;
357
+ if (!vtxoScript)
358
+ continue;
359
+ fetchedExtended.push({
298
360
  ...vtxo,
299
361
  forfeitTapLeafScript: vtxoScript.forfeit(),
300
362
  intentTapLeafScript: vtxoScript.forfeit(),
301
363
  tapTree: vtxoScript.encode(),
302
364
  });
303
365
  }
304
- // Update cache with fresh data
305
- await this.walletRepository.saveVtxos(address, allExtended);
306
- return allExtended;
366
+ // Save VTXOs first, then advance cursors only on success.
367
+ const cutoff = cursorCutoff(requestStartedAt);
368
+ await this.walletRepository.saveVtxos(address, fetchedExtended);
369
+ await advanceSyncCursors(this.walletRepository, Object.fromEntries(allScripts.map((s) => [s, cutoff])));
370
+ // For delta syncs, reconcile pending (preconfirmed/spent) VTXOs
371
+ // whose state may have changed since the cursor so that
372
+ // getVtxos()/getTransactionHistory() don't serve stale state.
373
+ if (hasDelta) {
374
+ const { vtxos: pendingVtxos } = await this.indexerProvider.getVtxos({
375
+ scripts: deltaScripts,
376
+ pendingOnly: true,
377
+ });
378
+ const pendingExtended = [];
379
+ for (const vtxo of pendingVtxos) {
380
+ const vtxoScript = vtxo.script
381
+ ? scriptMap.get(vtxo.script)
382
+ : undefined;
383
+ if (!vtxoScript)
384
+ continue;
385
+ pendingExtended.push({
386
+ ...vtxo,
387
+ forfeitTapLeafScript: vtxoScript.forfeit(),
388
+ intentTapLeafScript: vtxoScript.forfeit(),
389
+ tapTree: vtxoScript.encode(),
390
+ });
391
+ }
392
+ if (pendingExtended.length > 0) {
393
+ await this.walletRepository.saveVtxos(address, pendingExtended);
394
+ }
395
+ }
396
+ return {
397
+ isDelta: hasDelta || bootstrapScripts.length === 0,
398
+ fetchedExtended,
399
+ address,
400
+ };
307
401
  }
308
- async getTransactionHistory() {
309
- const scripts = await this.getWalletScripts();
310
- const response = await this.indexerProvider.getVtxos({ scripts });
311
- const { boardingTxs, commitmentsToIgnore } = await this.getBoardingTxs();
312
- const getTxCreatedAt = (txid) => this.indexerProvider
313
- .getVtxos({ outpoints: [{ txid, vout: 0 }] })
314
- .then((res) => res.vtxos[0]?.createdAt.getTime());
315
- return buildTransactionHistory(response.vtxos, boardingTxs, commitmentsToIgnore, getTxCreatedAt);
402
+ /**
403
+ * Clear all VTXO sync cursors, forcing a full re-bootstrap on next sync.
404
+ * Useful for recovery after indexer reprocessing or debugging.
405
+ */
406
+ async clearSyncCursors() {
407
+ await clearSyncCursors(this.walletRepository);
316
408
  }
317
409
  async getBoardingTxs() {
318
410
  const utxos = [];
319
411
  const commitmentsToIgnore = new Set();
320
412
  const boardingAddress = await this.getBoardingAddress();
321
413
  const txs = await this.onchainProvider.getTransactions(boardingAddress);
414
+ const outspendCache = new Map();
322
415
  for (const tx of txs) {
323
416
  for (let i = 0; i < tx.vout.length; i++) {
324
417
  const vout = tx.vout[i];
325
418
  if (vout.scriptpubkey_address === boardingAddress) {
326
- const spentStatuses = await this.onchainProvider.getTxOutspends(tx.txid);
419
+ let spentStatuses = outspendCache.get(tx.txid);
420
+ if (!spentStatuses) {
421
+ spentStatuses =
422
+ await this.onchainProvider.getTxOutspends(tx.txid);
423
+ outspendCache.set(tx.txid, spentStatuses);
424
+ }
327
425
  const spentStatus = spentStatuses[i];
328
426
  if (spentStatus?.spent) {
329
427
  commitmentsToIgnore.add(spentStatus.txid);
@@ -1338,10 +1436,15 @@ export class Wallet extends ReadonlyWallet {
1338
1436
  }
1339
1437
  /**
1340
1438
  * Finalizes pending transactions by retrieving them from the server and finalizing each one.
1439
+ * Skips the server check entirely when no send was interrupted (no pending tx flag set).
1341
1440
  * @param vtxos - Optional list of VTXOs to use instead of retrieving them from the server
1342
1441
  * @returns Array of transaction IDs that were finalized
1343
1442
  */
1344
1443
  async finalizePendingTxs(vtxos) {
1444
+ const hasPending = await this.hasPendingTxFlag();
1445
+ if (!hasPending) {
1446
+ return { finalized: [], pending: [] };
1447
+ }
1345
1448
  const MAX_INPUTS_PER_INTENT = 20;
1346
1449
  if (!vtxos || vtxos.length === 0) {
1347
1450
  // Batch all scripts into a single indexer call
@@ -1373,33 +1476,63 @@ export class Wallet extends ReadonlyWallet {
1373
1476
  }
1374
1477
  vtxos = allExtended;
1375
1478
  }
1376
- const finalized = [];
1377
- const pending = [];
1479
+ const batches = [];
1378
1480
  for (let i = 0; i < vtxos.length; i += MAX_INPUTS_PER_INTENT) {
1379
- const batch = vtxos.slice(i, i + MAX_INPUTS_PER_INTENT);
1481
+ batches.push(vtxos.slice(i, i + MAX_INPUTS_PER_INTENT));
1482
+ }
1483
+ // Track seen arkTxids so parallel batches don't finalize the same tx twice
1484
+ const seen = new Set();
1485
+ const results = await Promise.all(batches.map(async (batch) => {
1486
+ const batchFinalized = [];
1487
+ const batchPending = [];
1380
1488
  const intent = await this.makeGetPendingTxIntentSignature(batch);
1381
1489
  const pendingTxs = await this.arkProvider.getPendingTxs(intent);
1382
- // finalize each transaction by signing the checkpoints
1383
1490
  for (const pendingTx of pendingTxs) {
1384
- pending.push(pendingTx.arkTxid);
1491
+ if (seen.has(pendingTx.arkTxid))
1492
+ continue;
1493
+ seen.add(pendingTx.arkTxid);
1494
+ batchPending.push(pendingTx.arkTxid);
1385
1495
  try {
1386
- // sign the checkpoints
1387
1496
  const finalCheckpoints = await Promise.all(pendingTx.signedCheckpointTxs.map(async (c) => {
1388
1497
  const tx = Transaction.fromPSBT(base64.decode(c));
1389
1498
  const signedCheckpoint = await this.identity.sign(tx);
1390
1499
  return base64.encode(signedCheckpoint.toPSBT());
1391
1500
  }));
1392
1501
  await this.arkProvider.finalizeTx(pendingTx.arkTxid, finalCheckpoints);
1393
- finalized.push(pendingTx.arkTxid);
1502
+ batchFinalized.push(pendingTx.arkTxid);
1394
1503
  }
1395
1504
  catch (error) {
1396
1505
  console.error(`Failed to finalize transaction ${pendingTx.arkTxid}:`, error);
1397
- // continue with other transactions even if one fails
1398
1506
  }
1399
1507
  }
1508
+ return {
1509
+ finalized: batchFinalized,
1510
+ pending: batchPending,
1511
+ };
1512
+ }));
1513
+ const finalized = [];
1514
+ const pending = [];
1515
+ for (const result of results) {
1516
+ finalized.push(...result.finalized);
1517
+ pending.push(...result.pending);
1518
+ }
1519
+ // Only clear the flag if every discovered pending tx was finalized;
1520
+ // if any failed, keep it so recovery retries on next startup.
1521
+ if (finalized.length === pending.length) {
1522
+ await this.setPendingTxFlag(false);
1400
1523
  }
1401
1524
  return { finalized, pending };
1402
1525
  }
1526
+ async hasPendingTxFlag() {
1527
+ const state = await this.walletRepository.getWalletState();
1528
+ return state?.settings?.hasPendingTx === true;
1529
+ }
1530
+ async setPendingTxFlag(value) {
1531
+ await updateWalletState(this.walletRepository, (state) => ({
1532
+ ...state,
1533
+ settings: { ...state.settings, hasPendingTx: value },
1534
+ }));
1535
+ }
1403
1536
  /**
1404
1537
  * Send BTC and/or assets to one or more recipients.
1405
1538
  *
@@ -1568,6 +1701,9 @@ export class Wallet extends ReadonlyWallet {
1568
1701
  };
1569
1702
  }), outputs, this.serverUnrollScript);
1570
1703
  const signedVirtualTx = await this.identity.sign(offchainTx.arkTx);
1704
+ // Mark pending before submitting — if we crash between submit and
1705
+ // finalize, the next init will recover via finalizePendingTxs.
1706
+ await this.setPendingTxFlag(true);
1571
1707
  const { arkTxid, signedCheckpointTxs } = await this.arkProvider.submitTx(base64.encode(signedVirtualTx.toPSBT()), offchainTx.checkpoints.map((c) => base64.encode(c.toPSBT())));
1572
1708
  const finalCheckpoints = await Promise.all(signedCheckpointTxs.map(async (c) => {
1573
1709
  const tx = Transaction.fromPSBT(base64.decode(c));
@@ -1575,6 +1711,12 @@ export class Wallet extends ReadonlyWallet {
1575
1711
  return base64.encode(signedCheckpoint.toPSBT());
1576
1712
  }));
1577
1713
  await this.arkProvider.finalizeTx(arkTxid, finalCheckpoints);
1714
+ try {
1715
+ await this.setPendingTxFlag(false);
1716
+ }
1717
+ catch (error) {
1718
+ console.error("Failed to clear pending tx flag:", error);
1719
+ }
1578
1720
  return { arkTxid, signedCheckpointTxs };
1579
1721
  }
1580
1722
  // mark vtxo spent and save change vtxo if any
@@ -43,6 +43,11 @@ import { ContractRepository } from "../repositories";
43
43
  * manager.dispose();
44
44
  * ```
45
45
  */
46
+ export type RefreshVtxosOptions = {
47
+ scripts?: string[];
48
+ after?: number;
49
+ before?: number;
50
+ };
46
51
  export interface IContractManager extends Disposable {
47
52
  /**
48
53
  * Create and register a new contract.
@@ -103,10 +108,12 @@ export interface IContractManager extends Disposable {
103
108
  */
104
109
  onContractEvent(callback: ContractEventCallback): () => void;
105
110
  /**
106
- * Force a full VTXO refresh from the indexer for all contracts.
107
- * Populates the wallet repository with complete VTXO history.
111
+ * Force a VTXO refresh from the indexer.
112
+ *
113
+ * Without options, refreshes all contracts from scratch.
114
+ * With options, narrows the refresh to specific scripts and/or a time window.
108
115
  */
109
- refreshVtxos(): Promise<void>;
116
+ refreshVtxos(opts?: RefreshVtxosOptions): Promise<void>;
110
117
  /**
111
118
  * Whether the underlying watcher is currently active.
112
119
  */
@@ -240,7 +247,7 @@ export declare class ContractManager implements IContractManager {
240
247
  * ```
241
248
  */
242
249
  getContracts(filter?: GetContractsFilter): Promise<Contract[]>;
243
- getContractsWithVtxos(filter?: GetContractsFilter): Promise<ContractWithVtxos[]>;
250
+ getContractsWithVtxos(filter?: GetContractsFilter, pageSize?: number): Promise<ContractWithVtxos[]>;
244
251
  private buildContractsDbFilter;
245
252
  /**
246
253
  * Update a contract.
@@ -298,10 +305,12 @@ export declare class ContractManager implements IContractManager {
298
305
  */
299
306
  onContractEvent(callback: ContractEventCallback): () => void;
300
307
  /**
301
- * Force a full VTXO refresh from the indexer for all contracts.
302
- * Populates the wallet repository with complete VTXO history.
308
+ * Force a VTXO refresh from the indexer.
309
+ *
310
+ * Without options, clears all sync cursors and re-fetches every contract.
311
+ * With options, narrows the refresh to specific scripts and/or a time window.
303
312
  */
304
- refreshVtxos(): Promise<void>;
313
+ refreshVtxos(opts?: RefreshVtxosOptions): Promise<void>;
305
314
  /**
306
315
  * Check if currently watching.
307
316
  */
@@ -315,6 +324,18 @@ export declare class ContractManager implements IContractManager {
315
324
  */
316
325
  private handleContractEvent;
317
326
  private getVtxosForContracts;
327
+ /**
328
+ * Incrementally sync VTXOs for the given contracts.
329
+ * Uses per-script cursors to fetch only what changed since the last sync.
330
+ * Scripts without a cursor are bootstrapped with a full fetch.
331
+ */
332
+ private deltaSyncContracts;
333
+ /**
334
+ * Fetch all pending (not-yet-finalized) VTXOs and upsert them into the
335
+ * repository. This catches VTXOs whose state changed outside the delta
336
+ * window (e.g. a spend that hasn't settled yet).
337
+ */
338
+ private reconcilePendingFrontier;
318
339
  private fetchContractVxosFromIndexer;
319
340
  private fetchContractVtxosBulk;
320
341
  private fetchContractVtxosPaginated;
@@ -11,4 +11,4 @@ export type { ParsedArkContract } from "./arkcontract";
11
11
  export { ContractWatcher } from "./contractWatcher";
12
12
  export type { ContractWatcherConfig } from "./contractWatcher";
13
13
  export { ContractManager } from "./contractManager";
14
- export type { ContractManagerConfig, CreateContractParams, } from "./contractManager";
14
+ export type { ContractManagerConfig, CreateContractParams, RefreshVtxosOptions, } from "./contractManager";