@arkade-os/sdk 0.4.5 → 0.4.7
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 +391 -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 +390 -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
package/README.md
CHANGED
|
@@ -310,21 +310,62 @@ const settleTxid = await wallet.settle({
|
|
|
310
310
|
|
|
311
311
|
### VTXO Management (Renewal & Recovery)
|
|
312
312
|
|
|
313
|
-
VTXOs have an expiration time (batch expiry). The SDK provides the `VtxoManager` class to handle
|
|
313
|
+
VTXOs have an expiration time (batch expiry). The SDK provides the `VtxoManager` class to handle:
|
|
314
314
|
|
|
315
315
|
- **Renewal**: Renew VTXOs before they expire to maintain unilateral control of the funds.
|
|
316
316
|
- **Recovery**: Reclaim swept or expired VTXOs back to the wallet in case renewal window was missed.
|
|
317
|
+
- **Boarding UTXO Sweep**: Sweep expired boarding UTXOs back to a fresh boarding address to restart the timelock.
|
|
318
|
+
|
|
319
|
+
#### Settlement Configuration
|
|
320
|
+
|
|
321
|
+
The recommended way to configure `VtxoManager` is via `settlementConfig` on the wallet.
|
|
322
|
+
If you omit `settlementConfig`, settlement is enabled with the default behavior:
|
|
323
|
+
VTXO renewal at 3 days and boarding UTXO sweep enabled.
|
|
317
324
|
|
|
318
325
|
```typescript
|
|
319
|
-
|
|
326
|
+
const wallet = await Wallet.create({
|
|
327
|
+
identity,
|
|
328
|
+
arkServerUrl: 'https://mutinynet.arkade.sh',
|
|
329
|
+
// Enable settlement with defaults explicitly
|
|
330
|
+
settlementConfig: {},
|
|
331
|
+
})
|
|
332
|
+
```
|
|
320
333
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
334
|
+
```typescript
|
|
335
|
+
// Enable both VTXO renewal and boarding UTXO sweep
|
|
336
|
+
const wallet = await Wallet.create({
|
|
337
|
+
identity,
|
|
338
|
+
arkServerUrl: 'https://mutinynet.arkade.sh',
|
|
339
|
+
settlementConfig: {
|
|
340
|
+
vtxoThreshold: 86400, // renew when 24 hours remain (in seconds)
|
|
341
|
+
boardingUtxoSweep: true, // sweep expired boarding UTXOs
|
|
342
|
+
},
|
|
325
343
|
})
|
|
326
344
|
```
|
|
327
345
|
|
|
346
|
+
```typescript
|
|
347
|
+
// Explicitly disable all settlement
|
|
348
|
+
const wallet = await Wallet.create({
|
|
349
|
+
identity,
|
|
350
|
+
arkServerUrl: 'https://mutinynet.arkade.sh',
|
|
351
|
+
settlementConfig: false,
|
|
352
|
+
})
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
Create the `VtxoManager` by passing the wallet and its settlement config:
|
|
356
|
+
|
|
357
|
+
```typescript
|
|
358
|
+
import { VtxoManager } from '@arkade-os/sdk'
|
|
359
|
+
|
|
360
|
+
const manager = new VtxoManager(
|
|
361
|
+
wallet,
|
|
362
|
+
undefined, // deprecated renewalConfig
|
|
363
|
+
wallet.settlementConfig // new settlementConfig
|
|
364
|
+
)
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
> **Migration from `renewalConfig`:** The old `renewalConfig` with `enabled` and `thresholdMs` (milliseconds) is still supported but deprecated. If both are provided, `settlementConfig` takes precedence. The new `vtxoThreshold` uses **seconds** instead of milliseconds.
|
|
368
|
+
|
|
328
369
|
#### Renewal: Prevent Expiration
|
|
329
370
|
|
|
330
371
|
Renew VTXOs before they expire to retain unilateral control of funds.
|
|
@@ -341,6 +382,26 @@ const expiringVtxos = await manager.getExpiringVtxos()
|
|
|
341
382
|
const urgentlyExpiring = await manager.getExpiringVtxos(5_000)
|
|
342
383
|
```
|
|
343
384
|
|
|
385
|
+
#### Boarding UTXO Sweep
|
|
386
|
+
|
|
387
|
+
When a boarding UTXO's CSV timelock expires, it can no longer be onboarded into Ark cooperatively. The sweep feature detects these expired UTXOs and builds a raw on-chain transaction that spends them via the unilateral exit path back to a fresh boarding address, restarting the timelock.
|
|
388
|
+
|
|
389
|
+
- Multiple expired UTXOs are batched into a single transaction (many inputs, one output)
|
|
390
|
+
- A dust check ensures the sweep is skipped if fees would consume the entire value
|
|
391
|
+
|
|
392
|
+
```typescript
|
|
393
|
+
// Check for expired boarding UTXOs
|
|
394
|
+
const expired = await manager.getExpiredBoardingUtxos()
|
|
395
|
+
console.log(`${expired.length} expired boarding UTXOs`)
|
|
396
|
+
|
|
397
|
+
// Sweep them back to a fresh boarding address (requires boardingUtxoSweep: true)
|
|
398
|
+
try {
|
|
399
|
+
const txid = await manager.sweepExpiredBoardingUtxos()
|
|
400
|
+
console.log('Swept expired boarding UTXOs:', txid)
|
|
401
|
+
} catch (e) {
|
|
402
|
+
// "No expired boarding UTXOs to sweep" or "Sweep not economical"
|
|
403
|
+
}
|
|
404
|
+
```
|
|
344
405
|
|
|
345
406
|
#### Recovery: Reclaim Swept VTXOs
|
|
346
407
|
|
|
@@ -121,13 +121,17 @@ const nLocktimeMinSeconds = 500000000n;
|
|
|
121
121
|
function isSeconds(locktime) {
|
|
122
122
|
return locktime >= nLocktimeMinSeconds;
|
|
123
123
|
}
|
|
124
|
-
function hasBoardingTxExpired(coin, boardingTimelock) {
|
|
124
|
+
function hasBoardingTxExpired(coin, boardingTimelock, chainTipHeight) {
|
|
125
125
|
if (!coin.status.block_time)
|
|
126
126
|
return false;
|
|
127
127
|
if (boardingTimelock.value === 0n)
|
|
128
128
|
return true;
|
|
129
|
-
if (boardingTimelock.type === "blocks")
|
|
130
|
-
|
|
129
|
+
if (boardingTimelock.type === "blocks") {
|
|
130
|
+
if (chainTipHeight === undefined || !coin.status.block_height)
|
|
131
|
+
return false;
|
|
132
|
+
return (BigInt(chainTipHeight - coin.status.block_height) >=
|
|
133
|
+
boardingTimelock.value);
|
|
134
|
+
}
|
|
131
135
|
// validate expiry in terms of seconds
|
|
132
136
|
const now = BigInt(Math.floor(Date.now() / 1000));
|
|
133
137
|
const blockTime = BigInt(Math.floor(coin.status.block_time));
|
|
@@ -580,6 +580,20 @@ class WalletMessageHandler {
|
|
|
580
580
|
this.contractEventsSubscription();
|
|
581
581
|
this.contractEventsSubscription = undefined;
|
|
582
582
|
}
|
|
583
|
+
// Dispose the wallet to stop the ContractWatcher (and its polling
|
|
584
|
+
// intervals) before clearing the repositories, otherwise the poller
|
|
585
|
+
// will hit a closing IndexedDB connection.
|
|
586
|
+
try {
|
|
587
|
+
if (this.wallet) {
|
|
588
|
+
await this.wallet.dispose();
|
|
589
|
+
}
|
|
590
|
+
else {
|
|
591
|
+
await this.readonlyWallet.dispose();
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
catch (_) {
|
|
595
|
+
// best-effort teardown
|
|
596
|
+
}
|
|
583
597
|
try {
|
|
584
598
|
await this.walletRepository?.clear();
|
|
585
599
|
}
|
|
@@ -1,16 +1,45 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.VtxoManager = exports.DEFAULT_RENEWAL_CONFIG = exports.DEFAULT_THRESHOLD_MS = void 0;
|
|
3
|
+
exports.VtxoManager = exports.DEFAULT_SETTLEMENT_CONFIG = exports.DEFAULT_RENEWAL_CONFIG = exports.DEFAULT_THRESHOLD_SECONDS = exports.DEFAULT_THRESHOLD_MS = void 0;
|
|
4
4
|
exports.isVtxoExpiringSoon = isVtxoExpiringSoon;
|
|
5
5
|
exports.getExpiringAndRecoverableVtxos = getExpiringAndRecoverableVtxos;
|
|
6
6
|
const _1 = require(".");
|
|
7
|
+
const arkTransaction_1 = require("../utils/arkTransaction");
|
|
8
|
+
const tapscript_1 = require("../script/tapscript");
|
|
9
|
+
const base_1 = require("@scure/base");
|
|
10
|
+
const base_2 = require("../script/base");
|
|
11
|
+
const transaction_1 = require("../utils/transaction");
|
|
12
|
+
const txSizeEstimator_1 = require("../utils/txSizeEstimator");
|
|
13
|
+
/** Type guard to check if a wallet has the properties needed for sweep operations. */
|
|
14
|
+
function isSweepCapable(wallet) {
|
|
15
|
+
return ("boardingTapscript" in wallet &&
|
|
16
|
+
"onchainProvider" in wallet &&
|
|
17
|
+
"network" in wallet);
|
|
18
|
+
}
|
|
19
|
+
/** Asserts that the wallet supports sweep operations, throwing a clear error if not. */
|
|
20
|
+
function assertSweepCapable(wallet) {
|
|
21
|
+
if (!isSweepCapable(wallet)) {
|
|
22
|
+
throw new Error("Boarding UTXO sweep requires a Wallet instance with boardingTapscript, onchainProvider, and network");
|
|
23
|
+
}
|
|
24
|
+
}
|
|
7
25
|
exports.DEFAULT_THRESHOLD_MS = 3 * 24 * 60 * 60 * 1000; // 3 days
|
|
26
|
+
exports.DEFAULT_THRESHOLD_SECONDS = 3 * 24 * 60 * 60; // 3 days
|
|
8
27
|
/**
|
|
9
28
|
* Default renewal configuration values
|
|
29
|
+
* @deprecated Use DEFAULT_SETTLEMENT_CONFIG instead
|
|
10
30
|
*/
|
|
11
31
|
exports.DEFAULT_RENEWAL_CONFIG = {
|
|
12
32
|
thresholdMs: exports.DEFAULT_THRESHOLD_MS, // 3 days
|
|
13
33
|
};
|
|
34
|
+
/**
|
|
35
|
+
* Default settlement configuration values
|
|
36
|
+
*/
|
|
37
|
+
exports.DEFAULT_SETTLEMENT_CONFIG = {
|
|
38
|
+
vtxoThreshold: exports.DEFAULT_THRESHOLD_SECONDS,
|
|
39
|
+
boardingUtxoSweep: true,
|
|
40
|
+
pollIntervalMs: 60000,
|
|
41
|
+
};
|
|
42
|
+
/** Extracts the dust amount from the wallet, defaulting to 330 sats. */
|
|
14
43
|
function getDustAmount(wallet) {
|
|
15
44
|
return "dustAmount" in wallet ? wallet.dustAmount : 330n;
|
|
16
45
|
}
|
|
@@ -157,9 +186,38 @@ function getExpiringAndRecoverableVtxos(vtxos, thresholdMs, dustAmount) {
|
|
|
157
186
|
* ```
|
|
158
187
|
*/
|
|
159
188
|
class VtxoManager {
|
|
160
|
-
constructor(wallet,
|
|
189
|
+
constructor(wallet,
|
|
190
|
+
/** @deprecated Use settlementConfig instead */
|
|
191
|
+
renewalConfig, settlementConfig) {
|
|
161
192
|
this.wallet = wallet;
|
|
162
193
|
this.renewalConfig = renewalConfig;
|
|
194
|
+
this.knownBoardingUtxos = new Set();
|
|
195
|
+
this.sweptBoardingUtxos = new Set();
|
|
196
|
+
this.pollInProgress = false;
|
|
197
|
+
// Normalize: prefer settlementConfig, fall back to renewalConfig, default to enabled
|
|
198
|
+
if (settlementConfig !== undefined) {
|
|
199
|
+
this.settlementConfig = settlementConfig;
|
|
200
|
+
}
|
|
201
|
+
else if (renewalConfig && renewalConfig.enabled) {
|
|
202
|
+
this.settlementConfig = {
|
|
203
|
+
vtxoThreshold: renewalConfig.thresholdMs
|
|
204
|
+
? renewalConfig.thresholdMs / 1000
|
|
205
|
+
: undefined,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
else if (renewalConfig) {
|
|
209
|
+
// renewalConfig provided but not enabled → disabled
|
|
210
|
+
this.settlementConfig = false;
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
// No config at all → enabled by default
|
|
214
|
+
this.settlementConfig = { ...exports.DEFAULT_SETTLEMENT_CONFIG };
|
|
215
|
+
}
|
|
216
|
+
this.contractEventsSubscriptionReady =
|
|
217
|
+
this.initializeSubscription().then((subscription) => {
|
|
218
|
+
this.contractEventsSubscription = subscription;
|
|
219
|
+
return subscription;
|
|
220
|
+
});
|
|
163
221
|
}
|
|
164
222
|
// ========== Recovery Methods ==========
|
|
165
223
|
/**
|
|
@@ -271,10 +329,26 @@ class VtxoManager {
|
|
|
271
329
|
* ```
|
|
272
330
|
*/
|
|
273
331
|
async getExpiringVtxos(thresholdMs) {
|
|
332
|
+
// If settlementConfig is explicitly false and no override provided, renewal is disabled
|
|
333
|
+
if (this.settlementConfig === false && thresholdMs === undefined) {
|
|
334
|
+
return [];
|
|
335
|
+
}
|
|
274
336
|
const vtxos = await this.wallet.getVtxos({ withRecoverable: true });
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
337
|
+
// Resolve threshold: method param > settlementConfig (seconds→ms) > renewalConfig > default
|
|
338
|
+
let threshold;
|
|
339
|
+
if (thresholdMs !== undefined) {
|
|
340
|
+
threshold = thresholdMs;
|
|
341
|
+
}
|
|
342
|
+
else if (this.settlementConfig !== false &&
|
|
343
|
+
this.settlementConfig &&
|
|
344
|
+
this.settlementConfig.vtxoThreshold !== undefined) {
|
|
345
|
+
threshold = this.settlementConfig.vtxoThreshold * 1000;
|
|
346
|
+
}
|
|
347
|
+
else {
|
|
348
|
+
threshold =
|
|
349
|
+
this.renewalConfig?.thresholdMs ??
|
|
350
|
+
exports.DEFAULT_RENEWAL_CONFIG.thresholdMs;
|
|
351
|
+
}
|
|
278
352
|
return getExpiringAndRecoverableVtxos(vtxos, threshold, getDustAmount(this.wallet));
|
|
279
353
|
}
|
|
280
354
|
/**
|
|
@@ -304,7 +378,11 @@ class VtxoManager {
|
|
|
304
378
|
*/
|
|
305
379
|
async renewVtxos(eventCallback) {
|
|
306
380
|
// Get all VTXOs (including recoverable ones)
|
|
307
|
-
|
|
381
|
+
// Use default threshold to bypass settlementConfig gate (manual API should always work)
|
|
382
|
+
const vtxos = await this.getExpiringVtxos(this.settlementConfig !== false &&
|
|
383
|
+
this.settlementConfig?.vtxoThreshold !== undefined
|
|
384
|
+
? this.settlementConfig.vtxoThreshold * 1000
|
|
385
|
+
: exports.DEFAULT_RENEWAL_CONFIG.thresholdMs);
|
|
308
386
|
if (vtxos.length === 0) {
|
|
309
387
|
throw new Error("No VTXOs available to renew");
|
|
310
388
|
}
|
|
@@ -326,5 +404,312 @@ class VtxoManager {
|
|
|
326
404
|
],
|
|
327
405
|
}, eventCallback);
|
|
328
406
|
}
|
|
407
|
+
// ========== Boarding UTXO Sweep Methods ==========
|
|
408
|
+
/**
|
|
409
|
+
* Get boarding UTXOs whose timelock has expired.
|
|
410
|
+
*
|
|
411
|
+
* These UTXOs can no longer be onboarded cooperatively via `settle()` and
|
|
412
|
+
* must be swept back to a fresh boarding address using the unilateral exit path.
|
|
413
|
+
*
|
|
414
|
+
* @returns Array of expired boarding UTXOs
|
|
415
|
+
*
|
|
416
|
+
* @example
|
|
417
|
+
* ```typescript
|
|
418
|
+
* const manager = new VtxoManager(wallet);
|
|
419
|
+
* const expired = await manager.getExpiredBoardingUtxos();
|
|
420
|
+
* if (expired.length > 0) {
|
|
421
|
+
* console.log(`${expired.length} expired boarding UTXOs to sweep`);
|
|
422
|
+
* }
|
|
423
|
+
* ```
|
|
424
|
+
*/
|
|
425
|
+
async getExpiredBoardingUtxos() {
|
|
426
|
+
const boardingUtxos = await this.wallet.getBoardingUtxos();
|
|
427
|
+
const boardingTimelock = this.getBoardingTimelock();
|
|
428
|
+
// For block-based timelocks, fetch the chain tip height
|
|
429
|
+
let chainTipHeight;
|
|
430
|
+
if (boardingTimelock.type === "blocks") {
|
|
431
|
+
const tip = await this.getOnchainProvider().getChainTip();
|
|
432
|
+
chainTipHeight = tip.height;
|
|
433
|
+
}
|
|
434
|
+
return boardingUtxos.filter((utxo) => (0, arkTransaction_1.hasBoardingTxExpired)(utxo, boardingTimelock, chainTipHeight));
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* Sweep expired boarding UTXOs back to a fresh boarding address via
|
|
438
|
+
* the unilateral exit path (on-chain self-spend).
|
|
439
|
+
*
|
|
440
|
+
* This builds a raw on-chain transaction that:
|
|
441
|
+
* - Uses all expired boarding UTXOs as inputs (spent via the CSV exit script path)
|
|
442
|
+
* - Has a single output to the wallet's boarding address (restarts the timelock)
|
|
443
|
+
* - Batches multiple expired UTXOs into one transaction
|
|
444
|
+
* - Skips the sweep if the output after fees would be below dust
|
|
445
|
+
*
|
|
446
|
+
* No Ark server involvement is needed — this is a pure on-chain transaction.
|
|
447
|
+
*
|
|
448
|
+
* @returns The broadcast transaction ID
|
|
449
|
+
* @throws Error if no expired boarding UTXOs found
|
|
450
|
+
* @throws Error if output after fees is below dust (not economical to sweep)
|
|
451
|
+
* @throws Error if boarding UTXO sweep is not enabled in settlementConfig
|
|
452
|
+
*
|
|
453
|
+
* @example
|
|
454
|
+
* ```typescript
|
|
455
|
+
* const manager = new VtxoManager(wallet, undefined, {
|
|
456
|
+
* boardingUtxoSweep: true,
|
|
457
|
+
* });
|
|
458
|
+
*
|
|
459
|
+
* try {
|
|
460
|
+
* const txid = await manager.sweepExpiredBoardingUtxos();
|
|
461
|
+
* console.log('Swept expired boarding UTXOs:', txid);
|
|
462
|
+
* } catch (e) {
|
|
463
|
+
* console.log('No sweep needed or not economical');
|
|
464
|
+
* }
|
|
465
|
+
* ```
|
|
466
|
+
*/
|
|
467
|
+
async sweepExpiredBoardingUtxos() {
|
|
468
|
+
const sweepEnabled = this.settlementConfig !== false &&
|
|
469
|
+
(this.settlementConfig?.boardingUtxoSweep ??
|
|
470
|
+
exports.DEFAULT_SETTLEMENT_CONFIG.boardingUtxoSweep);
|
|
471
|
+
if (!sweepEnabled) {
|
|
472
|
+
throw new Error("Boarding UTXO sweep is not enabled in settlementConfig");
|
|
473
|
+
}
|
|
474
|
+
const allExpired = await this.getExpiredBoardingUtxos();
|
|
475
|
+
// Filter out UTXOs already swept (tx broadcast but not yet confirmed)
|
|
476
|
+
const expiredUtxos = allExpired.filter((u) => !this.sweptBoardingUtxos.has(`${u.txid}:${u.vout}`));
|
|
477
|
+
if (expiredUtxos.length === 0) {
|
|
478
|
+
throw new Error("No expired boarding UTXOs to sweep");
|
|
479
|
+
}
|
|
480
|
+
const boardingAddress = await this.wallet.getBoardingAddress();
|
|
481
|
+
// Get fee rate from onchain provider
|
|
482
|
+
const feeRate = (await this.getOnchainProvider().getFeeRate()) ?? 1;
|
|
483
|
+
// Get the exit tap leaf script for signing
|
|
484
|
+
const exitTapLeafScript = this.getBoardingExitLeaf();
|
|
485
|
+
// Estimate transaction size for fee calculation
|
|
486
|
+
const sequence = (0, base_2.getSequence)(exitTapLeafScript);
|
|
487
|
+
// TapLeafScript: [{version, internalKey, merklePath}, scriptWithVersion]
|
|
488
|
+
const leafScript = exitTapLeafScript[1];
|
|
489
|
+
const leafScriptSize = leafScript.length - 1; // minus version byte
|
|
490
|
+
const controlBlockSize = exitTapLeafScript[0].merklePath.length * 32;
|
|
491
|
+
// Exit path witness: 1 Schnorr signature (64 bytes)
|
|
492
|
+
const leafWitnessSize = 64;
|
|
493
|
+
const estimator = txSizeEstimator_1.TxWeightEstimator.create();
|
|
494
|
+
for (const _ of expiredUtxos) {
|
|
495
|
+
estimator.addTapscriptInput(leafWitnessSize, leafScriptSize, controlBlockSize);
|
|
496
|
+
}
|
|
497
|
+
estimator.addOutputAddress(boardingAddress, this.getNetwork());
|
|
498
|
+
const fee = Math.ceil(Number(estimator.vsize().value) * feeRate);
|
|
499
|
+
const totalValue = expiredUtxos.reduce((sum, utxo) => sum + BigInt(utxo.value), 0n);
|
|
500
|
+
const outputAmount = totalValue - BigInt(fee);
|
|
501
|
+
// Dust check: skip if output after fees is below dust
|
|
502
|
+
const dustAmount = getDustAmount(this.wallet);
|
|
503
|
+
if (outputAmount < dustAmount) {
|
|
504
|
+
throw new Error(`Sweep not economical: output ${outputAmount} sats after ${fee} sats fee is below dust (${dustAmount} sats)`);
|
|
505
|
+
}
|
|
506
|
+
// Build the raw transaction
|
|
507
|
+
const tx = new transaction_1.Transaction();
|
|
508
|
+
for (const utxo of expiredUtxos) {
|
|
509
|
+
tx.addInput({
|
|
510
|
+
txid: utxo.txid,
|
|
511
|
+
index: utxo.vout,
|
|
512
|
+
witnessUtxo: {
|
|
513
|
+
script: this.getBoardingOutputScript(),
|
|
514
|
+
amount: BigInt(utxo.value),
|
|
515
|
+
},
|
|
516
|
+
tapLeafScript: [exitTapLeafScript],
|
|
517
|
+
sequence,
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
tx.addOutputAddress(boardingAddress, outputAmount, this.getNetwork());
|
|
521
|
+
// Sign and finalize
|
|
522
|
+
const signedTx = await this.getIdentity().sign(tx);
|
|
523
|
+
signedTx.finalize();
|
|
524
|
+
// Broadcast
|
|
525
|
+
const txid = await this.getOnchainProvider().broadcastTransaction(signedTx.hex);
|
|
526
|
+
// Mark UTXOs as swept to prevent duplicate broadcasts on next poll
|
|
527
|
+
for (const u of expiredUtxos) {
|
|
528
|
+
this.sweptBoardingUtxos.add(`${u.txid}:${u.vout}`);
|
|
529
|
+
}
|
|
530
|
+
// Mark the sweep output as "known" so the next poll doesn't try to
|
|
531
|
+
// auto-settle it back into Ark (it lands at the same boarding address).
|
|
532
|
+
this.knownBoardingUtxos.add(`${txid}:0`);
|
|
533
|
+
return txid;
|
|
534
|
+
}
|
|
535
|
+
// ========== Private Helpers ==========
|
|
536
|
+
/** Asserts sweep capability and returns the typed wallet. */
|
|
537
|
+
getSweepWallet() {
|
|
538
|
+
assertSweepCapable(this.wallet);
|
|
539
|
+
return this.wallet;
|
|
540
|
+
}
|
|
541
|
+
/** Decodes the boarding tapscript exit path to extract the CSV timelock. */
|
|
542
|
+
getBoardingTimelock() {
|
|
543
|
+
const wallet = this.getSweepWallet();
|
|
544
|
+
const exitScript = tapscript_1.CSVMultisigTapscript.decode(base_1.hex.decode(wallet.boardingTapscript.exitScript));
|
|
545
|
+
return exitScript.params.timelock;
|
|
546
|
+
}
|
|
547
|
+
/** Returns the TapLeafScript for the boarding tapscript's exit (CSV) path. */
|
|
548
|
+
getBoardingExitLeaf() {
|
|
549
|
+
return this.getSweepWallet().boardingTapscript.exit();
|
|
550
|
+
}
|
|
551
|
+
/** Returns the pkScript (output script) of the boarding tapscript. */
|
|
552
|
+
getBoardingOutputScript() {
|
|
553
|
+
return this.getSweepWallet().boardingTapscript.pkScript;
|
|
554
|
+
}
|
|
555
|
+
/** Returns the on-chain provider for fee estimation and broadcasting. */
|
|
556
|
+
getOnchainProvider() {
|
|
557
|
+
return this.getSweepWallet().onchainProvider;
|
|
558
|
+
}
|
|
559
|
+
/** Returns the Bitcoin network configuration from the wallet. */
|
|
560
|
+
getNetwork() {
|
|
561
|
+
return this.getSweepWallet().network;
|
|
562
|
+
}
|
|
563
|
+
/** Returns the wallet's identity for transaction signing. */
|
|
564
|
+
getIdentity() {
|
|
565
|
+
return this.wallet.identity;
|
|
566
|
+
}
|
|
567
|
+
async initializeSubscription() {
|
|
568
|
+
if (this.settlementConfig === false) {
|
|
569
|
+
return undefined;
|
|
570
|
+
}
|
|
571
|
+
// Start polling for boarding UTXOs independently of contract manager
|
|
572
|
+
// SSE setup. Use a short delay to let the wallet finish construction.
|
|
573
|
+
setTimeout(() => this.startBoardingUtxoPoll(), 1000);
|
|
574
|
+
try {
|
|
575
|
+
const [delegatorManager, contractManager, destination] = await Promise.all([
|
|
576
|
+
this.wallet.getDelegatorManager(),
|
|
577
|
+
this.wallet.getContractManager(),
|
|
578
|
+
this.wallet.getAddress(),
|
|
579
|
+
]);
|
|
580
|
+
const stopWatching = contractManager.onContractEvent((event) => {
|
|
581
|
+
if (event.type !== "vtxo_received") {
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
this.renewVtxos().catch((e) => {
|
|
585
|
+
if (e instanceof Error) {
|
|
586
|
+
if (e.message.includes("No VTXOs available to renew")) {
|
|
587
|
+
// Not an error, just no VTXO eligible for renewal.
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
if (e.message.includes("is below dust threshold")) {
|
|
591
|
+
// Not an error, just below dust threshold.
|
|
592
|
+
// As more VTXOs are received, the threshold will be raised.
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
console.error("Error renewing VTXOs:", e);
|
|
597
|
+
});
|
|
598
|
+
delegatorManager
|
|
599
|
+
?.delegate(event.vtxos, destination)
|
|
600
|
+
.catch((e) => {
|
|
601
|
+
console.error("Error delegating VTXOs:", e);
|
|
602
|
+
});
|
|
603
|
+
});
|
|
604
|
+
return stopWatching;
|
|
605
|
+
}
|
|
606
|
+
catch (e) {
|
|
607
|
+
console.error("Error renewing VTXOs from VtxoManager", e);
|
|
608
|
+
return undefined;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* Starts a polling loop that:
|
|
613
|
+
* 1. Auto-settles new boarding UTXOs into Ark
|
|
614
|
+
* 2. Sweeps expired boarding UTXOs (when boardingUtxoSweep is enabled)
|
|
615
|
+
*/
|
|
616
|
+
startBoardingUtxoPoll() {
|
|
617
|
+
if (this.settlementConfig === false)
|
|
618
|
+
return;
|
|
619
|
+
const intervalMs = this.settlementConfig.pollIntervalMs ??
|
|
620
|
+
exports.DEFAULT_SETTLEMENT_CONFIG.pollIntervalMs;
|
|
621
|
+
// Run once immediately, then on interval
|
|
622
|
+
this.pollBoardingUtxos();
|
|
623
|
+
this.pollIntervalId = setInterval(() => this.pollBoardingUtxos(), intervalMs);
|
|
624
|
+
}
|
|
625
|
+
async pollBoardingUtxos() {
|
|
626
|
+
// Guard: wallet must support boarding UTXO + sweep operations
|
|
627
|
+
if (!isSweepCapable(this.wallet))
|
|
628
|
+
return;
|
|
629
|
+
// Skip if a previous poll is still running
|
|
630
|
+
if (this.pollInProgress)
|
|
631
|
+
return;
|
|
632
|
+
this.pollInProgress = true;
|
|
633
|
+
try {
|
|
634
|
+
// Settle new (unexpired) UTXOs first, then sweep expired ones.
|
|
635
|
+
// Sequential to avoid racing for the same UTXOs.
|
|
636
|
+
try {
|
|
637
|
+
await this.settleBoardingUtxos();
|
|
638
|
+
}
|
|
639
|
+
catch (e) {
|
|
640
|
+
console.error("Error auto-settling boarding UTXOs:", e);
|
|
641
|
+
}
|
|
642
|
+
const sweepEnabled = this.settlementConfig !== false &&
|
|
643
|
+
(this.settlementConfig?.boardingUtxoSweep ??
|
|
644
|
+
exports.DEFAULT_SETTLEMENT_CONFIG.boardingUtxoSweep);
|
|
645
|
+
if (sweepEnabled) {
|
|
646
|
+
try {
|
|
647
|
+
await this.sweepExpiredBoardingUtxos();
|
|
648
|
+
}
|
|
649
|
+
catch (e) {
|
|
650
|
+
if (!(e instanceof Error) ||
|
|
651
|
+
!e.message.includes("No expired boarding UTXOs")) {
|
|
652
|
+
console.error("Error auto-sweeping boarding UTXOs:", e);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
finally {
|
|
658
|
+
this.pollInProgress = false;
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
/**
|
|
662
|
+
* Auto-settle new (unexpired) boarding UTXOs into the Ark.
|
|
663
|
+
* Skips UTXOs that are already expired (those are handled by sweep).
|
|
664
|
+
* Only settles UTXOs not already in-flight (tracked in knownBoardingUtxos).
|
|
665
|
+
* UTXOs are marked as known only after a successful settle, so failed
|
|
666
|
+
* attempts will be retried on the next poll.
|
|
667
|
+
*/
|
|
668
|
+
async settleBoardingUtxos() {
|
|
669
|
+
const boardingUtxos = await this.wallet.getBoardingUtxos();
|
|
670
|
+
// Exclude expired UTXOs — those should be swept, not settled.
|
|
671
|
+
// If we can't determine expired status, bail out entirely to avoid
|
|
672
|
+
// accidentally settling expired UTXOs (which would conflict with sweep).
|
|
673
|
+
let expiredSet;
|
|
674
|
+
try {
|
|
675
|
+
const expired = await this.getExpiredBoardingUtxos();
|
|
676
|
+
expiredSet = new Set(expired.map((u) => `${u.txid}:${u.vout}`));
|
|
677
|
+
}
|
|
678
|
+
catch {
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
const unsettledUtxos = boardingUtxos.filter((u) => !this.knownBoardingUtxos.has(`${u.txid}:${u.vout}`) &&
|
|
682
|
+
!expiredSet.has(`${u.txid}:${u.vout}`));
|
|
683
|
+
if (unsettledUtxos.length === 0)
|
|
684
|
+
return;
|
|
685
|
+
const dustAmount = getDustAmount(this.wallet);
|
|
686
|
+
const totalAmount = unsettledUtxos.reduce((sum, u) => sum + BigInt(u.value), 0n);
|
|
687
|
+
if (totalAmount < dustAmount)
|
|
688
|
+
return;
|
|
689
|
+
const arkAddress = await this.wallet.getAddress();
|
|
690
|
+
await this.wallet.settle({
|
|
691
|
+
inputs: unsettledUtxos,
|
|
692
|
+
outputs: [{ address: arkAddress, amount: totalAmount }],
|
|
693
|
+
});
|
|
694
|
+
// Mark as known only after successful settle
|
|
695
|
+
for (const u of unsettledUtxos) {
|
|
696
|
+
this.knownBoardingUtxos.add(`${u.txid}:${u.vout}`);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
async dispose() {
|
|
700
|
+
this.disposePromise ?? (this.disposePromise = (async () => {
|
|
701
|
+
if (this.pollIntervalId) {
|
|
702
|
+
clearInterval(this.pollIntervalId);
|
|
703
|
+
this.pollIntervalId = undefined;
|
|
704
|
+
}
|
|
705
|
+
const subscription = await this.contractEventsSubscriptionReady;
|
|
706
|
+
this.contractEventsSubscription = undefined;
|
|
707
|
+
subscription?.();
|
|
708
|
+
})());
|
|
709
|
+
return this.disposePromise;
|
|
710
|
+
}
|
|
711
|
+
async [Symbol.asyncDispose]() {
|
|
712
|
+
await this.dispose();
|
|
713
|
+
}
|
|
329
714
|
}
|
|
330
715
|
exports.VtxoManager = VtxoManager;
|