@arkade-os/sdk 0.4.5 → 0.4.6
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/README.md +67 -6
- package/dist/cjs/utils/arkTransaction.js +7 -3
- package/dist/cjs/wallet/expo/wallet.js +1 -0
- package/dist/cjs/wallet/serviceWorker/wallet-message-handler.js +14 -0
- package/dist/cjs/wallet/vtxo-manager.js +380 -6
- package/dist/cjs/wallet/wallet.js +85 -3
- package/dist/esm/utils/arkTransaction.js +7 -3
- package/dist/esm/wallet/expo/wallet.js +1 -0
- package/dist/esm/wallet/serviceWorker/wallet-message-handler.js +14 -0
- package/dist/esm/wallet/vtxo-manager.js +379 -5
- package/dist/esm/wallet/wallet.js +86 -4
- package/dist/types/index.d.ts +2 -1
- package/dist/types/utils/arkTransaction.d.ts +1 -1
- package/dist/types/wallet/index.d.ts +12 -6
- package/dist/types/wallet/vtxo-manager.d.ts +166 -3
- package/dist/types/wallet/wallet.d.ts +12 -1
- package/package.json +1 -1
|
@@ -616,6 +616,18 @@ class ReadonlyWallet {
|
|
|
616
616
|
}
|
|
617
617
|
return manager;
|
|
618
618
|
}
|
|
619
|
+
async dispose() {
|
|
620
|
+
const manager = this._contractManager ??
|
|
621
|
+
(this._contractManagerInitializing
|
|
622
|
+
? await this._contractManagerInitializing.catch(() => undefined)
|
|
623
|
+
: undefined);
|
|
624
|
+
manager?.dispose();
|
|
625
|
+
this._contractManager = undefined;
|
|
626
|
+
this._contractManagerInitializing = undefined;
|
|
627
|
+
}
|
|
628
|
+
async [Symbol.asyncDispose]() {
|
|
629
|
+
await this.dispose();
|
|
630
|
+
}
|
|
619
631
|
}
|
|
620
632
|
exports.ReadonlyWallet = ReadonlyWallet;
|
|
621
633
|
/**
|
|
@@ -652,7 +664,9 @@ exports.ReadonlyWallet = ReadonlyWallet;
|
|
|
652
664
|
* ```
|
|
653
665
|
*/
|
|
654
666
|
class Wallet extends ReadonlyWallet {
|
|
655
|
-
constructor(identity, network, networkName, onchainProvider, arkProvider, indexerProvider, arkServerPublicKey, offchainTapscript, boardingTapscript, serverUnrollScript, forfeitOutputScript, forfeitPubkey, dustAmount, walletRepository, contractRepository,
|
|
667
|
+
constructor(identity, network, networkName, onchainProvider, arkProvider, indexerProvider, arkServerPublicKey, offchainTapscript, boardingTapscript, serverUnrollScript, forfeitOutputScript, forfeitPubkey, dustAmount, walletRepository, contractRepository,
|
|
668
|
+
/** @deprecated Use settlementConfig */
|
|
669
|
+
renewalConfig, delegatorProvider, watcherConfig, settlementConfig) {
|
|
656
670
|
super(identity, network, onchainProvider, indexerProvider, arkServerPublicKey, offchainTapscript, boardingTapscript, dustAmount, walletRepository, contractRepository, delegatorProvider, watcherConfig);
|
|
657
671
|
this.networkName = networkName;
|
|
658
672
|
this.arkProvider = arkProvider;
|
|
@@ -660,11 +674,31 @@ class Wallet extends ReadonlyWallet {
|
|
|
660
674
|
this.forfeitOutputScript = forfeitOutputScript;
|
|
661
675
|
this.forfeitPubkey = forfeitPubkey;
|
|
662
676
|
this.identity = identity;
|
|
677
|
+
// Backwards-compatible: keep renewalConfig populated for any code reading it
|
|
663
678
|
this.renewalConfig = {
|
|
664
679
|
enabled: renewalConfig?.enabled ?? false,
|
|
665
680
|
...vtxo_manager_1.DEFAULT_RENEWAL_CONFIG,
|
|
666
681
|
...renewalConfig,
|
|
667
682
|
};
|
|
683
|
+
// Normalize: prefer settlementConfig, fall back to renewalConfig, default to enabled
|
|
684
|
+
if (settlementConfig !== undefined) {
|
|
685
|
+
this.settlementConfig = settlementConfig;
|
|
686
|
+
}
|
|
687
|
+
else if (renewalConfig && this.renewalConfig.enabled) {
|
|
688
|
+
this.settlementConfig = {
|
|
689
|
+
vtxoThreshold: renewalConfig.thresholdMs
|
|
690
|
+
? renewalConfig.thresholdMs / 1000
|
|
691
|
+
: undefined,
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
else if (renewalConfig) {
|
|
695
|
+
// renewalConfig provided but not enabled → disabled
|
|
696
|
+
this.settlementConfig = false;
|
|
697
|
+
}
|
|
698
|
+
else {
|
|
699
|
+
// No config at all → enabled by default
|
|
700
|
+
this.settlementConfig = { ...vtxo_manager_1.DEFAULT_SETTLEMENT_CONFIG };
|
|
701
|
+
}
|
|
668
702
|
this._delegatorManager = delegatorProvider
|
|
669
703
|
? new delegator_1.DelegatorManagerImpl(delegatorProvider, arkProvider, identity)
|
|
670
704
|
: undefined;
|
|
@@ -673,6 +707,46 @@ class Wallet extends ReadonlyWallet {
|
|
|
673
707
|
this._walletAssetManager ?? (this._walletAssetManager = new asset_manager_1.AssetManager(this));
|
|
674
708
|
return this._walletAssetManager;
|
|
675
709
|
}
|
|
710
|
+
async getVtxoManager() {
|
|
711
|
+
if (this._vtxoManager) {
|
|
712
|
+
return this._vtxoManager;
|
|
713
|
+
}
|
|
714
|
+
if (this._vtxoManagerInitializing) {
|
|
715
|
+
return this._vtxoManagerInitializing;
|
|
716
|
+
}
|
|
717
|
+
this._vtxoManagerInitializing = Promise.resolve(new vtxo_manager_1.VtxoManager(this, this.renewalConfig, this.settlementConfig));
|
|
718
|
+
try {
|
|
719
|
+
const manager = await this._vtxoManagerInitializing;
|
|
720
|
+
this._vtxoManager = manager;
|
|
721
|
+
return manager;
|
|
722
|
+
}
|
|
723
|
+
catch (error) {
|
|
724
|
+
this._vtxoManagerInitializing = undefined;
|
|
725
|
+
throw error;
|
|
726
|
+
}
|
|
727
|
+
finally {
|
|
728
|
+
this._vtxoManagerInitializing = undefined;
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
async dispose() {
|
|
732
|
+
const manager = this._vtxoManager ??
|
|
733
|
+
(this._vtxoManagerInitializing
|
|
734
|
+
? await this._vtxoManagerInitializing.catch(() => undefined)
|
|
735
|
+
: undefined);
|
|
736
|
+
try {
|
|
737
|
+
if (manager) {
|
|
738
|
+
await manager.dispose();
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
catch {
|
|
742
|
+
// best-effort teardown; ensure super.dispose() still runs
|
|
743
|
+
}
|
|
744
|
+
finally {
|
|
745
|
+
this._vtxoManager = undefined;
|
|
746
|
+
this._vtxoManagerInitializing = undefined;
|
|
747
|
+
await super.dispose();
|
|
748
|
+
}
|
|
749
|
+
}
|
|
676
750
|
static async create(config) {
|
|
677
751
|
const pubkey = await config.identity.xOnlyPublicKey();
|
|
678
752
|
if (!pubkey) {
|
|
@@ -694,7 +768,9 @@ class Wallet extends ReadonlyWallet {
|
|
|
694
768
|
const forfeitPubkey = base_1.hex.decode(setup.info.forfeitPubkey).slice(1);
|
|
695
769
|
const forfeitAddress = (0, btc_signer_1.Address)(setup.network).decode(setup.info.forfeitAddress);
|
|
696
770
|
const forfeitOutputScript = btc_signer_1.OutScript.encode(forfeitAddress);
|
|
697
|
-
|
|
771
|
+
const wallet = new Wallet(config.identity, setup.network, setup.networkName, setup.onchainProvider, setup.arkProvider, setup.indexerProvider, setup.serverPubKey, setup.offchainTapscript, setup.boardingTapscript, serverUnrollScript, forfeitOutputScript, forfeitPubkey, setup.dustAmount, setup.walletRepository, setup.contractRepository, config.renewalConfig, config.delegatorProvider, config.watcherConfig, config.settlementConfig);
|
|
772
|
+
await wallet.getVtxoManager();
|
|
773
|
+
return wallet;
|
|
698
774
|
}
|
|
699
775
|
/**
|
|
700
776
|
* Convert this wallet to a readonly wallet.
|
|
@@ -797,7 +873,13 @@ class Wallet extends ReadonlyWallet {
|
|
|
797
873
|
let amount = 0;
|
|
798
874
|
const exitScript = tapscript_1.CSVMultisigTapscript.decode(base_1.hex.decode(this.boardingTapscript.exitScript));
|
|
799
875
|
const boardingTimelock = exitScript.params.timelock;
|
|
800
|
-
|
|
876
|
+
// For block-based timelocks, fetch the chain tip height
|
|
877
|
+
let chainTipHeight;
|
|
878
|
+
if (boardingTimelock.type === "blocks") {
|
|
879
|
+
const tip = await this.onchainProvider.getChainTip();
|
|
880
|
+
chainTipHeight = tip.height;
|
|
881
|
+
}
|
|
882
|
+
const boardingUtxos = (await this.getBoardingUtxos()).filter((utxo) => !(0, arkTransaction_1.hasBoardingTxExpired)(utxo, boardingTimelock, chainTipHeight));
|
|
801
883
|
const filteredBoardingUtxos = [];
|
|
802
884
|
for (const utxo of boardingUtxos) {
|
|
803
885
|
const inputFee = estimator.evalOnchainInput({
|
|
@@ -114,13 +114,17 @@ const nLocktimeMinSeconds = 500000000n;
|
|
|
114
114
|
function isSeconds(locktime) {
|
|
115
115
|
return locktime >= nLocktimeMinSeconds;
|
|
116
116
|
}
|
|
117
|
-
export function hasBoardingTxExpired(coin, boardingTimelock) {
|
|
117
|
+
export function hasBoardingTxExpired(coin, boardingTimelock, chainTipHeight) {
|
|
118
118
|
if (!coin.status.block_time)
|
|
119
119
|
return false;
|
|
120
120
|
if (boardingTimelock.value === 0n)
|
|
121
121
|
return true;
|
|
122
|
-
if (boardingTimelock.type === "blocks")
|
|
123
|
-
|
|
122
|
+
if (boardingTimelock.type === "blocks") {
|
|
123
|
+
if (chainTipHeight === undefined || !coin.status.block_height)
|
|
124
|
+
return false;
|
|
125
|
+
return (BigInt(chainTipHeight - coin.status.block_height) >=
|
|
126
|
+
boardingTimelock.value);
|
|
127
|
+
}
|
|
124
128
|
// validate expiry in terms of seconds
|
|
125
129
|
const now = BigInt(Math.floor(Date.now() / 1000));
|
|
126
130
|
const blockTime = BigInt(Math.floor(coin.status.block_time));
|
|
@@ -577,6 +577,20 @@ export class WalletMessageHandler {
|
|
|
577
577
|
this.contractEventsSubscription();
|
|
578
578
|
this.contractEventsSubscription = undefined;
|
|
579
579
|
}
|
|
580
|
+
// Dispose the wallet to stop the ContractWatcher (and its polling
|
|
581
|
+
// intervals) before clearing the repositories, otherwise the poller
|
|
582
|
+
// will hit a closing IndexedDB connection.
|
|
583
|
+
try {
|
|
584
|
+
if (this.wallet) {
|
|
585
|
+
await this.wallet.dispose();
|
|
586
|
+
}
|
|
587
|
+
else {
|
|
588
|
+
await this.readonlyWallet.dispose();
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
catch (_) {
|
|
592
|
+
// best-effort teardown
|
|
593
|
+
}
|
|
580
594
|
try {
|
|
581
595
|
await this.walletRepository?.clear();
|
|
582
596
|
}
|
|
@@ -1,11 +1,40 @@
|
|
|
1
1
|
import { isExpired, isRecoverable, isSpendable, isSubdust, } from './index.js';
|
|
2
|
+
import { hasBoardingTxExpired } from '../utils/arkTransaction.js';
|
|
3
|
+
import { CSVMultisigTapscript } from '../script/tapscript.js';
|
|
4
|
+
import { hex } from "@scure/base";
|
|
5
|
+
import { getSequence } from '../script/base.js';
|
|
6
|
+
import { Transaction } from '../utils/transaction.js';
|
|
7
|
+
import { TxWeightEstimator } from '../utils/txSizeEstimator.js';
|
|
8
|
+
/** Type guard to check if a wallet has the properties needed for sweep operations. */
|
|
9
|
+
function isSweepCapable(wallet) {
|
|
10
|
+
return ("boardingTapscript" in wallet &&
|
|
11
|
+
"onchainProvider" in wallet &&
|
|
12
|
+
"network" in wallet);
|
|
13
|
+
}
|
|
14
|
+
/** Asserts that the wallet supports sweep operations, throwing a clear error if not. */
|
|
15
|
+
function assertSweepCapable(wallet) {
|
|
16
|
+
if (!isSweepCapable(wallet)) {
|
|
17
|
+
throw new Error("Boarding UTXO sweep requires a Wallet instance with boardingTapscript, onchainProvider, and network");
|
|
18
|
+
}
|
|
19
|
+
}
|
|
2
20
|
export const DEFAULT_THRESHOLD_MS = 3 * 24 * 60 * 60 * 1000; // 3 days
|
|
21
|
+
export const DEFAULT_THRESHOLD_SECONDS = 3 * 24 * 60 * 60; // 3 days
|
|
3
22
|
/**
|
|
4
23
|
* Default renewal configuration values
|
|
24
|
+
* @deprecated Use DEFAULT_SETTLEMENT_CONFIG instead
|
|
5
25
|
*/
|
|
6
26
|
export const DEFAULT_RENEWAL_CONFIG = {
|
|
7
27
|
thresholdMs: DEFAULT_THRESHOLD_MS, // 3 days
|
|
8
28
|
};
|
|
29
|
+
/**
|
|
30
|
+
* Default settlement configuration values
|
|
31
|
+
*/
|
|
32
|
+
export const DEFAULT_SETTLEMENT_CONFIG = {
|
|
33
|
+
vtxoThreshold: DEFAULT_THRESHOLD_SECONDS,
|
|
34
|
+
boardingUtxoSweep: true,
|
|
35
|
+
pollIntervalMs: 60000,
|
|
36
|
+
};
|
|
37
|
+
/** Extracts the dust amount from the wallet, defaulting to 330 sats. */
|
|
9
38
|
function getDustAmount(wallet) {
|
|
10
39
|
return "dustAmount" in wallet ? wallet.dustAmount : 330n;
|
|
11
40
|
}
|
|
@@ -152,9 +181,38 @@ export function getExpiringAndRecoverableVtxos(vtxos, thresholdMs, dustAmount) {
|
|
|
152
181
|
* ```
|
|
153
182
|
*/
|
|
154
183
|
export class VtxoManager {
|
|
155
|
-
constructor(wallet,
|
|
184
|
+
constructor(wallet,
|
|
185
|
+
/** @deprecated Use settlementConfig instead */
|
|
186
|
+
renewalConfig, settlementConfig) {
|
|
156
187
|
this.wallet = wallet;
|
|
157
188
|
this.renewalConfig = renewalConfig;
|
|
189
|
+
this.knownBoardingUtxos = new Set();
|
|
190
|
+
this.sweptBoardingUtxos = new Set();
|
|
191
|
+
this.pollInProgress = false;
|
|
192
|
+
// Normalize: prefer settlementConfig, fall back to renewalConfig, default to enabled
|
|
193
|
+
if (settlementConfig !== undefined) {
|
|
194
|
+
this.settlementConfig = settlementConfig;
|
|
195
|
+
}
|
|
196
|
+
else if (renewalConfig && renewalConfig.enabled) {
|
|
197
|
+
this.settlementConfig = {
|
|
198
|
+
vtxoThreshold: renewalConfig.thresholdMs
|
|
199
|
+
? renewalConfig.thresholdMs / 1000
|
|
200
|
+
: undefined,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
else if (renewalConfig) {
|
|
204
|
+
// renewalConfig provided but not enabled → disabled
|
|
205
|
+
this.settlementConfig = false;
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
// No config at all → enabled by default
|
|
209
|
+
this.settlementConfig = { ...DEFAULT_SETTLEMENT_CONFIG };
|
|
210
|
+
}
|
|
211
|
+
this.contractEventsSubscriptionReady =
|
|
212
|
+
this.initializeSubscription().then((subscription) => {
|
|
213
|
+
this.contractEventsSubscription = subscription;
|
|
214
|
+
return subscription;
|
|
215
|
+
});
|
|
158
216
|
}
|
|
159
217
|
// ========== Recovery Methods ==========
|
|
160
218
|
/**
|
|
@@ -266,10 +324,26 @@ export class VtxoManager {
|
|
|
266
324
|
* ```
|
|
267
325
|
*/
|
|
268
326
|
async getExpiringVtxos(thresholdMs) {
|
|
327
|
+
// If settlementConfig is explicitly false and no override provided, renewal is disabled
|
|
328
|
+
if (this.settlementConfig === false && thresholdMs === undefined) {
|
|
329
|
+
return [];
|
|
330
|
+
}
|
|
269
331
|
const vtxos = await this.wallet.getVtxos({ withRecoverable: true });
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
332
|
+
// Resolve threshold: method param > settlementConfig (seconds→ms) > renewalConfig > default
|
|
333
|
+
let threshold;
|
|
334
|
+
if (thresholdMs !== undefined) {
|
|
335
|
+
threshold = thresholdMs;
|
|
336
|
+
}
|
|
337
|
+
else if (this.settlementConfig !== false &&
|
|
338
|
+
this.settlementConfig &&
|
|
339
|
+
this.settlementConfig.vtxoThreshold !== undefined) {
|
|
340
|
+
threshold = this.settlementConfig.vtxoThreshold * 1000;
|
|
341
|
+
}
|
|
342
|
+
else {
|
|
343
|
+
threshold =
|
|
344
|
+
this.renewalConfig?.thresholdMs ??
|
|
345
|
+
DEFAULT_RENEWAL_CONFIG.thresholdMs;
|
|
346
|
+
}
|
|
273
347
|
return getExpiringAndRecoverableVtxos(vtxos, threshold, getDustAmount(this.wallet));
|
|
274
348
|
}
|
|
275
349
|
/**
|
|
@@ -299,7 +373,11 @@ export class VtxoManager {
|
|
|
299
373
|
*/
|
|
300
374
|
async renewVtxos(eventCallback) {
|
|
301
375
|
// Get all VTXOs (including recoverable ones)
|
|
302
|
-
|
|
376
|
+
// Use default threshold to bypass settlementConfig gate (manual API should always work)
|
|
377
|
+
const vtxos = await this.getExpiringVtxos(this.settlementConfig !== false &&
|
|
378
|
+
this.settlementConfig?.vtxoThreshold !== undefined
|
|
379
|
+
? this.settlementConfig.vtxoThreshold * 1000
|
|
380
|
+
: DEFAULT_RENEWAL_CONFIG.thresholdMs);
|
|
303
381
|
if (vtxos.length === 0) {
|
|
304
382
|
throw new Error("No VTXOs available to renew");
|
|
305
383
|
}
|
|
@@ -321,4 +399,300 @@ export class VtxoManager {
|
|
|
321
399
|
],
|
|
322
400
|
}, eventCallback);
|
|
323
401
|
}
|
|
402
|
+
// ========== Boarding UTXO Sweep Methods ==========
|
|
403
|
+
/**
|
|
404
|
+
* Get boarding UTXOs whose timelock has expired.
|
|
405
|
+
*
|
|
406
|
+
* These UTXOs can no longer be onboarded cooperatively via `settle()` and
|
|
407
|
+
* must be swept back to a fresh boarding address using the unilateral exit path.
|
|
408
|
+
*
|
|
409
|
+
* @returns Array of expired boarding UTXOs
|
|
410
|
+
*
|
|
411
|
+
* @example
|
|
412
|
+
* ```typescript
|
|
413
|
+
* const manager = new VtxoManager(wallet);
|
|
414
|
+
* const expired = await manager.getExpiredBoardingUtxos();
|
|
415
|
+
* if (expired.length > 0) {
|
|
416
|
+
* console.log(`${expired.length} expired boarding UTXOs to sweep`);
|
|
417
|
+
* }
|
|
418
|
+
* ```
|
|
419
|
+
*/
|
|
420
|
+
async getExpiredBoardingUtxos() {
|
|
421
|
+
const boardingUtxos = await this.wallet.getBoardingUtxos();
|
|
422
|
+
const boardingTimelock = this.getBoardingTimelock();
|
|
423
|
+
// For block-based timelocks, fetch the chain tip height
|
|
424
|
+
let chainTipHeight;
|
|
425
|
+
if (boardingTimelock.type === "blocks") {
|
|
426
|
+
const tip = await this.getOnchainProvider().getChainTip();
|
|
427
|
+
chainTipHeight = tip.height;
|
|
428
|
+
}
|
|
429
|
+
return boardingUtxos.filter((utxo) => hasBoardingTxExpired(utxo, boardingTimelock, chainTipHeight));
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Sweep expired boarding UTXOs back to a fresh boarding address via
|
|
433
|
+
* the unilateral exit path (on-chain self-spend).
|
|
434
|
+
*
|
|
435
|
+
* This builds a raw on-chain transaction that:
|
|
436
|
+
* - Uses all expired boarding UTXOs as inputs (spent via the CSV exit script path)
|
|
437
|
+
* - Has a single output to the wallet's boarding address (restarts the timelock)
|
|
438
|
+
* - Batches multiple expired UTXOs into one transaction
|
|
439
|
+
* - Skips the sweep if the output after fees would be below dust
|
|
440
|
+
*
|
|
441
|
+
* No Ark server involvement is needed — this is a pure on-chain transaction.
|
|
442
|
+
*
|
|
443
|
+
* @returns The broadcast transaction ID
|
|
444
|
+
* @throws Error if no expired boarding UTXOs found
|
|
445
|
+
* @throws Error if output after fees is below dust (not economical to sweep)
|
|
446
|
+
* @throws Error if boarding UTXO sweep is not enabled in settlementConfig
|
|
447
|
+
*
|
|
448
|
+
* @example
|
|
449
|
+
* ```typescript
|
|
450
|
+
* const manager = new VtxoManager(wallet, undefined, {
|
|
451
|
+
* boardingUtxoSweep: true,
|
|
452
|
+
* });
|
|
453
|
+
*
|
|
454
|
+
* try {
|
|
455
|
+
* const txid = await manager.sweepExpiredBoardingUtxos();
|
|
456
|
+
* console.log('Swept expired boarding UTXOs:', txid);
|
|
457
|
+
* } catch (e) {
|
|
458
|
+
* console.log('No sweep needed or not economical');
|
|
459
|
+
* }
|
|
460
|
+
* ```
|
|
461
|
+
*/
|
|
462
|
+
async sweepExpiredBoardingUtxos() {
|
|
463
|
+
const sweepEnabled = this.settlementConfig !== false &&
|
|
464
|
+
(this.settlementConfig?.boardingUtxoSweep ??
|
|
465
|
+
DEFAULT_SETTLEMENT_CONFIG.boardingUtxoSweep);
|
|
466
|
+
if (!sweepEnabled) {
|
|
467
|
+
throw new Error("Boarding UTXO sweep is not enabled in settlementConfig");
|
|
468
|
+
}
|
|
469
|
+
const allExpired = await this.getExpiredBoardingUtxos();
|
|
470
|
+
// Filter out UTXOs already swept (tx broadcast but not yet confirmed)
|
|
471
|
+
const expiredUtxos = allExpired.filter((u) => !this.sweptBoardingUtxos.has(`${u.txid}:${u.vout}`));
|
|
472
|
+
if (expiredUtxos.length === 0) {
|
|
473
|
+
throw new Error("No expired boarding UTXOs to sweep");
|
|
474
|
+
}
|
|
475
|
+
const boardingAddress = await this.wallet.getBoardingAddress();
|
|
476
|
+
// Get fee rate from onchain provider
|
|
477
|
+
const feeRate = (await this.getOnchainProvider().getFeeRate()) ?? 1;
|
|
478
|
+
// Get the exit tap leaf script for signing
|
|
479
|
+
const exitTapLeafScript = this.getBoardingExitLeaf();
|
|
480
|
+
// Estimate transaction size for fee calculation
|
|
481
|
+
const sequence = getSequence(exitTapLeafScript);
|
|
482
|
+
// TapLeafScript: [{version, internalKey, merklePath}, scriptWithVersion]
|
|
483
|
+
const leafScript = exitTapLeafScript[1];
|
|
484
|
+
const leafScriptSize = leafScript.length - 1; // minus version byte
|
|
485
|
+
const controlBlockSize = exitTapLeafScript[0].merklePath.length * 32;
|
|
486
|
+
// Exit path witness: 1 Schnorr signature (64 bytes)
|
|
487
|
+
const leafWitnessSize = 64;
|
|
488
|
+
const estimator = TxWeightEstimator.create();
|
|
489
|
+
for (const _ of expiredUtxos) {
|
|
490
|
+
estimator.addTapscriptInput(leafWitnessSize, leafScriptSize, controlBlockSize);
|
|
491
|
+
}
|
|
492
|
+
estimator.addOutputAddress(boardingAddress, this.getNetwork());
|
|
493
|
+
const fee = Math.ceil(Number(estimator.vsize().value) * feeRate);
|
|
494
|
+
const totalValue = expiredUtxos.reduce((sum, utxo) => sum + BigInt(utxo.value), 0n);
|
|
495
|
+
const outputAmount = totalValue - BigInt(fee);
|
|
496
|
+
// Dust check: skip if output after fees is below dust
|
|
497
|
+
const dustAmount = getDustAmount(this.wallet);
|
|
498
|
+
if (outputAmount < dustAmount) {
|
|
499
|
+
throw new Error(`Sweep not economical: output ${outputAmount} sats after ${fee} sats fee is below dust (${dustAmount} sats)`);
|
|
500
|
+
}
|
|
501
|
+
// Build the raw transaction
|
|
502
|
+
const tx = new Transaction();
|
|
503
|
+
for (const utxo of expiredUtxos) {
|
|
504
|
+
tx.addInput({
|
|
505
|
+
txid: utxo.txid,
|
|
506
|
+
index: utxo.vout,
|
|
507
|
+
witnessUtxo: {
|
|
508
|
+
script: this.getBoardingOutputScript(),
|
|
509
|
+
amount: BigInt(utxo.value),
|
|
510
|
+
},
|
|
511
|
+
tapLeafScript: [exitTapLeafScript],
|
|
512
|
+
sequence,
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
tx.addOutputAddress(boardingAddress, outputAmount, this.getNetwork());
|
|
516
|
+
// Sign and finalize
|
|
517
|
+
const signedTx = await this.getIdentity().sign(tx);
|
|
518
|
+
signedTx.finalize();
|
|
519
|
+
// Broadcast
|
|
520
|
+
const txid = await this.getOnchainProvider().broadcastTransaction(signedTx.hex);
|
|
521
|
+
// Mark UTXOs as swept to prevent duplicate broadcasts on next poll
|
|
522
|
+
for (const u of expiredUtxos) {
|
|
523
|
+
this.sweptBoardingUtxos.add(`${u.txid}:${u.vout}`);
|
|
524
|
+
}
|
|
525
|
+
// Mark the sweep output as "known" so the next poll doesn't try to
|
|
526
|
+
// auto-settle it back into Ark (it lands at the same boarding address).
|
|
527
|
+
this.knownBoardingUtxos.add(`${txid}:0`);
|
|
528
|
+
return txid;
|
|
529
|
+
}
|
|
530
|
+
// ========== Private Helpers ==========
|
|
531
|
+
/** Asserts sweep capability and returns the typed wallet. */
|
|
532
|
+
getSweepWallet() {
|
|
533
|
+
assertSweepCapable(this.wallet);
|
|
534
|
+
return this.wallet;
|
|
535
|
+
}
|
|
536
|
+
/** Decodes the boarding tapscript exit path to extract the CSV timelock. */
|
|
537
|
+
getBoardingTimelock() {
|
|
538
|
+
const wallet = this.getSweepWallet();
|
|
539
|
+
const exitScript = CSVMultisigTapscript.decode(hex.decode(wallet.boardingTapscript.exitScript));
|
|
540
|
+
return exitScript.params.timelock;
|
|
541
|
+
}
|
|
542
|
+
/** Returns the TapLeafScript for the boarding tapscript's exit (CSV) path. */
|
|
543
|
+
getBoardingExitLeaf() {
|
|
544
|
+
return this.getSweepWallet().boardingTapscript.exit();
|
|
545
|
+
}
|
|
546
|
+
/** Returns the pkScript (output script) of the boarding tapscript. */
|
|
547
|
+
getBoardingOutputScript() {
|
|
548
|
+
return this.getSweepWallet().boardingTapscript.pkScript;
|
|
549
|
+
}
|
|
550
|
+
/** Returns the on-chain provider for fee estimation and broadcasting. */
|
|
551
|
+
getOnchainProvider() {
|
|
552
|
+
return this.getSweepWallet().onchainProvider;
|
|
553
|
+
}
|
|
554
|
+
/** Returns the Bitcoin network configuration from the wallet. */
|
|
555
|
+
getNetwork() {
|
|
556
|
+
return this.getSweepWallet().network;
|
|
557
|
+
}
|
|
558
|
+
/** Returns the wallet's identity for transaction signing. */
|
|
559
|
+
getIdentity() {
|
|
560
|
+
return this.wallet.identity;
|
|
561
|
+
}
|
|
562
|
+
async initializeSubscription() {
|
|
563
|
+
if (this.settlementConfig === false) {
|
|
564
|
+
return undefined;
|
|
565
|
+
}
|
|
566
|
+
// Start polling for boarding UTXOs independently of contract manager
|
|
567
|
+
// SSE setup. Use a short delay to let the wallet finish construction.
|
|
568
|
+
setTimeout(() => this.startBoardingUtxoPoll(), 1000);
|
|
569
|
+
try {
|
|
570
|
+
const [delegatorManager, contractManager, destination] = await Promise.all([
|
|
571
|
+
this.wallet.getDelegatorManager(),
|
|
572
|
+
this.wallet.getContractManager(),
|
|
573
|
+
this.wallet.getAddress(),
|
|
574
|
+
]);
|
|
575
|
+
const stopWatching = contractManager.onContractEvent((event) => {
|
|
576
|
+
if (event.type !== "vtxo_received") {
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
this.renewVtxos().catch((e) => {
|
|
580
|
+
console.error("Error renewing VTXOs:", e);
|
|
581
|
+
});
|
|
582
|
+
delegatorManager
|
|
583
|
+
?.delegate(event.vtxos, destination)
|
|
584
|
+
.catch((e) => {
|
|
585
|
+
console.error("Error delegating VTXOs:", e);
|
|
586
|
+
});
|
|
587
|
+
});
|
|
588
|
+
return stopWatching;
|
|
589
|
+
}
|
|
590
|
+
catch (e) {
|
|
591
|
+
console.error("Error renewing VTXOs from VtxoManager", e);
|
|
592
|
+
return undefined;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* Starts a polling loop that:
|
|
597
|
+
* 1. Auto-settles new boarding UTXOs into Ark
|
|
598
|
+
* 2. Sweeps expired boarding UTXOs (when boardingUtxoSweep is enabled)
|
|
599
|
+
*/
|
|
600
|
+
startBoardingUtxoPoll() {
|
|
601
|
+
if (this.settlementConfig === false)
|
|
602
|
+
return;
|
|
603
|
+
const intervalMs = this.settlementConfig.pollIntervalMs ??
|
|
604
|
+
DEFAULT_SETTLEMENT_CONFIG.pollIntervalMs;
|
|
605
|
+
// Run once immediately, then on interval
|
|
606
|
+
this.pollBoardingUtxos();
|
|
607
|
+
this.pollIntervalId = setInterval(() => this.pollBoardingUtxos(), intervalMs);
|
|
608
|
+
}
|
|
609
|
+
async pollBoardingUtxos() {
|
|
610
|
+
// Guard: wallet must support boarding UTXO + sweep operations
|
|
611
|
+
if (!isSweepCapable(this.wallet))
|
|
612
|
+
return;
|
|
613
|
+
// Skip if a previous poll is still running
|
|
614
|
+
if (this.pollInProgress)
|
|
615
|
+
return;
|
|
616
|
+
this.pollInProgress = true;
|
|
617
|
+
try {
|
|
618
|
+
// Settle new (unexpired) UTXOs first, then sweep expired ones.
|
|
619
|
+
// Sequential to avoid racing for the same UTXOs.
|
|
620
|
+
try {
|
|
621
|
+
await this.settleBoardingUtxos();
|
|
622
|
+
}
|
|
623
|
+
catch (e) {
|
|
624
|
+
console.error("Error auto-settling boarding UTXOs:", e);
|
|
625
|
+
}
|
|
626
|
+
const sweepEnabled = this.settlementConfig !== false &&
|
|
627
|
+
(this.settlementConfig?.boardingUtxoSweep ??
|
|
628
|
+
DEFAULT_SETTLEMENT_CONFIG.boardingUtxoSweep);
|
|
629
|
+
if (sweepEnabled) {
|
|
630
|
+
try {
|
|
631
|
+
await this.sweepExpiredBoardingUtxos();
|
|
632
|
+
}
|
|
633
|
+
catch (e) {
|
|
634
|
+
if (!(e instanceof Error) ||
|
|
635
|
+
!e.message.includes("No expired boarding UTXOs")) {
|
|
636
|
+
console.error("Error auto-sweeping boarding UTXOs:", e);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
finally {
|
|
642
|
+
this.pollInProgress = false;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* Auto-settle new (unexpired) boarding UTXOs into the Ark.
|
|
647
|
+
* Skips UTXOs that are already expired (those are handled by sweep).
|
|
648
|
+
* Only settles UTXOs not already in-flight (tracked in knownBoardingUtxos).
|
|
649
|
+
* UTXOs are marked as known only after a successful settle, so failed
|
|
650
|
+
* attempts will be retried on the next poll.
|
|
651
|
+
*/
|
|
652
|
+
async settleBoardingUtxos() {
|
|
653
|
+
const boardingUtxos = await this.wallet.getBoardingUtxos();
|
|
654
|
+
// Exclude expired UTXOs — those should be swept, not settled.
|
|
655
|
+
// If we can't determine expired status, bail out entirely to avoid
|
|
656
|
+
// accidentally settling expired UTXOs (which would conflict with sweep).
|
|
657
|
+
let expiredSet;
|
|
658
|
+
try {
|
|
659
|
+
const expired = await this.getExpiredBoardingUtxos();
|
|
660
|
+
expiredSet = new Set(expired.map((u) => `${u.txid}:${u.vout}`));
|
|
661
|
+
}
|
|
662
|
+
catch {
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
const unsettledUtxos = boardingUtxos.filter((u) => !this.knownBoardingUtxos.has(`${u.txid}:${u.vout}`) &&
|
|
666
|
+
!expiredSet.has(`${u.txid}:${u.vout}`));
|
|
667
|
+
if (unsettledUtxos.length === 0)
|
|
668
|
+
return;
|
|
669
|
+
const dustAmount = getDustAmount(this.wallet);
|
|
670
|
+
const totalAmount = unsettledUtxos.reduce((sum, u) => sum + BigInt(u.value), 0n);
|
|
671
|
+
if (totalAmount < dustAmount)
|
|
672
|
+
return;
|
|
673
|
+
const arkAddress = await this.wallet.getAddress();
|
|
674
|
+
await this.wallet.settle({
|
|
675
|
+
inputs: unsettledUtxos,
|
|
676
|
+
outputs: [{ address: arkAddress, amount: totalAmount }],
|
|
677
|
+
});
|
|
678
|
+
// Mark as known only after successful settle
|
|
679
|
+
for (const u of unsettledUtxos) {
|
|
680
|
+
this.knownBoardingUtxos.add(`${u.txid}:${u.vout}`);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
async dispose() {
|
|
684
|
+
this.disposePromise ?? (this.disposePromise = (async () => {
|
|
685
|
+
if (this.pollIntervalId) {
|
|
686
|
+
clearInterval(this.pollIntervalId);
|
|
687
|
+
this.pollIntervalId = undefined;
|
|
688
|
+
}
|
|
689
|
+
const subscription = await this.contractEventsSubscriptionReady;
|
|
690
|
+
this.contractEventsSubscription = undefined;
|
|
691
|
+
subscription?.();
|
|
692
|
+
})());
|
|
693
|
+
return this.disposePromise;
|
|
694
|
+
}
|
|
695
|
+
async [Symbol.asyncDispose]() {
|
|
696
|
+
await this.dispose();
|
|
697
|
+
}
|
|
324
698
|
}
|