@arkade-os/sdk 0.4.4 → 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.
@@ -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, renewalConfig, delegatorProvider, watcherConfig) {
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
- return 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);
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
- const boardingUtxos = (await this.getBoardingUtxos()).filter((utxo) => !(0, arkTransaction_1.hasBoardingTxExpired)(utxo, boardingTimelock));
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
- return false; // TODO: handle get chain tip
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));
@@ -156,6 +156,7 @@ export class ExpoWallet {
156
156
  catch {
157
157
  // expo-background-task not installed — nothing to unregister
158
158
  }
159
+ await this.wallet.dispose();
159
160
  }
160
161
  // ── IWallet delegation ───────────────────────────────────────────
161
162
  getAddress() {
@@ -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, renewalConfig) {
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
- const threshold = thresholdMs ??
271
- this.renewalConfig?.thresholdMs ??
272
- DEFAULT_RENEWAL_CONFIG.thresholdMs;
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
- const vtxos = await this.getExpiringVtxos();
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
  }