@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.
- package/dist/cjs/contracts/contractManager.js +181 -25
- package/dist/cjs/providers/indexer.js +27 -4
- 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 +2 -1
- package/dist/cjs/wallet/vtxo-manager.js +30 -9
- package/dist/cjs/wallet/wallet.js +193 -38
- package/dist/esm/contracts/contractManager.js +181 -25
- package/dist/esm/providers/indexer.js +27 -4
- 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 +2 -1
- package/dist/esm/wallet/vtxo-manager.js +30 -9
- package/dist/esm/wallet/wallet.js +193 -38
- package/dist/types/contracts/contractManager.d.ts +28 -7
- package/dist/types/contracts/index.d.ts +1 -1
- 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 +20 -0
- package/package.json +2 -2
|
@@ -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
|
-
//
|
|
535
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
612
|
-
*
|
|
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.
|
|
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
|
-
//
|
|
795
|
-
// buildTransactionHistory
|
|
796
|
-
// no change outputs
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
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
|
-
|
|
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.
|
|
273
|
-
const scriptMap = await this.getScriptMap();
|
|
273
|
+
const { isDelta, fetchedExtended, address } = await this.syncVtxos();
|
|
274
274
|
const f = filter ?? { withRecoverable: true, withUnrolled: false };
|
|
275
|
-
|
|
276
|
-
//
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
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
|
-
|
|
295
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
|
|
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
|
|
1377
|
-
const pending = [];
|
|
1492
|
+
const batches = [];
|
|
1378
1493
|
for (let i = 0; i < vtxos.length; i += MAX_INPUTS_PER_INTENT) {
|
|
1379
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
107
|
-
*
|
|
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
|
|
302
|
-
*
|
|
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";
|