@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.
@@ -292,7 +292,7 @@ class WalletMessageHandler {
292
292
  }
293
293
  case "REFRESH_VTXOS": {
294
294
  const manager = await this.readonlyWallet.getContractManager();
295
- await manager.refreshVtxos();
295
+ await manager.refreshVtxos(message.payload);
296
296
  return this.tagged({
297
297
  id,
298
298
  type: "REFRESH_VTXOS_SUCCESS",
@@ -537,11 +537,13 @@ class WalletMessageHandler {
537
537
  // Initialize contract manager FIRST — this populates the repository
538
538
  // with full VTXO history for all contracts (one indexer call per contract)
539
539
  await this.ensureContractEventBroadcasting();
540
- // Read VTXOs from repository (now populated by contract manager)
541
- const vtxos = await this.getVtxosFromRepo();
540
+ // Refresh cached data (VTXOs, boarding UTXOs, tx history)
541
+ await this.refreshCachedData();
542
+ // Recover pending transactions (init-only, not on reload).
543
+ // Pending txs only exist if a send was interrupted mid-finalization.
542
544
  if (this.wallet) {
543
545
  try {
544
- // recover pending transactions if possible
546
+ const vtxos = await this.getVtxosFromRepo();
545
547
  const { pending, finalized } = await this.wallet.finalizePendingTxs(vtxos.filter((vtxo) => vtxo.virtualStatus.state !== "swept" &&
546
548
  vtxo.virtualStatus.state !== "settled"));
547
549
  console.info(`Recovered ${finalized.length}/${pending.length} pending transactions: ${finalized.join(", ")}`);
@@ -550,18 +552,10 @@ class WalletMessageHandler {
550
552
  console.error("Error recovering pending transactions:", error);
551
553
  }
552
554
  }
553
- // Fetch boarding utxos and save using unified repository
554
- const boardingAddress = await this.readonlyWallet.getBoardingAddress();
555
- const coins = await this.readonlyWallet.onchainProvider.getCoins(boardingAddress);
556
- await this.walletRepository.saveUtxos(boardingAddress, coins.map((utxo) => (0, utils_1.extendCoin)(this.readonlyWallet, utxo)));
557
- // Build transaction history from cached VTXOs (no indexer call)
558
- const address = await this.readonlyWallet.getAddress();
559
- const txs = await this.buildTransactionHistoryFromCache(vtxos);
560
- if (txs)
561
- await this.walletRepository.saveTransactions(address, txs);
562
555
  // unsubscribe previous subscription if any
563
556
  if (this.incomingFundsSubscription)
564
557
  this.incomingFundsSubscription();
558
+ const address = await this.readonlyWallet.getAddress();
565
559
  // subscribe for incoming funds and notify all clients when new funds arrive
566
560
  this.incomingFundsSubscription =
567
561
  await this.readonlyWallet.notifyIncomingFunds(async (funds) => {
@@ -614,15 +608,38 @@ class WalletMessageHandler {
614
608
  }
615
609
  }
616
610
  /**
617
- * Force a full VTXO refresh from the indexer, then re-run bootstrap.
618
- * Used by RELOAD_WALLET to ensure fresh data.
611
+ * Refresh VTXOs, boarding UTXOs, and transaction history from cache.
612
+ * Shared by onWalletInitialized (full bootstrap) and reloadWallet
613
+ * (post-refresh), avoiding duplicate subscriptions and VtxoManager restarts.
614
+ */
615
+ async refreshCachedData() {
616
+ if (!this.readonlyWallet || !this.walletRepository) {
617
+ return;
618
+ }
619
+ // Read VTXOs from repository (now populated by contract manager)
620
+ const vtxos = await this.getVtxosFromRepo();
621
+ // Fetch boarding utxos and save using unified repository
622
+ const boardingAddress = await this.readonlyWallet.getBoardingAddress();
623
+ const coins = await this.readonlyWallet.onchainProvider.getCoins(boardingAddress);
624
+ await this.walletRepository.deleteUtxos(boardingAddress);
625
+ await this.walletRepository.saveUtxos(boardingAddress, coins.map((utxo) => (0, utils_1.extendCoin)(this.readonlyWallet, utxo)));
626
+ // Build transaction history from cached VTXOs (no indexer call)
627
+ const address = await this.readonlyWallet.getAddress();
628
+ const txs = await this.buildTransactionHistoryFromCache(vtxos);
629
+ if (txs)
630
+ await this.walletRepository.saveTransactions(address, txs);
631
+ }
632
+ /**
633
+ * Force a full VTXO refresh from the indexer, then refresh cached data.
634
+ * Used by RELOAD_WALLET to ensure fresh data without re-subscribing
635
+ * to incoming funds or restarting the VtxoManager.
619
636
  */
620
637
  async reloadWallet() {
621
638
  if (!this.readonlyWallet)
622
639
  return;
623
640
  const manager = await this.readonlyWallet.getContractManager();
624
641
  await manager.refreshVtxos();
625
- await this.onWalletInitialized();
642
+ await this.refreshCachedData();
626
643
  }
627
644
  async handleSettle(message) {
628
645
  const wallet = this.requireWallet();
@@ -797,24 +814,37 @@ class WalletMessageHandler {
797
814
  vtxoCreatedAt.set(vtxo.txid, ts);
798
815
  }
799
816
  }
800
- // getTxCreatedAt resolves the creation timestamp of a transaction.
801
- // buildTransactionHistory calls this for spent-offchain VTXOs with
802
- // no change outputs to determine the time of the sending tx.
803
- // Returns undefined on miss so buildTransactionHistory uses its
804
- // own fallback (vtxo.createdAt + 1) rather than epoch 0.
805
- // The vout:0 lookup in the indexer fallback mirrors the pre-existing
806
- // convention in ReadonlyWallet.getTransactionHistory().
807
- const getTxCreatedAt = async (txid) => {
808
- const cached = vtxoCreatedAt.get(txid);
809
- if (cached !== undefined)
810
- return cached;
811
- if (this.indexerProvider) {
812
- const res = await this.indexerProvider.getVtxos({
813
- outpoints: [{ txid, vout: 0 }],
814
- });
815
- return res.vtxos[0]?.createdAt.getTime();
817
+ // Pre-fetch uncached timestamps in a single batched indexer call.
818
+ // buildTransactionHistory needs these for spent-offchain VTXOs with
819
+ // no change outputs (i.e. arkTxId is set but no VTXO has txid === arkTxId).
820
+ if (this.indexerProvider) {
821
+ const uncachedTxids = new Set();
822
+ for (const vtxo of vtxos) {
823
+ if (vtxo.isSpent &&
824
+ vtxo.arkTxId &&
825
+ !vtxoCreatedAt.has(vtxo.arkTxId) &&
826
+ !vtxos.some((v) => v.txid === vtxo.arkTxId)) {
827
+ uncachedTxids.add(vtxo.arkTxId);
828
+ }
816
829
  }
817
- return undefined;
830
+ if (uncachedTxids.size > 0) {
831
+ const outpoints = [...uncachedTxids].map((txid) => ({
832
+ txid,
833
+ vout: 0,
834
+ }));
835
+ const BATCH_SIZE = 100;
836
+ for (let i = 0; i < outpoints.length; i += BATCH_SIZE) {
837
+ const res = await this.indexerProvider.getVtxos({
838
+ outpoints: outpoints.slice(i, i + BATCH_SIZE),
839
+ });
840
+ for (const v of res.vtxos) {
841
+ vtxoCreatedAt.set(v.txid, v.createdAt.getTime());
842
+ }
843
+ }
844
+ }
845
+ }
846
+ const getTxCreatedAt = async (txid) => {
847
+ return vtxoCreatedAt.get(txid);
818
848
  };
819
849
  return (0, transactionHistory_1.buildTransactionHistory)(vtxos, boardingTxs, commitmentsToIgnore, getTxCreatedAt);
820
850
  }
@@ -685,11 +685,12 @@ class ServiceWorkerReadonlyWallet {
685
685
  navigator.serviceWorker.removeEventListener("message", messageHandler);
686
686
  };
687
687
  },
688
- async refreshVtxos() {
688
+ async refreshVtxos(opts) {
689
689
  const message = {
690
690
  type: "REFRESH_VTXOS",
691
691
  id: (0, utils_2.getRandomId)(),
692
692
  tag: messageTag,
693
+ payload: opts,
693
694
  };
694
695
  await sendContractMessage(message);
695
696
  },
@@ -402,8 +402,8 @@ class VtxoManager {
402
402
  * }
403
403
  * ```
404
404
  */
405
- async getExpiredBoardingUtxos() {
406
- const boardingUtxos = await this.wallet.getBoardingUtxos();
405
+ async getExpiredBoardingUtxos(prefetchedUtxos) {
406
+ const boardingUtxos = prefetchedUtxos ?? (await this.wallet.getBoardingUtxos());
407
407
  const boardingTimelock = this.getBoardingTimelock();
408
408
  // For block-based timelocks, fetch the chain tip height
409
409
  let chainTipHeight;
@@ -444,14 +444,14 @@ class VtxoManager {
444
444
  * }
445
445
  * ```
446
446
  */
447
- async sweepExpiredBoardingUtxos() {
447
+ async sweepExpiredBoardingUtxos(prefetchedUtxos) {
448
448
  const sweepEnabled = this.settlementConfig !== false &&
449
449
  (this.settlementConfig?.boardingUtxoSweep ??
450
450
  exports.DEFAULT_SETTLEMENT_CONFIG.boardingUtxoSweep);
451
451
  if (!sweepEnabled) {
452
452
  throw new Error("Boarding UTXO sweep is not enabled in settlementConfig");
453
453
  }
454
- const allExpired = await this.getExpiredBoardingUtxos();
454
+ const allExpired = await this.getExpiredBoardingUtxos(prefetchedUtxos);
455
455
  // Filter out UTXOs already swept (tx broadcast but not yet confirmed)
456
456
  const expiredUtxos = allExpired.filter((u) => !this.sweptBoardingUtxos.has(`${u.txid}:${u.vout}`));
457
457
  if (expiredUtxos.length === 0) {
@@ -640,16 +640,25 @@ class VtxoManager {
640
640
  // Guard: wallet must support boarding UTXO + sweep operations
641
641
  if (!isSweepCapable(this.wallet))
642
642
  return;
643
- // Skip if a previous poll is still running
643
+ // Skip if disposed or a previous poll is still running
644
+ if (this.disposed)
645
+ return;
644
646
  if (this.pollInProgress)
645
647
  return;
646
648
  this.pollInProgress = true;
649
+ // Create a promise that dispose() can await
650
+ let resolve;
651
+ const promise = new Promise((r) => (resolve = r));
652
+ this.pollDone = { promise, resolve: resolve };
647
653
  let hadError = false;
648
654
  try {
655
+ // Fetch boarding UTXOs once for the entire poll cycle so that
656
+ // settle and sweep don't each hit the network independently.
657
+ const boardingUtxos = await this.wallet.getBoardingUtxos();
649
658
  // Settle new (unexpired) UTXOs first, then sweep expired ones.
650
659
  // Sequential to avoid racing for the same UTXOs.
651
660
  try {
652
- await this.settleBoardingUtxos();
661
+ await this.settleBoardingUtxos(boardingUtxos);
653
662
  }
654
663
  catch (e) {
655
664
  hadError = true;
@@ -660,7 +669,7 @@ class VtxoManager {
660
669
  exports.DEFAULT_SETTLEMENT_CONFIG.boardingUtxoSweep);
661
670
  if (sweepEnabled) {
662
671
  try {
663
- await this.sweepExpiredBoardingUtxos();
672
+ await this.sweepExpiredBoardingUtxos(boardingUtxos);
664
673
  }
665
674
  catch (e) {
666
675
  if (!(e instanceof Error) ||
@@ -671,6 +680,10 @@ class VtxoManager {
671
680
  }
672
681
  }
673
682
  }
683
+ catch (e) {
684
+ hadError = true;
685
+ console.error("Error fetching boarding UTXOs:", e);
686
+ }
674
687
  finally {
675
688
  if (hadError) {
676
689
  this.consecutivePollFailures++;
@@ -679,6 +692,8 @@ class VtxoManager {
679
692
  this.consecutivePollFailures = 0;
680
693
  }
681
694
  this.pollInProgress = false;
695
+ this.pollDone.resolve();
696
+ this.pollDone = undefined;
682
697
  this.schedulePoll();
683
698
  }
684
699
  }
@@ -689,8 +704,7 @@ class VtxoManager {
689
704
  * UTXOs are marked as known only after a successful settle, so failed
690
705
  * attempts will be retried on the next poll.
691
706
  */
692
- async settleBoardingUtxos() {
693
- const boardingUtxos = await this.wallet.getBoardingUtxos();
707
+ async settleBoardingUtxos(boardingUtxos) {
694
708
  // Exclude expired UTXOs — those should be swept, not settled.
695
709
  // If we can't determine expired status, bail out entirely to avoid
696
710
  // accidentally settling expired UTXOs (which would conflict with sweep).
@@ -737,6 +751,13 @@ class VtxoManager {
737
751
  clearTimeout(this.pollTimeoutId);
738
752
  this.pollTimeoutId = undefined;
739
753
  }
754
+ // Wait for any in-flight poll to finish (with timeout to avoid hanging)
755
+ if (this.pollDone) {
756
+ let timer;
757
+ const timeout = new Promise((r) => (timer = setTimeout(r, 30000)));
758
+ await Promise.race([this.pollDone.promise, timeout]);
759
+ clearTimeout(timer);
760
+ }
740
761
  const subscription = await this.contractEventsSubscriptionReady;
741
762
  this.contractEventsSubscription = undefined;
742
763
  subscription?.();
@@ -37,6 +37,7 @@ const repositories_1 = require("../repositories");
37
37
  const contractManager_1 = require("../contracts/contractManager");
38
38
  const handlers_1 = require("../contracts/handlers");
39
39
  const helpers_1 = require("../contracts/handlers/helpers");
40
+ const syncCursors_1 = require("../utils/syncCursors");
40
41
  /**
41
42
  * Type guard function to check if an identity has a toReadonly method.
42
43
  */
@@ -274,61 +275,158 @@ class ReadonlyWallet {
274
275
  };
275
276
  }
276
277
  async getVtxos(filter) {
277
- const address = await this.getAddress();
278
- const scriptMap = await this.getScriptMap();
278
+ const { isDelta, fetchedExtended, address } = await this.syncVtxos();
279
279
  const f = filter ?? { withRecoverable: true, withUnrolled: false };
280
- const allExtended = [];
281
- // Batch all scripts into a single indexer call
282
- const allScripts = [...scriptMap.keys()];
283
- const response = await this.indexerProvider.getVtxos({
284
- scripts: allScripts,
285
- });
286
- for (const vtxo of response.vtxos) {
287
- const vtxoScript = vtxo.script
288
- ? scriptMap.get(vtxo.script)
289
- : undefined;
290
- if (!vtxoScript)
291
- continue;
280
+ // For delta syncs, read the full merged set from cache so old
281
+ // VTXOs that weren't in the delta are still returned.
282
+ const vtxos = isDelta
283
+ ? await this.walletRepository.getVtxos(address)
284
+ : fetchedExtended;
285
+ return vtxos.filter((vtxo) => {
292
286
  if ((0, _1.isSpendable)(vtxo)) {
293
287
  if (!f.withRecoverable &&
294
288
  ((0, _1.isRecoverable)(vtxo) || (0, _1.isExpired)(vtxo))) {
295
- continue;
289
+ return false;
296
290
  }
291
+ return true;
292
+ }
293
+ return !!(f.withUnrolled && vtxo.isUnrolled);
294
+ });
295
+ }
296
+ async getTransactionHistory() {
297
+ // Delta-sync VTXOs into cache, then build history from the cache.
298
+ const { isDelta, fetchedExtended, address } = await this.syncVtxos();
299
+ const allVtxos = isDelta
300
+ ? await this.walletRepository.getVtxos(address)
301
+ : fetchedExtended;
302
+ const { boardingTxs, commitmentsToIgnore } = await this.getBoardingTxs();
303
+ const getTxCreatedAt = (txid) => this.indexerProvider
304
+ .getVtxos({ outpoints: [{ txid, vout: 0 }] })
305
+ .then((res) => res.vtxos[0]?.createdAt.getTime());
306
+ return (0, transactionHistory_1.buildTransactionHistory)(allVtxos, boardingTxs, commitmentsToIgnore, getTxCreatedAt);
307
+ }
308
+ /**
309
+ * Delta-sync wallet VTXOs: fetch only changed VTXOs since the last
310
+ * cursor, or do a full bootstrap when no cursor exists. Upserts
311
+ * the result into the cache and advances the sync cursors.
312
+ */
313
+ async syncVtxos() {
314
+ const address = await this.getAddress();
315
+ // Batch cursor read with script map to avoid extra async hops
316
+ // before the fetch (background operations may run between hops).
317
+ const [scriptMap, cursors] = await Promise.all([
318
+ this.getScriptMap(),
319
+ (0, syncCursors_1.getAllSyncCursors)(this.walletRepository),
320
+ ]);
321
+ const allScripts = [...scriptMap.keys()];
322
+ // Partition scripts into bootstrap (no cursor) and delta (has cursor).
323
+ const bootstrapScripts = [];
324
+ const deltaScripts = [];
325
+ for (const s of allScripts) {
326
+ if (cursors[s] === undefined) {
327
+ bootstrapScripts.push(s);
297
328
  }
298
329
  else {
299
- if (!f.withUnrolled || !vtxo.isUnrolled)
300
- continue;
330
+ deltaScripts.push(s);
331
+ }
332
+ }
333
+ const requestStartedAt = Date.now();
334
+ const allVtxos = [];
335
+ // Full fetch for scripts with no cursor.
336
+ if (bootstrapScripts.length > 0) {
337
+ const response = await this.indexerProvider.getVtxos({
338
+ scripts: bootstrapScripts,
339
+ });
340
+ allVtxos.push(...response.vtxos);
341
+ }
342
+ // Delta fetch for scripts with an existing cursor.
343
+ let hasDelta = false;
344
+ if (deltaScripts.length > 0) {
345
+ const minCursor = Math.min(...deltaScripts.map((s) => cursors[s]));
346
+ const window = (0, syncCursors_1.computeSyncWindow)(minCursor);
347
+ if (window) {
348
+ hasDelta = true;
349
+ const response = await this.indexerProvider.getVtxos({
350
+ scripts: deltaScripts,
351
+ after: window.after,
352
+ });
353
+ allVtxos.push(...response.vtxos);
301
354
  }
302
- allExtended.push({
355
+ }
356
+ // Extend every fetched VTXO and upsert into the cache.
357
+ const fetchedExtended = [];
358
+ for (const vtxo of allVtxos) {
359
+ const vtxoScript = vtxo.script
360
+ ? scriptMap.get(vtxo.script)
361
+ : undefined;
362
+ if (!vtxoScript)
363
+ continue;
364
+ fetchedExtended.push({
303
365
  ...vtxo,
304
366
  forfeitTapLeafScript: vtxoScript.forfeit(),
305
367
  intentTapLeafScript: vtxoScript.forfeit(),
306
368
  tapTree: vtxoScript.encode(),
307
369
  });
308
370
  }
309
- // Update cache with fresh data
310
- await this.walletRepository.saveVtxos(address, allExtended);
311
- return allExtended;
371
+ // Save VTXOs first, then advance cursors only on success.
372
+ const cutoff = (0, syncCursors_1.cursorCutoff)(requestStartedAt);
373
+ await this.walletRepository.saveVtxos(address, fetchedExtended);
374
+ await (0, syncCursors_1.advanceSyncCursors)(this.walletRepository, Object.fromEntries(allScripts.map((s) => [s, cutoff])));
375
+ // For delta syncs, reconcile pending (preconfirmed/spent) VTXOs
376
+ // whose state may have changed since the cursor so that
377
+ // getVtxos()/getTransactionHistory() don't serve stale state.
378
+ if (hasDelta) {
379
+ const { vtxos: pendingVtxos } = await this.indexerProvider.getVtxos({
380
+ scripts: deltaScripts,
381
+ pendingOnly: true,
382
+ });
383
+ const pendingExtended = [];
384
+ for (const vtxo of pendingVtxos) {
385
+ const vtxoScript = vtxo.script
386
+ ? scriptMap.get(vtxo.script)
387
+ : undefined;
388
+ if (!vtxoScript)
389
+ continue;
390
+ pendingExtended.push({
391
+ ...vtxo,
392
+ forfeitTapLeafScript: vtxoScript.forfeit(),
393
+ intentTapLeafScript: vtxoScript.forfeit(),
394
+ tapTree: vtxoScript.encode(),
395
+ });
396
+ }
397
+ if (pendingExtended.length > 0) {
398
+ await this.walletRepository.saveVtxos(address, pendingExtended);
399
+ }
400
+ }
401
+ return {
402
+ isDelta: hasDelta || bootstrapScripts.length === 0,
403
+ fetchedExtended,
404
+ address,
405
+ };
312
406
  }
313
- async getTransactionHistory() {
314
- const scripts = await this.getWalletScripts();
315
- const response = await this.indexerProvider.getVtxos({ scripts });
316
- const { boardingTxs, commitmentsToIgnore } = await this.getBoardingTxs();
317
- const getTxCreatedAt = (txid) => this.indexerProvider
318
- .getVtxos({ outpoints: [{ txid, vout: 0 }] })
319
- .then((res) => res.vtxos[0]?.createdAt.getTime());
320
- return (0, transactionHistory_1.buildTransactionHistory)(response.vtxos, boardingTxs, commitmentsToIgnore, getTxCreatedAt);
407
+ /**
408
+ * Clear all VTXO sync cursors, forcing a full re-bootstrap on next sync.
409
+ * Useful for recovery after indexer reprocessing or debugging.
410
+ */
411
+ async clearSyncCursors() {
412
+ await (0, syncCursors_1.clearSyncCursors)(this.walletRepository);
321
413
  }
322
414
  async getBoardingTxs() {
323
415
  const utxos = [];
324
416
  const commitmentsToIgnore = new Set();
325
417
  const boardingAddress = await this.getBoardingAddress();
326
418
  const txs = await this.onchainProvider.getTransactions(boardingAddress);
419
+ const outspendCache = new Map();
327
420
  for (const tx of txs) {
328
421
  for (let i = 0; i < tx.vout.length; i++) {
329
422
  const vout = tx.vout[i];
330
423
  if (vout.scriptpubkey_address === boardingAddress) {
331
- const spentStatuses = await this.onchainProvider.getTxOutspends(tx.txid);
424
+ let spentStatuses = outspendCache.get(tx.txid);
425
+ if (!spentStatuses) {
426
+ spentStatuses =
427
+ await this.onchainProvider.getTxOutspends(tx.txid);
428
+ outspendCache.set(tx.txid, spentStatuses);
429
+ }
332
430
  const spentStatus = spentStatuses[i];
333
431
  if (spentStatus?.spent) {
334
432
  commitmentsToIgnore.add(spentStatus.txid);
@@ -1344,10 +1442,15 @@ class Wallet extends ReadonlyWallet {
1344
1442
  }
1345
1443
  /**
1346
1444
  * Finalizes pending transactions by retrieving them from the server and finalizing each one.
1445
+ * Skips the server check entirely when no send was interrupted (no pending tx flag set).
1347
1446
  * @param vtxos - Optional list of VTXOs to use instead of retrieving them from the server
1348
1447
  * @returns Array of transaction IDs that were finalized
1349
1448
  */
1350
1449
  async finalizePendingTxs(vtxos) {
1450
+ const hasPending = await this.hasPendingTxFlag();
1451
+ if (!hasPending) {
1452
+ return { finalized: [], pending: [] };
1453
+ }
1351
1454
  const MAX_INPUTS_PER_INTENT = 20;
1352
1455
  if (!vtxos || vtxos.length === 0) {
1353
1456
  // Batch all scripts into a single indexer call
@@ -1379,33 +1482,63 @@ class Wallet extends ReadonlyWallet {
1379
1482
  }
1380
1483
  vtxos = allExtended;
1381
1484
  }
1382
- const finalized = [];
1383
- const pending = [];
1485
+ const batches = [];
1384
1486
  for (let i = 0; i < vtxos.length; i += MAX_INPUTS_PER_INTENT) {
1385
- const batch = vtxos.slice(i, i + MAX_INPUTS_PER_INTENT);
1487
+ batches.push(vtxos.slice(i, i + MAX_INPUTS_PER_INTENT));
1488
+ }
1489
+ // Track seen arkTxids so parallel batches don't finalize the same tx twice
1490
+ const seen = new Set();
1491
+ const results = await Promise.all(batches.map(async (batch) => {
1492
+ const batchFinalized = [];
1493
+ const batchPending = [];
1386
1494
  const intent = await this.makeGetPendingTxIntentSignature(batch);
1387
1495
  const pendingTxs = await this.arkProvider.getPendingTxs(intent);
1388
- // finalize each transaction by signing the checkpoints
1389
1496
  for (const pendingTx of pendingTxs) {
1390
- pending.push(pendingTx.arkTxid);
1497
+ if (seen.has(pendingTx.arkTxid))
1498
+ continue;
1499
+ seen.add(pendingTx.arkTxid);
1500
+ batchPending.push(pendingTx.arkTxid);
1391
1501
  try {
1392
- // sign the checkpoints
1393
1502
  const finalCheckpoints = await Promise.all(pendingTx.signedCheckpointTxs.map(async (c) => {
1394
1503
  const tx = btc_signer_1.Transaction.fromPSBT(base_1.base64.decode(c));
1395
1504
  const signedCheckpoint = await this.identity.sign(tx);
1396
1505
  return base_1.base64.encode(signedCheckpoint.toPSBT());
1397
1506
  }));
1398
1507
  await this.arkProvider.finalizeTx(pendingTx.arkTxid, finalCheckpoints);
1399
- finalized.push(pendingTx.arkTxid);
1508
+ batchFinalized.push(pendingTx.arkTxid);
1400
1509
  }
1401
1510
  catch (error) {
1402
1511
  console.error(`Failed to finalize transaction ${pendingTx.arkTxid}:`, error);
1403
- // continue with other transactions even if one fails
1404
1512
  }
1405
1513
  }
1514
+ return {
1515
+ finalized: batchFinalized,
1516
+ pending: batchPending,
1517
+ };
1518
+ }));
1519
+ const finalized = [];
1520
+ const pending = [];
1521
+ for (const result of results) {
1522
+ finalized.push(...result.finalized);
1523
+ pending.push(...result.pending);
1524
+ }
1525
+ // Only clear the flag if every discovered pending tx was finalized;
1526
+ // if any failed, keep it so recovery retries on next startup.
1527
+ if (finalized.length === pending.length) {
1528
+ await this.setPendingTxFlag(false);
1406
1529
  }
1407
1530
  return { finalized, pending };
1408
1531
  }
1532
+ async hasPendingTxFlag() {
1533
+ const state = await this.walletRepository.getWalletState();
1534
+ return state?.settings?.hasPendingTx === true;
1535
+ }
1536
+ async setPendingTxFlag(value) {
1537
+ await (0, syncCursors_1.updateWalletState)(this.walletRepository, (state) => ({
1538
+ ...state,
1539
+ settings: { ...state.settings, hasPendingTx: value },
1540
+ }));
1541
+ }
1409
1542
  /**
1410
1543
  * Send BTC and/or assets to one or more recipients.
1411
1544
  *
@@ -1574,6 +1707,9 @@ class Wallet extends ReadonlyWallet {
1574
1707
  };
1575
1708
  }), outputs, this.serverUnrollScript);
1576
1709
  const signedVirtualTx = await this.identity.sign(offchainTx.arkTx);
1710
+ // Mark pending before submitting — if we crash between submit and
1711
+ // finalize, the next init will recover via finalizePendingTxs.
1712
+ await this.setPendingTxFlag(true);
1577
1713
  const { arkTxid, signedCheckpointTxs } = await this.arkProvider.submitTx(base_1.base64.encode(signedVirtualTx.toPSBT()), offchainTx.checkpoints.map((c) => base_1.base64.encode(c.toPSBT())));
1578
1714
  const finalCheckpoints = await Promise.all(signedCheckpointTxs.map(async (c) => {
1579
1715
  const tx = btc_signer_1.Transaction.fromPSBT(base_1.base64.decode(c));
@@ -1581,6 +1717,12 @@ class Wallet extends ReadonlyWallet {
1581
1717
  return base_1.base64.encode(signedCheckpoint.toPSBT());
1582
1718
  }));
1583
1719
  await this.arkProvider.finalizeTx(arkTxid, finalCheckpoints);
1720
+ try {
1721
+ await this.setPendingTxFlag(false);
1722
+ }
1723
+ catch (error) {
1724
+ console.error("Failed to clear pending tx flag:", error);
1725
+ }
1584
1726
  return { arkTxid, signedCheckpointTxs };
1585
1727
  }
1586
1728
  // mark vtxo spent and save change vtxo if any