@arkade-os/sdk 0.4.10 → 0.4.12

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,171 @@ 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
+ * Concurrent calls are deduplicated: if a sync is already in flight,
309
+ * subsequent callers receive the same promise instead of triggering
310
+ * a second network round-trip.
311
+ */
312
+ syncVtxos() {
313
+ if (this._syncVtxosInflight)
314
+ return this._syncVtxosInflight;
315
+ const p = this.doSyncVtxos().finally(() => {
316
+ this._syncVtxosInflight = undefined;
317
+ });
318
+ this._syncVtxosInflight = p;
319
+ return p;
320
+ }
321
+ async doSyncVtxos() {
322
+ const address = await this.getAddress();
323
+ // Batch cursor read with script map to avoid extra async hops
324
+ // before the fetch (background operations may run between hops).
325
+ const [scriptMap, cursors] = await Promise.all([
326
+ this.getScriptMap(),
327
+ getAllSyncCursors(this.walletRepository),
328
+ ]);
329
+ const allScripts = [...scriptMap.keys()];
330
+ // Partition scripts into bootstrap (no cursor) and delta (has cursor).
331
+ const bootstrapScripts = [];
332
+ const deltaScripts = [];
333
+ for (const s of allScripts) {
334
+ if (cursors[s] === undefined) {
335
+ bootstrapScripts.push(s);
292
336
  }
293
337
  else {
294
- if (!f.withUnrolled || !vtxo.isUnrolled)
295
- continue;
338
+ deltaScripts.push(s);
339
+ }
340
+ }
341
+ const requestStartedAt = Date.now();
342
+ const allVtxos = [];
343
+ // Full fetch for scripts with no cursor.
344
+ if (bootstrapScripts.length > 0) {
345
+ const response = await this.indexerProvider.getVtxos({
346
+ scripts: bootstrapScripts,
347
+ });
348
+ allVtxos.push(...response.vtxos);
349
+ }
350
+ // Delta fetch for scripts with an existing cursor.
351
+ let hasDelta = false;
352
+ if (deltaScripts.length > 0) {
353
+ const minCursor = Math.min(...deltaScripts.map((s) => cursors[s]));
354
+ const window = computeSyncWindow(minCursor);
355
+ if (window) {
356
+ hasDelta = true;
357
+ const response = await this.indexerProvider.getVtxos({
358
+ scripts: deltaScripts,
359
+ after: window.after,
360
+ });
361
+ allVtxos.push(...response.vtxos);
296
362
  }
297
- allExtended.push({
363
+ }
364
+ // Extend every fetched VTXO and upsert into the cache.
365
+ const fetchedExtended = [];
366
+ for (const vtxo of allVtxos) {
367
+ const vtxoScript = vtxo.script
368
+ ? scriptMap.get(vtxo.script)
369
+ : undefined;
370
+ if (!vtxoScript)
371
+ continue;
372
+ fetchedExtended.push({
298
373
  ...vtxo,
299
374
  forfeitTapLeafScript: vtxoScript.forfeit(),
300
375
  intentTapLeafScript: vtxoScript.forfeit(),
301
376
  tapTree: vtxoScript.encode(),
302
377
  });
303
378
  }
304
- // Update cache with fresh data
305
- await this.walletRepository.saveVtxos(address, allExtended);
306
- return allExtended;
379
+ // Save VTXOs first, then advance cursors only on success.
380
+ const cutoff = cursorCutoff(requestStartedAt);
381
+ await this.walletRepository.saveVtxos(address, fetchedExtended);
382
+ await advanceSyncCursors(this.walletRepository, Object.fromEntries(allScripts.map((s) => [s, cutoff])));
383
+ // For delta syncs, reconcile pending (preconfirmed/spent) VTXOs
384
+ // whose state may have changed since the cursor so that
385
+ // getVtxos()/getTransactionHistory() don't serve stale state.
386
+ if (hasDelta) {
387
+ const { vtxos: pendingVtxos } = await this.indexerProvider.getVtxos({
388
+ scripts: deltaScripts,
389
+ pendingOnly: true,
390
+ });
391
+ const pendingExtended = [];
392
+ for (const vtxo of pendingVtxos) {
393
+ const vtxoScript = vtxo.script
394
+ ? scriptMap.get(vtxo.script)
395
+ : undefined;
396
+ if (!vtxoScript)
397
+ continue;
398
+ pendingExtended.push({
399
+ ...vtxo,
400
+ forfeitTapLeafScript: vtxoScript.forfeit(),
401
+ intentTapLeafScript: vtxoScript.forfeit(),
402
+ tapTree: vtxoScript.encode(),
403
+ });
404
+ }
405
+ if (pendingExtended.length > 0) {
406
+ await this.walletRepository.saveVtxos(address, pendingExtended);
407
+ }
408
+ }
409
+ return {
410
+ isDelta: hasDelta || bootstrapScripts.length === 0,
411
+ fetchedExtended,
412
+ address,
413
+ };
307
414
  }
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);
415
+ /**
416
+ * Clear all VTXO sync cursors, forcing a full re-bootstrap on next sync.
417
+ * Useful for recovery after indexer reprocessing or debugging.
418
+ */
419
+ async clearSyncCursors() {
420
+ await clearSyncCursors(this.walletRepository);
316
421
  }
317
422
  async getBoardingTxs() {
318
423
  const utxos = [];
319
424
  const commitmentsToIgnore = new Set();
320
425
  const boardingAddress = await this.getBoardingAddress();
321
426
  const txs = await this.onchainProvider.getTransactions(boardingAddress);
427
+ const outspendCache = new Map();
322
428
  for (const tx of txs) {
323
429
  for (let i = 0; i < tx.vout.length; i++) {
324
430
  const vout = tx.vout[i];
325
431
  if (vout.scriptpubkey_address === boardingAddress) {
326
- const spentStatuses = await this.onchainProvider.getTxOutspends(tx.txid);
432
+ let spentStatuses = outspendCache.get(tx.txid);
433
+ if (!spentStatuses) {
434
+ spentStatuses =
435
+ await this.onchainProvider.getTxOutspends(tx.txid);
436
+ outspendCache.set(tx.txid, spentStatuses);
437
+ }
327
438
  const spentStatus = spentStatuses[i];
328
439
  if (spentStatus?.spent) {
329
440
  commitmentsToIgnore.add(spentStatus.txid);
@@ -1338,10 +1449,15 @@ export class Wallet extends ReadonlyWallet {
1338
1449
  }
1339
1450
  /**
1340
1451
  * Finalizes pending transactions by retrieving them from the server and finalizing each one.
1452
+ * Skips the server check entirely when no send was interrupted (no pending tx flag set).
1341
1453
  * @param vtxos - Optional list of VTXOs to use instead of retrieving them from the server
1342
1454
  * @returns Array of transaction IDs that were finalized
1343
1455
  */
1344
1456
  async finalizePendingTxs(vtxos) {
1457
+ const hasPending = await this.hasPendingTxFlag();
1458
+ if (!hasPending) {
1459
+ return { finalized: [], pending: [] };
1460
+ }
1345
1461
  const MAX_INPUTS_PER_INTENT = 20;
1346
1462
  if (!vtxos || vtxos.length === 0) {
1347
1463
  // Batch all scripts into a single indexer call
@@ -1373,33 +1489,63 @@ export class Wallet extends ReadonlyWallet {
1373
1489
  }
1374
1490
  vtxos = allExtended;
1375
1491
  }
1376
- const finalized = [];
1377
- const pending = [];
1492
+ const batches = [];
1378
1493
  for (let i = 0; i < vtxos.length; i += MAX_INPUTS_PER_INTENT) {
1379
- const batch = vtxos.slice(i, i + MAX_INPUTS_PER_INTENT);
1494
+ batches.push(vtxos.slice(i, i + MAX_INPUTS_PER_INTENT));
1495
+ }
1496
+ // Track seen arkTxids so parallel batches don't finalize the same tx twice
1497
+ const seen = new Set();
1498
+ const results = await Promise.all(batches.map(async (batch) => {
1499
+ const batchFinalized = [];
1500
+ const batchPending = [];
1380
1501
  const intent = await this.makeGetPendingTxIntentSignature(batch);
1381
1502
  const pendingTxs = await this.arkProvider.getPendingTxs(intent);
1382
- // finalize each transaction by signing the checkpoints
1383
1503
  for (const pendingTx of pendingTxs) {
1384
- pending.push(pendingTx.arkTxid);
1504
+ if (seen.has(pendingTx.arkTxid))
1505
+ continue;
1506
+ seen.add(pendingTx.arkTxid);
1507
+ batchPending.push(pendingTx.arkTxid);
1385
1508
  try {
1386
- // sign the checkpoints
1387
1509
  const finalCheckpoints = await Promise.all(pendingTx.signedCheckpointTxs.map(async (c) => {
1388
1510
  const tx = Transaction.fromPSBT(base64.decode(c));
1389
1511
  const signedCheckpoint = await this.identity.sign(tx);
1390
1512
  return base64.encode(signedCheckpoint.toPSBT());
1391
1513
  }));
1392
1514
  await this.arkProvider.finalizeTx(pendingTx.arkTxid, finalCheckpoints);
1393
- finalized.push(pendingTx.arkTxid);
1515
+ batchFinalized.push(pendingTx.arkTxid);
1394
1516
  }
1395
1517
  catch (error) {
1396
1518
  console.error(`Failed to finalize transaction ${pendingTx.arkTxid}:`, error);
1397
- // continue with other transactions even if one fails
1398
1519
  }
1399
1520
  }
1521
+ return {
1522
+ finalized: batchFinalized,
1523
+ pending: batchPending,
1524
+ };
1525
+ }));
1526
+ const finalized = [];
1527
+ const pending = [];
1528
+ for (const result of results) {
1529
+ finalized.push(...result.finalized);
1530
+ pending.push(...result.pending);
1531
+ }
1532
+ // Only clear the flag if every discovered pending tx was finalized;
1533
+ // if any failed, keep it so recovery retries on next startup.
1534
+ if (finalized.length === pending.length) {
1535
+ await this.setPendingTxFlag(false);
1400
1536
  }
1401
1537
  return { finalized, pending };
1402
1538
  }
1539
+ async hasPendingTxFlag() {
1540
+ const state = await this.walletRepository.getWalletState();
1541
+ return state?.settings?.hasPendingTx === true;
1542
+ }
1543
+ async setPendingTxFlag(value) {
1544
+ await updateWalletState(this.walletRepository, (state) => ({
1545
+ ...state,
1546
+ settings: { ...state.settings, hasPendingTx: value },
1547
+ }));
1548
+ }
1403
1549
  /**
1404
1550
  * Send BTC and/or assets to one or more recipients.
1405
1551
  *
@@ -1568,6 +1714,9 @@ export class Wallet extends ReadonlyWallet {
1568
1714
  };
1569
1715
  }), outputs, this.serverUnrollScript);
1570
1716
  const signedVirtualTx = await this.identity.sign(offchainTx.arkTx);
1717
+ // Mark pending before submitting — if we crash between submit and
1718
+ // finalize, the next init will recover via finalizePendingTxs.
1719
+ await this.setPendingTxFlag(true);
1571
1720
  const { arkTxid, signedCheckpointTxs } = await this.arkProvider.submitTx(base64.encode(signedVirtualTx.toPSBT()), offchainTx.checkpoints.map((c) => base64.encode(c.toPSBT())));
1572
1721
  const finalCheckpoints = await Promise.all(signedCheckpointTxs.map(async (c) => {
1573
1722
  const tx = Transaction.fromPSBT(base64.decode(c));
@@ -1575,6 +1724,12 @@ export class Wallet extends ReadonlyWallet {
1575
1724
  return base64.encode(signedCheckpoint.toPSBT());
1576
1725
  }));
1577
1726
  await this.arkProvider.finalizeTx(arkTxid, finalCheckpoints);
1727
+ try {
1728
+ await this.setPendingTxFlag(false);
1729
+ }
1730
+ catch (error) {
1731
+ console.error("Failed to clear pending tx flag:", error);
1732
+ }
1578
1733
  return { arkTxid, signedCheckpointTxs };
1579
1734
  }
1580
1735
  // 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";