@arkade-os/sdk 0.4.9 → 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.
- package/dist/cjs/contracts/contractManager.js +173 -25
- package/dist/cjs/index.js +4 -3
- package/dist/cjs/providers/indexer.js +27 -4
- package/dist/cjs/utils/arkTransaction.js +17 -6
- package/dist/cjs/utils/syncCursors.js +136 -0
- package/dist/cjs/wallet/delegator.js +9 -6
- package/dist/cjs/wallet/serviceWorker/wallet-message-handler.js +63 -33
- package/dist/cjs/wallet/serviceWorker/wallet.js +5 -3
- package/dist/cjs/wallet/vtxo-manager.js +30 -9
- package/dist/cjs/wallet/wallet.js +180 -38
- package/dist/cjs/worker/errors.js +3 -4
- package/dist/esm/contracts/contractManager.js +173 -25
- package/dist/esm/index.js +2 -2
- package/dist/esm/providers/indexer.js +27 -4
- package/dist/esm/utils/arkTransaction.js +17 -6
- package/dist/esm/utils/syncCursors.js +125 -0
- package/dist/esm/wallet/delegator.js +9 -6
- package/dist/esm/wallet/serviceWorker/wallet-message-handler.js +63 -33
- package/dist/esm/wallet/serviceWorker/wallet.js +6 -4
- package/dist/esm/wallet/vtxo-manager.js +30 -9
- package/dist/esm/wallet/wallet.js +180 -38
- package/dist/esm/worker/errors.js +2 -3
- package/dist/types/contracts/contractManager.d.ts +28 -7
- package/dist/types/contracts/index.d.ts +1 -1
- package/dist/types/index.d.ts +2 -2
- package/dist/types/providers/indexer.d.ts +16 -14
- package/dist/types/utils/syncCursors.d.ts +58 -0
- package/dist/types/wallet/serviceWorker/wallet-message-handler.d.ts +14 -2
- package/dist/types/wallet/vtxo-manager.d.ts +3 -2
- package/dist/types/wallet/wallet.d.ts +14 -0
- package/dist/types/worker/errors.d.ts +1 -0
- package/package.json +2 -2
|
@@ -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
|
-
//
|
|
541
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
618
|
-
*
|
|
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.
|
|
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
|
-
//
|
|
801
|
-
// buildTransactionHistory
|
|
802
|
-
// no change outputs
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
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
|
-
|
|
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
|
}
|
|
@@ -7,11 +7,12 @@ const repositories_1 = require("../../repositories");
|
|
|
7
7
|
const wallet_message_handler_1 = require("./wallet-message-handler");
|
|
8
8
|
const utils_2 = require("../utils");
|
|
9
9
|
const errors_1 = require("../../worker/errors");
|
|
10
|
-
// Check by error
|
|
10
|
+
// Check by error message content instead of instanceof because postMessage uses the
|
|
11
11
|
// structured clone algorithm which strips the prototype chain — the page
|
|
12
12
|
// receives a plain Error, not the original MessageBusNotInitializedError.
|
|
13
13
|
function isMessageBusNotInitializedError(error) {
|
|
14
|
-
return (error instanceof Error &&
|
|
14
|
+
return (error instanceof Error &&
|
|
15
|
+
error.message.includes(errors_1.MESSAGE_BUS_NOT_INITIALIZED));
|
|
15
16
|
}
|
|
16
17
|
const DEDUPABLE_REQUEST_TYPES = new Set([
|
|
17
18
|
"GET_ADDRESS",
|
|
@@ -684,11 +685,12 @@ class ServiceWorkerReadonlyWallet {
|
|
|
684
685
|
navigator.serviceWorker.removeEventListener("message", messageHandler);
|
|
685
686
|
};
|
|
686
687
|
},
|
|
687
|
-
async refreshVtxos() {
|
|
688
|
+
async refreshVtxos(opts) {
|
|
688
689
|
const message = {
|
|
689
690
|
type: "REFRESH_VTXOS",
|
|
690
691
|
id: (0, utils_2.getRandomId)(),
|
|
691
692
|
tag: messageTag,
|
|
693
|
+
payload: opts,
|
|
692
694
|
};
|
|
693
695
|
await sendContractMessage(message);
|
|
694
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.
|
|
278
|
-
const scriptMap = await this.getScriptMap();
|
|
278
|
+
const { isDelta, fetchedExtended, address } = await this.syncVtxos();
|
|
279
279
|
const f = filter ?? { withRecoverable: true, withUnrolled: false };
|
|
280
|
-
|
|
281
|
-
//
|
|
282
|
-
const
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
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
|
-
|
|
300
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
|
|
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
|
|
1383
|
-
const pending = [];
|
|
1485
|
+
const batches = [];
|
|
1384
1486
|
for (let i = 0; i < vtxos.length; i += MAX_INPUTS_PER_INTENT) {
|
|
1385
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.ServiceWorkerTimeoutError = exports.MessageBusNotInitializedError = void 0;
|
|
3
|
+
exports.ServiceWorkerTimeoutError = exports.MessageBusNotInitializedError = exports.MESSAGE_BUS_NOT_INITIALIZED = void 0;
|
|
4
|
+
exports.MESSAGE_BUS_NOT_INITIALIZED = "MessageBus not initialized";
|
|
4
5
|
class MessageBusNotInitializedError extends Error {
|
|
5
6
|
constructor() {
|
|
6
|
-
super(
|
|
7
|
-
this.name = "MessageBusNotInitializedError";
|
|
7
|
+
super(exports.MESSAGE_BUS_NOT_INITIALIZED);
|
|
8
8
|
}
|
|
9
9
|
}
|
|
10
10
|
exports.MessageBusNotInitializedError = MessageBusNotInitializedError;
|
|
11
11
|
class ServiceWorkerTimeoutError extends Error {
|
|
12
12
|
constructor(detail) {
|
|
13
13
|
super(detail);
|
|
14
|
-
this.name = "ServiceWorkerTimeoutError";
|
|
15
14
|
}
|
|
16
15
|
}
|
|
17
16
|
exports.ServiceWorkerTimeoutError = ServiceWorkerTimeoutError;
|