@arkade-os/sdk 0.4.7 → 0.4.9
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 +59 -11
- package/dist/cjs/contracts/contractWatcher.js +21 -2
- package/dist/cjs/identity/seedIdentity.js +2 -2
- package/dist/cjs/index.js +9 -2
- package/dist/cjs/providers/expoIndexer.js +1 -0
- package/dist/cjs/providers/indexer.js +1 -0
- package/dist/cjs/utils/transactionHistory.js +2 -1
- package/dist/cjs/wallet/serviceWorker/wallet-message-handler.js +249 -36
- package/dist/cjs/wallet/serviceWorker/wallet.js +286 -34
- package/dist/cjs/wallet/vtxo-manager.js +123 -86
- package/dist/cjs/wallet/wallet.js +140 -68
- package/dist/cjs/worker/errors.js +17 -0
- package/dist/cjs/worker/messageBus.js +14 -2
- package/dist/esm/contracts/contractManager.js +59 -11
- package/dist/esm/contracts/contractWatcher.js +21 -2
- package/dist/esm/identity/seedIdentity.js +2 -2
- package/dist/esm/index.js +3 -2
- package/dist/esm/providers/expoIndexer.js +1 -0
- package/dist/esm/providers/indexer.js +1 -0
- package/dist/esm/utils/transactionHistory.js +2 -1
- package/dist/esm/wallet/serviceWorker/wallet-message-handler.js +245 -35
- package/dist/esm/wallet/serviceWorker/wallet.js +286 -34
- package/dist/esm/wallet/vtxo-manager.js +123 -86
- package/dist/esm/wallet/wallet.js +140 -68
- package/dist/esm/worker/errors.js +12 -0
- package/dist/esm/worker/messageBus.js +14 -2
- package/dist/types/contracts/contractManager.d.ts +10 -0
- package/dist/types/identity/seedIdentity.d.ts +5 -2
- package/dist/types/index.d.ts +5 -4
- package/dist/types/repositories/serialization.d.ts +1 -0
- package/dist/types/utils/transactionHistory.d.ts +1 -1
- package/dist/types/wallet/index.d.ts +2 -0
- package/dist/types/wallet/serviceWorker/wallet-message-handler.d.ts +101 -7
- package/dist/types/wallet/serviceWorker/wallet.d.ts +16 -0
- package/dist/types/wallet/vtxo-manager.d.ts +29 -2
- package/dist/types/wallet/wallet.d.ts +10 -0
- package/dist/types/worker/errors.d.ts +6 -0
- package/dist/types/worker/messageBus.d.ts +6 -0
- package/package.json +1 -1
|
@@ -143,43 +143,6 @@ export function getExpiringAndRecoverableVtxos(vtxos, thresholdMs, dustAmount) {
|
|
|
143
143
|
(isSpendable(vtxo) && isExpired(vtxo)) ||
|
|
144
144
|
isSubdust(vtxo, dustAmount));
|
|
145
145
|
}
|
|
146
|
-
/**
|
|
147
|
-
* VtxoManager is a unified class for managing VTXO lifecycle operations including
|
|
148
|
-
* recovery of swept/expired VTXOs and renewal to prevent expiration.
|
|
149
|
-
*
|
|
150
|
-
* Key Features:
|
|
151
|
-
* - **Recovery**: Reclaim swept or expired VTXOs back to the wallet
|
|
152
|
-
* - **Renewal**: Refresh VTXO expiration time before they expire
|
|
153
|
-
* - **Smart subdust handling**: Automatically includes subdust VTXOs when economically viable
|
|
154
|
-
* - **Expiry monitoring**: Check for VTXOs that are expiring soon
|
|
155
|
-
*
|
|
156
|
-
* VTXOs become recoverable when:
|
|
157
|
-
* - The Ark server sweeps them (virtualStatus.state === "swept") and they remain spendable
|
|
158
|
-
* - They are preconfirmed subdust (to consolidate small amounts without locking liquidity on settled VTXOs)
|
|
159
|
-
*
|
|
160
|
-
* @example
|
|
161
|
-
* ```typescript
|
|
162
|
-
* // Initialize with renewal config
|
|
163
|
-
* const manager = new VtxoManager(wallet, {
|
|
164
|
-
* enabled: true,
|
|
165
|
-
* thresholdMs: 86400000
|
|
166
|
-
* });
|
|
167
|
-
*
|
|
168
|
-
* // Check recoverable balance
|
|
169
|
-
* const balance = await manager.getRecoverableBalance();
|
|
170
|
-
* if (balance.recoverable > 0n) {
|
|
171
|
-
* console.log(`Can recover ${balance.recoverable} sats`);
|
|
172
|
-
* const txid = await manager.recoverVtxos();
|
|
173
|
-
* }
|
|
174
|
-
*
|
|
175
|
-
* // Check for expiring VTXOs
|
|
176
|
-
* const expiring = await manager.getExpiringVtxos();
|
|
177
|
-
* if (expiring.length > 0) {
|
|
178
|
-
* console.log(`${expiring.length} VTXOs expiring soon`);
|
|
179
|
-
* const txid = await manager.renewVtxos();
|
|
180
|
-
* }
|
|
181
|
-
* ```
|
|
182
|
-
*/
|
|
183
146
|
export class VtxoManager {
|
|
184
147
|
constructor(wallet,
|
|
185
148
|
/** @deprecated Use settlementConfig instead */
|
|
@@ -189,6 +152,12 @@ export class VtxoManager {
|
|
|
189
152
|
this.knownBoardingUtxos = new Set();
|
|
190
153
|
this.sweptBoardingUtxos = new Set();
|
|
191
154
|
this.pollInProgress = false;
|
|
155
|
+
this.disposed = false;
|
|
156
|
+
this.consecutivePollFailures = 0;
|
|
157
|
+
// Guards against renewal feedback loop: when renewVtxos() settles, the
|
|
158
|
+
// server emits new VTXOs → vtxo_received → renewVtxos() again → infinite loop.
|
|
159
|
+
this.renewalInProgress = false;
|
|
160
|
+
this.lastRenewalTimestamp = 0;
|
|
192
161
|
// Normalize: prefer settlementConfig, fall back to renewalConfig, default to enabled
|
|
193
162
|
if (settlementConfig !== undefined) {
|
|
194
163
|
this.settlementConfig = settlementConfig;
|
|
@@ -372,32 +341,43 @@ export class VtxoManager {
|
|
|
372
341
|
* ```
|
|
373
342
|
*/
|
|
374
343
|
async renewVtxos(eventCallback) {
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
344
|
+
if (this.renewalInProgress) {
|
|
345
|
+
throw new Error("Renewal already in progress");
|
|
346
|
+
}
|
|
347
|
+
this.renewalInProgress = true;
|
|
348
|
+
try {
|
|
349
|
+
// Get all VTXOs (including recoverable ones)
|
|
350
|
+
// Use default threshold to bypass settlementConfig gate (manual API should always work)
|
|
351
|
+
const vtxos = await this.getExpiringVtxos(this.settlementConfig !== false &&
|
|
352
|
+
this.settlementConfig?.vtxoThreshold !== undefined
|
|
353
|
+
? this.settlementConfig.vtxoThreshold * 1000
|
|
354
|
+
: DEFAULT_RENEWAL_CONFIG.thresholdMs);
|
|
355
|
+
if (vtxos.length === 0) {
|
|
356
|
+
throw new Error("No VTXOs available to renew");
|
|
357
|
+
}
|
|
358
|
+
const totalAmount = vtxos.reduce((sum, vtxo) => sum + vtxo.value, 0);
|
|
359
|
+
// Get dust amount from wallet
|
|
360
|
+
const dustAmount = getDustAmount(this.wallet);
|
|
361
|
+
// Check if total amount is above dust threshold
|
|
362
|
+
if (BigInt(totalAmount) < dustAmount) {
|
|
363
|
+
throw new Error(`Total amount ${totalAmount} is below dust threshold ${dustAmount}`);
|
|
364
|
+
}
|
|
365
|
+
const arkAddress = await this.wallet.getAddress();
|
|
366
|
+
const txid = await this.wallet.settle({
|
|
367
|
+
inputs: vtxos,
|
|
368
|
+
outputs: [
|
|
369
|
+
{
|
|
370
|
+
address: arkAddress,
|
|
371
|
+
amount: BigInt(totalAmount),
|
|
372
|
+
},
|
|
373
|
+
],
|
|
374
|
+
}, eventCallback);
|
|
375
|
+
this.lastRenewalTimestamp = Date.now();
|
|
376
|
+
return txid;
|
|
377
|
+
}
|
|
378
|
+
finally {
|
|
379
|
+
this.renewalInProgress = false;
|
|
390
380
|
}
|
|
391
|
-
const arkAddress = await this.wallet.getAddress();
|
|
392
|
-
return this.wallet.settle({
|
|
393
|
-
inputs: vtxos,
|
|
394
|
-
outputs: [
|
|
395
|
-
{
|
|
396
|
-
address: arkAddress,
|
|
397
|
-
amount: BigInt(totalAmount),
|
|
398
|
-
},
|
|
399
|
-
],
|
|
400
|
-
}, eventCallback);
|
|
401
381
|
}
|
|
402
382
|
// ========== Boarding UTXO Sweep Methods ==========
|
|
403
383
|
/**
|
|
@@ -565,7 +545,11 @@ export class VtxoManager {
|
|
|
565
545
|
}
|
|
566
546
|
// Start polling for boarding UTXOs independently of contract manager
|
|
567
547
|
// SSE setup. Use a short delay to let the wallet finish construction.
|
|
568
|
-
setTimeout(() =>
|
|
548
|
+
this.startupPollTimeoutId = setTimeout(() => {
|
|
549
|
+
if (this.disposed)
|
|
550
|
+
return;
|
|
551
|
+
this.startBoardingUtxoPoll();
|
|
552
|
+
}, 1000);
|
|
569
553
|
try {
|
|
570
554
|
const [delegatorManager, contractManager, destination] = await Promise.all([
|
|
571
555
|
this.wallet.getDelegatorManager(),
|
|
@@ -576,20 +560,33 @@ export class VtxoManager {
|
|
|
576
560
|
if (event.type !== "vtxo_received") {
|
|
577
561
|
return;
|
|
578
562
|
}
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
563
|
+
const msSinceLastRenewal = Date.now() - this.lastRenewalTimestamp;
|
|
564
|
+
const shouldRenew = !this.renewalInProgress &&
|
|
565
|
+
msSinceLastRenewal >= VtxoManager.RENEWAL_COOLDOWN_MS;
|
|
566
|
+
if (shouldRenew) {
|
|
567
|
+
this.renewVtxos().catch((e) => {
|
|
568
|
+
if (e instanceof Error) {
|
|
569
|
+
if (e.message.includes("No VTXOs available to renew")) {
|
|
570
|
+
// Not an error, just no VTXO eligible for renewal.
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
if (e.message.includes("is below dust threshold")) {
|
|
574
|
+
// Not an error, just below dust threshold.
|
|
575
|
+
// As more VTXOs are received, the threshold will be raised.
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
if (e.message.includes("VTXO_ALREADY_REGISTERED") ||
|
|
579
|
+
e.message.includes("duplicated input")) {
|
|
580
|
+
// VTXO is already being used in a concurrent
|
|
581
|
+
// user-initiated operation. Skip silently — the
|
|
582
|
+
// wallet's tx lock serializes these, but the
|
|
583
|
+
// renewal will retry on the next cycle.
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
584
586
|
}
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
return;
|
|
589
|
-
}
|
|
590
|
-
}
|
|
591
|
-
console.error("Error renewing VTXOs:", e);
|
|
592
|
-
});
|
|
587
|
+
console.error("Error renewing VTXOs:", e);
|
|
588
|
+
});
|
|
589
|
+
}
|
|
593
590
|
delegatorManager
|
|
594
591
|
?.delegate(event.vtxos, destination)
|
|
595
592
|
.catch((e) => {
|
|
@@ -603,19 +600,36 @@ export class VtxoManager {
|
|
|
603
600
|
return undefined;
|
|
604
601
|
}
|
|
605
602
|
}
|
|
603
|
+
/** Computes the next poll delay, applying exponential backoff on failures. */
|
|
604
|
+
getNextPollDelay() {
|
|
605
|
+
if (this.settlementConfig === false)
|
|
606
|
+
return 0;
|
|
607
|
+
const baseMs = this.settlementConfig.pollIntervalMs ??
|
|
608
|
+
DEFAULT_SETTLEMENT_CONFIG.pollIntervalMs;
|
|
609
|
+
if (this.consecutivePollFailures === 0)
|
|
610
|
+
return baseMs;
|
|
611
|
+
const backoff = Math.min(baseMs * Math.pow(2, this.consecutivePollFailures), VtxoManager.MAX_BACKOFF_MS);
|
|
612
|
+
return backoff;
|
|
613
|
+
}
|
|
606
614
|
/**
|
|
607
615
|
* Starts a polling loop that:
|
|
608
616
|
* 1. Auto-settles new boarding UTXOs into Ark
|
|
609
617
|
* 2. Sweeps expired boarding UTXOs (when boardingUtxoSweep is enabled)
|
|
618
|
+
*
|
|
619
|
+
* Uses setTimeout chaining (not setInterval) so a slow/blocked poll
|
|
620
|
+
* cannot stack up and the next delay can incorporate backoff.
|
|
610
621
|
*/
|
|
611
622
|
startBoardingUtxoPoll() {
|
|
612
623
|
if (this.settlementConfig === false)
|
|
613
624
|
return;
|
|
614
|
-
|
|
615
|
-
DEFAULT_SETTLEMENT_CONFIG.pollIntervalMs;
|
|
616
|
-
// Run once immediately, then on interval
|
|
625
|
+
// Run once immediately, then schedule next
|
|
617
626
|
this.pollBoardingUtxos();
|
|
618
|
-
|
|
627
|
+
}
|
|
628
|
+
schedulePoll() {
|
|
629
|
+
if (this.disposed || this.settlementConfig === false)
|
|
630
|
+
return;
|
|
631
|
+
const delay = this.getNextPollDelay();
|
|
632
|
+
this.pollTimeoutId = setTimeout(() => this.pollBoardingUtxos(), delay);
|
|
619
633
|
}
|
|
620
634
|
async pollBoardingUtxos() {
|
|
621
635
|
// Guard: wallet must support boarding UTXO + sweep operations
|
|
@@ -625,6 +639,7 @@ export class VtxoManager {
|
|
|
625
639
|
if (this.pollInProgress)
|
|
626
640
|
return;
|
|
627
641
|
this.pollInProgress = true;
|
|
642
|
+
let hadError = false;
|
|
628
643
|
try {
|
|
629
644
|
// Settle new (unexpired) UTXOs first, then sweep expired ones.
|
|
630
645
|
// Sequential to avoid racing for the same UTXOs.
|
|
@@ -632,6 +647,7 @@ export class VtxoManager {
|
|
|
632
647
|
await this.settleBoardingUtxos();
|
|
633
648
|
}
|
|
634
649
|
catch (e) {
|
|
650
|
+
hadError = true;
|
|
635
651
|
console.error("Error auto-settling boarding UTXOs:", e);
|
|
636
652
|
}
|
|
637
653
|
const sweepEnabled = this.settlementConfig !== false &&
|
|
@@ -644,13 +660,21 @@ export class VtxoManager {
|
|
|
644
660
|
catch (e) {
|
|
645
661
|
if (!(e instanceof Error) ||
|
|
646
662
|
!e.message.includes("No expired boarding UTXOs")) {
|
|
663
|
+
hadError = true;
|
|
647
664
|
console.error("Error auto-sweeping boarding UTXOs:", e);
|
|
648
665
|
}
|
|
649
666
|
}
|
|
650
667
|
}
|
|
651
668
|
}
|
|
652
669
|
finally {
|
|
670
|
+
if (hadError) {
|
|
671
|
+
this.consecutivePollFailures++;
|
|
672
|
+
}
|
|
673
|
+
else {
|
|
674
|
+
this.consecutivePollFailures = 0;
|
|
675
|
+
}
|
|
653
676
|
this.pollInProgress = false;
|
|
677
|
+
this.schedulePoll();
|
|
654
678
|
}
|
|
655
679
|
}
|
|
656
680
|
/**
|
|
@@ -667,11 +691,17 @@ export class VtxoManager {
|
|
|
667
691
|
// accidentally settling expired UTXOs (which would conflict with sweep).
|
|
668
692
|
let expiredSet;
|
|
669
693
|
try {
|
|
670
|
-
const
|
|
694
|
+
const boardingTimelock = this.getBoardingTimelock();
|
|
695
|
+
let chainTipHeight;
|
|
696
|
+
if (boardingTimelock.type === "blocks") {
|
|
697
|
+
const tip = await this.getOnchainProvider().getChainTip();
|
|
698
|
+
chainTipHeight = tip.height;
|
|
699
|
+
}
|
|
700
|
+
const expired = boardingUtxos.filter((utxo) => hasBoardingTxExpired(utxo, boardingTimelock, chainTipHeight));
|
|
671
701
|
expiredSet = new Set(expired.map((u) => `${u.txid}:${u.vout}`));
|
|
672
702
|
}
|
|
673
|
-
catch {
|
|
674
|
-
|
|
703
|
+
catch (e) {
|
|
704
|
+
throw e instanceof Error ? e : new Error(String(e));
|
|
675
705
|
}
|
|
676
706
|
const unsettledUtxos = boardingUtxos.filter((u) => !this.knownBoardingUtxos.has(`${u.txid}:${u.vout}`) &&
|
|
677
707
|
!expiredSet.has(`${u.txid}:${u.vout}`));
|
|
@@ -693,9 +723,14 @@ export class VtxoManager {
|
|
|
693
723
|
}
|
|
694
724
|
async dispose() {
|
|
695
725
|
this.disposePromise ?? (this.disposePromise = (async () => {
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
this.
|
|
726
|
+
this.disposed = true;
|
|
727
|
+
if (this.startupPollTimeoutId) {
|
|
728
|
+
clearTimeout(this.startupPollTimeoutId);
|
|
729
|
+
this.startupPollTimeoutId = undefined;
|
|
730
|
+
}
|
|
731
|
+
if (this.pollTimeoutId) {
|
|
732
|
+
clearTimeout(this.pollTimeoutId);
|
|
733
|
+
this.pollTimeoutId = undefined;
|
|
699
734
|
}
|
|
700
735
|
const subscription = await this.contractEventsSubscriptionReady;
|
|
701
736
|
this.contractEventsSubscription = undefined;
|
|
@@ -707,3 +742,5 @@ export class VtxoManager {
|
|
|
707
742
|
await this.dispose();
|
|
708
743
|
}
|
|
709
744
|
}
|
|
745
|
+
VtxoManager.MAX_BACKOFF_MS = 5 * 60 * 1000; // 5 minutes
|
|
746
|
+
VtxoManager.RENEWAL_COOLDOWN_MS = 30000; // 30 seconds
|
|
@@ -57,6 +57,19 @@ export class ReadonlyWallet {
|
|
|
57
57
|
this.walletRepository = walletRepository;
|
|
58
58
|
this.contractRepository = contractRepository;
|
|
59
59
|
this.delegatorProvider = delegatorProvider;
|
|
60
|
+
// Guard: detect identity/server network mismatch for descriptor-based identities.
|
|
61
|
+
// This duplicates the check in setupWalletConfig() so that subclasses
|
|
62
|
+
// bypassing the factory still get the safety net.
|
|
63
|
+
if ("descriptor" in identity) {
|
|
64
|
+
const descriptor = identity.descriptor;
|
|
65
|
+
const identityIsMainnet = !descriptor.includes("tpub");
|
|
66
|
+
const serverIsMainnet = network.bech32 === "bc";
|
|
67
|
+
if (identityIsMainnet !== serverIsMainnet) {
|
|
68
|
+
throw new Error(`Network mismatch: identity uses ${identityIsMainnet ? "mainnet" : "testnet"} derivation ` +
|
|
69
|
+
`but wallet network is ${serverIsMainnet ? "mainnet" : "testnet"}. ` +
|
|
70
|
+
`Create identity with { isMainnet: ${serverIsMainnet} } to match.`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
60
73
|
this.watcherConfig = watcherConfig;
|
|
61
74
|
this._assetManager = new ReadonlyAssetManager(this.indexerProvider);
|
|
62
75
|
}
|
|
@@ -84,6 +97,24 @@ export class ReadonlyWallet {
|
|
|
84
97
|
const indexerProvider = config.indexerProvider || new RestIndexerProvider(indexerUrl);
|
|
85
98
|
const info = await arkProvider.getInfo();
|
|
86
99
|
const network = getNetwork(info.network);
|
|
100
|
+
// Guard: detect identity/server network mismatch for seed-based identities.
|
|
101
|
+
// A mainnet descriptor (xpub, coin type 0) connected to a testnet server
|
|
102
|
+
// (or vice versa) means wrong derivation path → wrong keys → potential fund loss.
|
|
103
|
+
if ("descriptor" in config.identity) {
|
|
104
|
+
const descriptor = config.identity.descriptor;
|
|
105
|
+
const identityIsMainnet = !descriptor.includes("tpub");
|
|
106
|
+
const serverIsMainnet = info.network === "bitcoin";
|
|
107
|
+
if (identityIsMainnet && !serverIsMainnet) {
|
|
108
|
+
throw new Error(`Network mismatch: identity uses mainnet derivation (coin type 0) ` +
|
|
109
|
+
`but Ark server is on ${info.network}. ` +
|
|
110
|
+
`Create identity with { isMainnet: false } to use testnet derivation.`);
|
|
111
|
+
}
|
|
112
|
+
if (!identityIsMainnet && serverIsMainnet) {
|
|
113
|
+
throw new Error(`Network mismatch: identity uses testnet derivation (coin type 1) ` +
|
|
114
|
+
`but Ark server is on mainnet. ` +
|
|
115
|
+
`Create identity with { isMainnet: true } or omit isMainnet (defaults to mainnet).`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
87
118
|
// Extract esploraUrl from provider if not explicitly provided
|
|
88
119
|
const esploraUrl = config.esploraUrl || ESPLORA_URL[info.network];
|
|
89
120
|
// Use provided onchainProvider instance or create a new one
|
|
@@ -242,27 +273,33 @@ export class ReadonlyWallet {
|
|
|
242
273
|
const scriptMap = await this.getScriptMap();
|
|
243
274
|
const f = filter ?? { withRecoverable: true, withUnrolled: false };
|
|
244
275
|
const allExtended = [];
|
|
245
|
-
//
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
if (
|
|
255
|
-
|
|
256
|
-
|
|
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;
|
|
287
|
+
if (isSpendable(vtxo)) {
|
|
288
|
+
if (!f.withRecoverable &&
|
|
289
|
+
(isRecoverable(vtxo) || isExpired(vtxo))) {
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
257
292
|
}
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
forfeitTapLeafScript: vtxoScript.forfeit(),
|
|
262
|
-
intentTapLeafScript: vtxoScript.forfeit(),
|
|
263
|
-
tapTree: vtxoScript.encode(),
|
|
264
|
-
});
|
|
293
|
+
else {
|
|
294
|
+
if (!f.withUnrolled || !vtxo.isUnrolled)
|
|
295
|
+
continue;
|
|
265
296
|
}
|
|
297
|
+
allExtended.push({
|
|
298
|
+
...vtxo,
|
|
299
|
+
forfeitTapLeafScript: vtxoScript.forfeit(),
|
|
300
|
+
intentTapLeafScript: vtxoScript.forfeit(),
|
|
301
|
+
tapTree: vtxoScript.encode(),
|
|
302
|
+
});
|
|
266
303
|
}
|
|
267
304
|
// Update cache with fresh data
|
|
268
305
|
await this.walletRepository.saveVtxos(address, allExtended);
|
|
@@ -274,7 +311,7 @@ export class ReadonlyWallet {
|
|
|
274
311
|
const { boardingTxs, commitmentsToIgnore } = await this.getBoardingTxs();
|
|
275
312
|
const getTxCreatedAt = (txid) => this.indexerProvider
|
|
276
313
|
.getVtxos({ outpoints: [{ txid, vout: 0 }] })
|
|
277
|
-
.then((res) => res.vtxos[0]?.createdAt.getTime()
|
|
314
|
+
.then((res) => res.vtxos[0]?.createdAt.getTime());
|
|
278
315
|
return buildTransactionHistory(response.vtxos, boardingTxs, commitmentsToIgnore, getTxCreatedAt);
|
|
279
316
|
}
|
|
280
317
|
async getBoardingTxs() {
|
|
@@ -658,6 +695,20 @@ export class ReadonlyWallet {
|
|
|
658
695
|
* ```
|
|
659
696
|
*/
|
|
660
697
|
export class Wallet extends ReadonlyWallet {
|
|
698
|
+
_withTxLock(fn) {
|
|
699
|
+
let release;
|
|
700
|
+
const lock = new Promise((r) => (release = r));
|
|
701
|
+
const prev = this._txLock;
|
|
702
|
+
this._txLock = lock;
|
|
703
|
+
return prev.then(async () => {
|
|
704
|
+
try {
|
|
705
|
+
return await fn();
|
|
706
|
+
}
|
|
707
|
+
finally {
|
|
708
|
+
release();
|
|
709
|
+
}
|
|
710
|
+
});
|
|
711
|
+
}
|
|
661
712
|
constructor(identity, network, networkName, onchainProvider, arkProvider, indexerProvider, arkServerPublicKey, offchainTapscript, boardingTapscript, serverUnrollScript, forfeitOutputScript, forfeitPubkey, dustAmount, walletRepository, contractRepository,
|
|
662
713
|
/** @deprecated Use settlementConfig */
|
|
663
714
|
renewalConfig, delegatorProvider, watcherConfig, settlementConfig) {
|
|
@@ -667,6 +718,13 @@ export class Wallet extends ReadonlyWallet {
|
|
|
667
718
|
this.serverUnrollScript = serverUnrollScript;
|
|
668
719
|
this.forfeitOutputScript = forfeitOutputScript;
|
|
669
720
|
this.forfeitPubkey = forfeitPubkey;
|
|
721
|
+
/**
|
|
722
|
+
* Async mutex that serializes all operations submitting VTXOs to the Ark
|
|
723
|
+
* server (`settle`, `send`, `sendBitcoin`). This prevents VtxoManager's
|
|
724
|
+
* background renewal from racing with user-initiated transactions for the
|
|
725
|
+
* same VTXO inputs.
|
|
726
|
+
*/
|
|
727
|
+
this._txLock = Promise.resolve();
|
|
670
728
|
this.identity = identity;
|
|
671
729
|
// Backwards-compatible: keep renewalConfig populated for any code reading it
|
|
672
730
|
this.renewalConfig = {
|
|
@@ -805,40 +863,42 @@ export class Wallet extends ReadonlyWallet {
|
|
|
805
863
|
throw new Error("Invalid Ark address " + params.address);
|
|
806
864
|
}
|
|
807
865
|
if (params.selectedVtxos && params.selectedVtxos.length > 0) {
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
866
|
+
return this._withTxLock(async () => {
|
|
867
|
+
const selectedVtxoSum = params
|
|
868
|
+
.selectedVtxos.map((v) => v.value)
|
|
869
|
+
.reduce((a, b) => a + b, 0);
|
|
870
|
+
if (selectedVtxoSum < params.amount) {
|
|
871
|
+
throw new Error("Selected VTXOs do not cover specified amount");
|
|
872
|
+
}
|
|
873
|
+
const changeAmount = selectedVtxoSum - params.amount;
|
|
874
|
+
const selected = {
|
|
875
|
+
inputs: params.selectedVtxos,
|
|
876
|
+
changeAmount: BigInt(changeAmount),
|
|
877
|
+
};
|
|
878
|
+
const outputAddress = ArkAddress.decode(params.address);
|
|
879
|
+
const outputScript = BigInt(params.amount) < this.dustAmount
|
|
880
|
+
? outputAddress.subdustPkScript
|
|
881
|
+
: outputAddress.pkScript;
|
|
882
|
+
const outputs = [
|
|
883
|
+
{
|
|
884
|
+
script: outputScript,
|
|
885
|
+
amount: BigInt(params.amount),
|
|
886
|
+
},
|
|
887
|
+
];
|
|
888
|
+
// add change output if needed
|
|
889
|
+
if (selected.changeAmount > 0n) {
|
|
890
|
+
const changeOutputScript = selected.changeAmount < this.dustAmount
|
|
891
|
+
? this.arkAddress.subdustPkScript
|
|
892
|
+
: this.arkAddress.pkScript;
|
|
893
|
+
outputs.push({
|
|
894
|
+
script: changeOutputScript,
|
|
895
|
+
amount: BigInt(selected.changeAmount),
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
const { arkTxid, signedCheckpointTxs } = await this.buildAndSubmitOffchainTx(selected.inputs, outputs);
|
|
899
|
+
await this.updateDbAfterOffchainTx(selected.inputs, arkTxid, signedCheckpointTxs, params.amount, selected.changeAmount, selected.changeAmount > 0n ? outputs.length - 1 : 0);
|
|
900
|
+
return arkTxid;
|
|
901
|
+
});
|
|
842
902
|
}
|
|
843
903
|
return this.send({
|
|
844
904
|
address: params.address,
|
|
@@ -846,6 +906,9 @@ export class Wallet extends ReadonlyWallet {
|
|
|
846
906
|
});
|
|
847
907
|
}
|
|
848
908
|
async settle(params, eventCallback) {
|
|
909
|
+
return this._withTxLock(() => this._settleImpl(params, eventCallback));
|
|
910
|
+
}
|
|
911
|
+
async _settleImpl(params, eventCallback) {
|
|
849
912
|
if (params?.inputs) {
|
|
850
913
|
for (const input of params.inputs) {
|
|
851
914
|
// validate arknotes inputs
|
|
@@ -1281,23 +1344,29 @@ export class Wallet extends ReadonlyWallet {
|
|
|
1281
1344
|
async finalizePendingTxs(vtxos) {
|
|
1282
1345
|
const MAX_INPUTS_PER_INTENT = 20;
|
|
1283
1346
|
if (!vtxos || vtxos.length === 0) {
|
|
1284
|
-
//
|
|
1347
|
+
// Batch all scripts into a single indexer call
|
|
1285
1348
|
const scriptMap = await this.getScriptMap();
|
|
1286
1349
|
const allExtended = [];
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1350
|
+
const allScripts = [...scriptMap.keys()];
|
|
1351
|
+
const { vtxos: fetchedVtxos } = await this.indexerProvider.getVtxos({
|
|
1352
|
+
scripts: allScripts,
|
|
1353
|
+
});
|
|
1354
|
+
for (const vtxo of fetchedVtxos) {
|
|
1355
|
+
const vtxoScript = vtxo.script
|
|
1356
|
+
? scriptMap.get(vtxo.script)
|
|
1357
|
+
: undefined;
|
|
1358
|
+
if (!vtxoScript)
|
|
1359
|
+
continue;
|
|
1360
|
+
if (vtxo.virtualStatus.state === "swept" ||
|
|
1361
|
+
vtxo.virtualStatus.state === "settled") {
|
|
1362
|
+
continue;
|
|
1300
1363
|
}
|
|
1364
|
+
allExtended.push({
|
|
1365
|
+
...vtxo,
|
|
1366
|
+
forfeitTapLeafScript: vtxoScript.forfeit(),
|
|
1367
|
+
intentTapLeafScript: vtxoScript.forfeit(),
|
|
1368
|
+
tapTree: vtxoScript.encode(),
|
|
1369
|
+
});
|
|
1301
1370
|
}
|
|
1302
1371
|
if (allExtended.length === 0) {
|
|
1303
1372
|
return { finalized: [], pending: [] };
|
|
@@ -1347,6 +1416,9 @@ export class Wallet extends ReadonlyWallet {
|
|
|
1347
1416
|
* ```
|
|
1348
1417
|
*/
|
|
1349
1418
|
async send(...args) {
|
|
1419
|
+
return this._withTxLock(() => this._sendImpl(...args));
|
|
1420
|
+
}
|
|
1421
|
+
async _sendImpl(...args) {
|
|
1350
1422
|
if (args.length === 0) {
|
|
1351
1423
|
throw new Error("At least one receiver is required");
|
|
1352
1424
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export class MessageBusNotInitializedError extends Error {
|
|
2
|
+
constructor() {
|
|
3
|
+
super("MessageBus not initialized");
|
|
4
|
+
this.name = "MessageBusNotInitializedError";
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
export class ServiceWorkerTimeoutError extends Error {
|
|
8
|
+
constructor(detail) {
|
|
9
|
+
super(detail);
|
|
10
|
+
this.name = "ServiceWorkerTimeoutError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -5,6 +5,7 @@ import { RestDelegatorProvider } from '../providers/delegator.js';
|
|
|
5
5
|
import { ReadonlySingleKey, SingleKey } from '../identity/index.js';
|
|
6
6
|
import { ReadonlyWallet, Wallet } from '../wallet/wallet.js';
|
|
7
7
|
import { hex } from "@scure/base";
|
|
8
|
+
import { MessageBusNotInitializedError, ServiceWorkerTimeoutError, } from './errors.js';
|
|
8
9
|
export class MessageBus {
|
|
9
10
|
constructor(walletRepository, contractRepository, { messageHandlers, tickIntervalMs = 10000, messageTimeoutMs = 30000, debug = false, buildServices, }) {
|
|
10
11
|
this.walletRepository = walletRepository;
|
|
@@ -148,8 +149,12 @@ export class MessageBus {
|
|
|
148
149
|
identity,
|
|
149
150
|
arkServerUrl: config.arkServer.url,
|
|
150
151
|
arkServerPublicKey: config.arkServer.publicKey,
|
|
152
|
+
indexerUrl: config.indexerUrl,
|
|
153
|
+
esploraUrl: config.esploraUrl,
|
|
151
154
|
storage,
|
|
152
155
|
delegatorProvider,
|
|
156
|
+
settlementConfig: config.settlementConfig,
|
|
157
|
+
watcherConfig: config.watcherConfig,
|
|
153
158
|
});
|
|
154
159
|
return { wallet, arkProvider, readonlyWallet: wallet };
|
|
155
160
|
}
|
|
@@ -159,8 +164,11 @@ export class MessageBus {
|
|
|
159
164
|
identity,
|
|
160
165
|
arkServerUrl: config.arkServer.url,
|
|
161
166
|
arkServerPublicKey: config.arkServer.publicKey,
|
|
167
|
+
indexerUrl: config.indexerUrl,
|
|
168
|
+
esploraUrl: config.esploraUrl,
|
|
162
169
|
storage,
|
|
163
170
|
delegatorProvider,
|
|
171
|
+
watcherConfig: config.watcherConfig,
|
|
164
172
|
});
|
|
165
173
|
return { readonlyWallet, arkProvider };
|
|
166
174
|
}
|
|
@@ -180,6 +188,10 @@ export class MessageBus {
|
|
|
180
188
|
}
|
|
181
189
|
async processMessage(event) {
|
|
182
190
|
const { id, tag, broadcast } = event.data;
|
|
191
|
+
if (tag === "PING") {
|
|
192
|
+
event.source?.postMessage({ id, tag: "PONG" });
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
183
195
|
if (tag === "INITIALIZE_MESSAGE_BUS") {
|
|
184
196
|
if (this.debug) {
|
|
185
197
|
console.log("Init Command received");
|
|
@@ -204,7 +216,7 @@ export class MessageBus {
|
|
|
204
216
|
event.source?.postMessage({
|
|
205
217
|
id,
|
|
206
218
|
tag: tag ?? "unknown",
|
|
207
|
-
error: new
|
|
219
|
+
error: new MessageBusNotInitializedError(),
|
|
208
220
|
});
|
|
209
221
|
return;
|
|
210
222
|
}
|
|
@@ -275,7 +287,7 @@ export class MessageBus {
|
|
|
275
287
|
return promise;
|
|
276
288
|
return new Promise((resolve, reject) => {
|
|
277
289
|
const timer = self.setTimeout(() => {
|
|
278
|
-
reject(new
|
|
290
|
+
reject(new ServiceWorkerTimeoutError(`Message handler timed out after ${this.messageTimeoutMs}ms (${label})`));
|
|
279
291
|
}, this.messageTimeoutMs);
|
|
280
292
|
promise.then((val) => {
|
|
281
293
|
self.clearTimeout(timer);
|