@arkade-os/sdk 0.4.6 → 0.4.8

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.
@@ -148,43 +148,6 @@ function getExpiringAndRecoverableVtxos(vtxos, thresholdMs, dustAmount) {
148
148
  ((0, _1.isSpendable)(vtxo) && (0, _1.isExpired)(vtxo)) ||
149
149
  (0, _1.isSubdust)(vtxo, dustAmount));
150
150
  }
151
- /**
152
- * VtxoManager is a unified class for managing VTXO lifecycle operations including
153
- * recovery of swept/expired VTXOs and renewal to prevent expiration.
154
- *
155
- * Key Features:
156
- * - **Recovery**: Reclaim swept or expired VTXOs back to the wallet
157
- * - **Renewal**: Refresh VTXO expiration time before they expire
158
- * - **Smart subdust handling**: Automatically includes subdust VTXOs when economically viable
159
- * - **Expiry monitoring**: Check for VTXOs that are expiring soon
160
- *
161
- * VTXOs become recoverable when:
162
- * - The Ark server sweeps them (virtualStatus.state === "swept") and they remain spendable
163
- * - They are preconfirmed subdust (to consolidate small amounts without locking liquidity on settled VTXOs)
164
- *
165
- * @example
166
- * ```typescript
167
- * // Initialize with renewal config
168
- * const manager = new VtxoManager(wallet, {
169
- * enabled: true,
170
- * thresholdMs: 86400000
171
- * });
172
- *
173
- * // Check recoverable balance
174
- * const balance = await manager.getRecoverableBalance();
175
- * if (balance.recoverable > 0n) {
176
- * console.log(`Can recover ${balance.recoverable} sats`);
177
- * const txid = await manager.recoverVtxos();
178
- * }
179
- *
180
- * // Check for expiring VTXOs
181
- * const expiring = await manager.getExpiringVtxos();
182
- * if (expiring.length > 0) {
183
- * console.log(`${expiring.length} VTXOs expiring soon`);
184
- * const txid = await manager.renewVtxos();
185
- * }
186
- * ```
187
- */
188
151
  class VtxoManager {
189
152
  constructor(wallet,
190
153
  /** @deprecated Use settlementConfig instead */
@@ -194,6 +157,7 @@ class VtxoManager {
194
157
  this.knownBoardingUtxos = new Set();
195
158
  this.sweptBoardingUtxos = new Set();
196
159
  this.pollInProgress = false;
160
+ this.consecutivePollFailures = 0;
197
161
  // Normalize: prefer settlementConfig, fall back to renewalConfig, default to enabled
198
162
  if (settlementConfig !== undefined) {
199
163
  this.settlementConfig = settlementConfig;
@@ -582,6 +546,25 @@ class VtxoManager {
582
546
  return;
583
547
  }
584
548
  this.renewVtxos().catch((e) => {
549
+ if (e instanceof Error) {
550
+ if (e.message.includes("No VTXOs available to renew")) {
551
+ // Not an error, just no VTXO eligible for renewal.
552
+ return;
553
+ }
554
+ if (e.message.includes("is below dust threshold")) {
555
+ // Not an error, just below dust threshold.
556
+ // As more VTXOs are received, the threshold will be raised.
557
+ return;
558
+ }
559
+ if (e.message.includes("VTXO_ALREADY_REGISTERED") ||
560
+ e.message.includes("duplicated input")) {
561
+ // VTXO is already being used in a concurrent
562
+ // user-initiated operation. Skip silently — the
563
+ // wallet's tx lock serializes these, but the
564
+ // renewal will retry on the next cycle.
565
+ return;
566
+ }
567
+ }
585
568
  console.error("Error renewing VTXOs:", e);
586
569
  });
587
570
  delegatorManager
@@ -597,19 +580,36 @@ class VtxoManager {
597
580
  return undefined;
598
581
  }
599
582
  }
583
+ /** Computes the next poll delay, applying exponential backoff on failures. */
584
+ getNextPollDelay() {
585
+ if (this.settlementConfig === false)
586
+ return 0;
587
+ const baseMs = this.settlementConfig.pollIntervalMs ??
588
+ exports.DEFAULT_SETTLEMENT_CONFIG.pollIntervalMs;
589
+ if (this.consecutivePollFailures === 0)
590
+ return baseMs;
591
+ const backoff = Math.min(baseMs * Math.pow(2, this.consecutivePollFailures), VtxoManager.MAX_BACKOFF_MS);
592
+ return backoff;
593
+ }
600
594
  /**
601
595
  * Starts a polling loop that:
602
596
  * 1. Auto-settles new boarding UTXOs into Ark
603
597
  * 2. Sweeps expired boarding UTXOs (when boardingUtxoSweep is enabled)
598
+ *
599
+ * Uses setTimeout chaining (not setInterval) so a slow/blocked poll
600
+ * cannot stack up and the next delay can incorporate backoff.
604
601
  */
605
602
  startBoardingUtxoPoll() {
606
603
  if (this.settlementConfig === false)
607
604
  return;
608
- const intervalMs = this.settlementConfig.pollIntervalMs ??
609
- exports.DEFAULT_SETTLEMENT_CONFIG.pollIntervalMs;
610
- // Run once immediately, then on interval
605
+ // Run once immediately, then schedule next
611
606
  this.pollBoardingUtxos();
612
- this.pollIntervalId = setInterval(() => this.pollBoardingUtxos(), intervalMs);
607
+ }
608
+ schedulePoll() {
609
+ if (this.settlementConfig === false)
610
+ return;
611
+ const delay = this.getNextPollDelay();
612
+ this.pollTimeoutId = setTimeout(() => this.pollBoardingUtxos(), delay);
613
613
  }
614
614
  async pollBoardingUtxos() {
615
615
  // Guard: wallet must support boarding UTXO + sweep operations
@@ -619,6 +619,7 @@ class VtxoManager {
619
619
  if (this.pollInProgress)
620
620
  return;
621
621
  this.pollInProgress = true;
622
+ let hadError = false;
622
623
  try {
623
624
  // Settle new (unexpired) UTXOs first, then sweep expired ones.
624
625
  // Sequential to avoid racing for the same UTXOs.
@@ -626,6 +627,7 @@ class VtxoManager {
626
627
  await this.settleBoardingUtxos();
627
628
  }
628
629
  catch (e) {
630
+ hadError = true;
629
631
  console.error("Error auto-settling boarding UTXOs:", e);
630
632
  }
631
633
  const sweepEnabled = this.settlementConfig !== false &&
@@ -638,13 +640,21 @@ class VtxoManager {
638
640
  catch (e) {
639
641
  if (!(e instanceof Error) ||
640
642
  !e.message.includes("No expired boarding UTXOs")) {
643
+ hadError = true;
641
644
  console.error("Error auto-sweeping boarding UTXOs:", e);
642
645
  }
643
646
  }
644
647
  }
645
648
  }
646
649
  finally {
650
+ if (hadError) {
651
+ this.consecutivePollFailures++;
652
+ }
653
+ else {
654
+ this.consecutivePollFailures = 0;
655
+ }
647
656
  this.pollInProgress = false;
657
+ this.schedulePoll();
648
658
  }
649
659
  }
650
660
  /**
@@ -661,7 +671,13 @@ class VtxoManager {
661
671
  // accidentally settling expired UTXOs (which would conflict with sweep).
662
672
  let expiredSet;
663
673
  try {
664
- const expired = await this.getExpiredBoardingUtxos();
674
+ const boardingTimelock = this.getBoardingTimelock();
675
+ let chainTipHeight;
676
+ if (boardingTimelock.type === "blocks") {
677
+ const tip = await this.getOnchainProvider().getChainTip();
678
+ chainTipHeight = tip.height;
679
+ }
680
+ const expired = boardingUtxos.filter((utxo) => (0, arkTransaction_1.hasBoardingTxExpired)(utxo, boardingTimelock, chainTipHeight));
665
681
  expiredSet = new Set(expired.map((u) => `${u.txid}:${u.vout}`));
666
682
  }
667
683
  catch {
@@ -687,9 +703,9 @@ class VtxoManager {
687
703
  }
688
704
  async dispose() {
689
705
  this.disposePromise ?? (this.disposePromise = (async () => {
690
- if (this.pollIntervalId) {
691
- clearInterval(this.pollIntervalId);
692
- this.pollIntervalId = undefined;
706
+ if (this.pollTimeoutId) {
707
+ clearTimeout(this.pollTimeoutId);
708
+ this.pollTimeoutId = undefined;
693
709
  }
694
710
  const subscription = await this.contractEventsSubscriptionReady;
695
711
  this.contractEventsSubscription = undefined;
@@ -702,3 +718,4 @@ class VtxoManager {
702
718
  }
703
719
  }
704
720
  exports.VtxoManager = VtxoManager;
721
+ VtxoManager.MAX_BACKOFF_MS = 5 * 60 * 1000; // 5 minutes
@@ -62,6 +62,19 @@ class ReadonlyWallet {
62
62
  this.walletRepository = walletRepository;
63
63
  this.contractRepository = contractRepository;
64
64
  this.delegatorProvider = delegatorProvider;
65
+ // Guard: detect identity/server network mismatch for descriptor-based identities.
66
+ // This duplicates the check in setupWalletConfig() so that subclasses
67
+ // bypassing the factory still get the safety net.
68
+ if ("descriptor" in identity) {
69
+ const descriptor = identity.descriptor;
70
+ const identityIsMainnet = !descriptor.includes("tpub");
71
+ const serverIsMainnet = network.bech32 === "bc";
72
+ if (identityIsMainnet !== serverIsMainnet) {
73
+ throw new Error(`Network mismatch: identity uses ${identityIsMainnet ? "mainnet" : "testnet"} derivation ` +
74
+ `but wallet network is ${serverIsMainnet ? "mainnet" : "testnet"}. ` +
75
+ `Create identity with { isMainnet: ${serverIsMainnet} } to match.`);
76
+ }
77
+ }
65
78
  this.watcherConfig = watcherConfig;
66
79
  this._assetManager = new asset_manager_1.ReadonlyAssetManager(this.indexerProvider);
67
80
  }
@@ -89,6 +102,24 @@ class ReadonlyWallet {
89
102
  const indexerProvider = config.indexerProvider || new indexer_1.RestIndexerProvider(indexerUrl);
90
103
  const info = await arkProvider.getInfo();
91
104
  const network = (0, networks_1.getNetwork)(info.network);
105
+ // Guard: detect identity/server network mismatch for seed-based identities.
106
+ // A mainnet descriptor (xpub, coin type 0) connected to a testnet server
107
+ // (or vice versa) means wrong derivation path → wrong keys → potential fund loss.
108
+ if ("descriptor" in config.identity) {
109
+ const descriptor = config.identity.descriptor;
110
+ const identityIsMainnet = !descriptor.includes("tpub");
111
+ const serverIsMainnet = info.network === "bitcoin";
112
+ if (identityIsMainnet && !serverIsMainnet) {
113
+ throw new Error(`Network mismatch: identity uses mainnet derivation (coin type 0) ` +
114
+ `but Ark server is on ${info.network}. ` +
115
+ `Create identity with { isMainnet: false } to use testnet derivation.`);
116
+ }
117
+ if (!identityIsMainnet && serverIsMainnet) {
118
+ throw new Error(`Network mismatch: identity uses testnet derivation (coin type 1) ` +
119
+ `but Ark server is on mainnet. ` +
120
+ `Create identity with { isMainnet: true } or omit isMainnet (defaults to mainnet).`);
121
+ }
122
+ }
92
123
  // Extract esploraUrl from provider if not explicitly provided
93
124
  const esploraUrl = config.esploraUrl || onchain_1.ESPLORA_URL[info.network];
94
125
  // Use provided onchainProvider instance or create a new one
@@ -664,6 +695,20 @@ exports.ReadonlyWallet = ReadonlyWallet;
664
695
  * ```
665
696
  */
666
697
  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
+ }
667
712
  constructor(identity, network, networkName, onchainProvider, arkProvider, indexerProvider, arkServerPublicKey, offchainTapscript, boardingTapscript, serverUnrollScript, forfeitOutputScript, forfeitPubkey, dustAmount, walletRepository, contractRepository,
668
713
  /** @deprecated Use settlementConfig */
669
714
  renewalConfig, delegatorProvider, watcherConfig, settlementConfig) {
@@ -673,6 +718,13 @@ class Wallet extends ReadonlyWallet {
673
718
  this.serverUnrollScript = serverUnrollScript;
674
719
  this.forfeitOutputScript = forfeitOutputScript;
675
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();
676
728
  this.identity = identity;
677
729
  // Backwards-compatible: keep renewalConfig populated for any code reading it
678
730
  this.renewalConfig = {
@@ -811,40 +863,42 @@ class Wallet extends ReadonlyWallet {
811
863
  throw new Error("Invalid Ark address " + params.address);
812
864
  }
813
865
  if (params.selectedVtxos && params.selectedVtxos.length > 0) {
814
- const selectedVtxoSum = params.selectedVtxos
815
- .map((v) => v.value)
816
- .reduce((a, b) => a + b, 0);
817
- if (selectedVtxoSum < params.amount) {
818
- throw new Error("Selected VTXOs do not cover specified amount");
819
- }
820
- const changeAmount = selectedVtxoSum - params.amount;
821
- const selected = {
822
- inputs: params.selectedVtxos,
823
- changeAmount: BigInt(changeAmount),
824
- };
825
- const outputAddress = address_1.ArkAddress.decode(params.address);
826
- const outputScript = BigInt(params.amount) < this.dustAmount
827
- ? outputAddress.subdustPkScript
828
- : outputAddress.pkScript;
829
- const outputs = [
830
- {
831
- script: outputScript,
832
- amount: BigInt(params.amount),
833
- },
834
- ];
835
- // add change output if needed
836
- if (selected.changeAmount > 0n) {
837
- const changeOutputScript = selected.changeAmount < this.dustAmount
838
- ? this.arkAddress.subdustPkScript
839
- : this.arkAddress.pkScript;
840
- outputs.push({
841
- script: changeOutputScript,
842
- amount: BigInt(selected.changeAmount),
843
- });
844
- }
845
- const { arkTxid, signedCheckpointTxs } = await this.buildAndSubmitOffchainTx(selected.inputs, outputs);
846
- await this.updateDbAfterOffchainTx(selected.inputs, arkTxid, signedCheckpointTxs, params.amount, selected.changeAmount, selected.changeAmount > 0n ? outputs.length - 1 : 0);
847
- return arkTxid;
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 = address_1.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
+ });
848
902
  }
849
903
  return this.send({
850
904
  address: params.address,
@@ -852,6 +906,9 @@ class Wallet extends ReadonlyWallet {
852
906
  });
853
907
  }
854
908
  async settle(params, eventCallback) {
909
+ return this._withTxLock(() => this._settleImpl(params, eventCallback));
910
+ }
911
+ async _settleImpl(params, eventCallback) {
855
912
  if (params?.inputs) {
856
913
  for (const input of params.inputs) {
857
914
  // validate arknotes inputs
@@ -1353,6 +1410,9 @@ class Wallet extends ReadonlyWallet {
1353
1410
  * ```
1354
1411
  */
1355
1412
  async send(...args) {
1413
+ return this._withTxLock(() => this._sendImpl(...args));
1414
+ }
1415
+ async _sendImpl(...args) {
1356
1416
  if (args.length === 0) {
1357
1417
  throw new Error("At least one receiver is required");
1358
1418
  }
@@ -0,0 +1,17 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ServiceWorkerTimeoutError = exports.MessageBusNotInitializedError = void 0;
4
+ class MessageBusNotInitializedError extends Error {
5
+ constructor() {
6
+ super("MessageBus not initialized");
7
+ this.name = "MessageBusNotInitializedError";
8
+ }
9
+ }
10
+ exports.MessageBusNotInitializedError = MessageBusNotInitializedError;
11
+ class ServiceWorkerTimeoutError extends Error {
12
+ constructor(detail) {
13
+ super(detail);
14
+ this.name = "ServiceWorkerTimeoutError";
15
+ }
16
+ }
17
+ exports.ServiceWorkerTimeoutError = ServiceWorkerTimeoutError;
@@ -8,6 +8,7 @@ const delegator_1 = require("../providers/delegator");
8
8
  const identity_1 = require("../identity");
9
9
  const wallet_1 = require("../wallet/wallet");
10
10
  const base_1 = require("@scure/base");
11
+ const errors_1 = require("./errors");
11
12
  class MessageBus {
12
13
  constructor(walletRepository, contractRepository, { messageHandlers, tickIntervalMs = 10000, messageTimeoutMs = 30000, debug = false, buildServices, }) {
13
14
  this.walletRepository = walletRepository;
@@ -183,6 +184,10 @@ class MessageBus {
183
184
  }
184
185
  async processMessage(event) {
185
186
  const { id, tag, broadcast } = event.data;
187
+ if (tag === "PING") {
188
+ event.source?.postMessage({ id, tag: "PONG" });
189
+ return;
190
+ }
186
191
  if (tag === "INITIALIZE_MESSAGE_BUS") {
187
192
  if (this.debug) {
188
193
  console.log("Init Command received");
@@ -207,7 +212,7 @@ class MessageBus {
207
212
  event.source?.postMessage({
208
213
  id,
209
214
  tag: tag ?? "unknown",
210
- error: new Error("MessageBus not initialized"),
215
+ error: new errors_1.MessageBusNotInitializedError(),
211
216
  });
212
217
  return;
213
218
  }
@@ -278,7 +283,7 @@ class MessageBus {
278
283
  return promise;
279
284
  return new Promise((resolve, reject) => {
280
285
  const timer = self.setTimeout(() => {
281
- reject(new Error(`Message handler timed out after ${this.messageTimeoutMs}ms (${label})`));
286
+ reject(new errors_1.ServiceWorkerTimeoutError(`Message handler timed out after ${this.messageTimeoutMs}ms (${label})`));
282
287
  }, this.messageTimeoutMs);
283
288
  promise.then((val) => {
284
289
  self.clearTimeout(timer);
@@ -102,7 +102,7 @@ export class SeedIdentity {
102
102
  static fromSeed(seed, opts) {
103
103
  const descriptor = hasDescriptor(opts)
104
104
  ? opts.descriptor
105
- : buildDescriptor(seed, opts.isMainnet);
105
+ : buildDescriptor(seed, opts.isMainnet ?? true);
106
106
  return new SeedIdentity(seed, descriptor);
107
107
  }
108
108
  async xOnlyPublicKey() {
@@ -190,7 +190,7 @@ export class MnemonicIdentity extends SeedIdentity {
190
190
  const seed = mnemonicToSeedSync(phrase, passphrase);
191
191
  const descriptor = hasDescriptor(opts)
192
192
  ? opts.descriptor
193
- : buildDescriptor(seed, opts.isMainnet);
193
+ : buildDescriptor(seed, opts.isMainnet ?? true);
194
194
  return new MnemonicIdentity(seed, descriptor);
195
195
  }
196
196
  }
package/dist/esm/index.js CHANGED
@@ -39,7 +39,8 @@ export * as asset from './extension/asset/index.js';
39
39
  // Contracts
40
40
  import { ContractManager, ContractWatcher, contractHandlers, DefaultContractHandler, DelegateContractHandler, VHTLCContractHandler, encodeArkContract, decodeArkContract, contractFromArkContract, contractFromArkContractWithAddress, isArkContract, } from './contracts/index.js';
41
41
  import { closeDatabase, openDatabase } from './repositories/indexedDB/manager.js';
42
- import { WalletMessageHandler } from './wallet/serviceWorker/wallet-message-handler.js';
42
+ import { WalletMessageHandler, WalletNotInitializedError, ReadonlyWalletError, DelegatorNotConfiguredError, } from './wallet/serviceWorker/wallet-message-handler.js';
43
+ import { MessageBusNotInitializedError, ServiceWorkerTimeoutError, } from './worker/errors.js';
43
44
  export {
44
45
  // Wallets
45
46
  Wallet, ReadonlyWallet, SingleKey, ReadonlySingleKey, SeedIdentity, MnemonicIdentity, ReadonlyDescriptorIdentity, OnchainWallet, Ramps, VtxoManager, DelegatorManagerImpl, RestDelegatorProvider,
@@ -50,7 +51,7 @@ ArkAddress, DefaultVtxo, DelegateVtxo, VtxoScript, VHTLC,
50
51
  // Enums
51
52
  TxType, IndexerTxType, ChainTxType, SettlementEventType,
52
53
  // Service Worker
53
- setupServiceWorker, MessageBus, WalletMessageHandler, ServiceWorkerWallet, ServiceWorkerReadonlyWallet,
54
+ setupServiceWorker, MessageBus, WalletMessageHandler, WalletNotInitializedError, ReadonlyWalletError, DelegatorNotConfiguredError, MessageBusNotInitializedError, ServiceWorkerTimeoutError, ServiceWorkerWallet, ServiceWorkerReadonlyWallet,
54
55
  // Tapscript
55
56
  decodeTapscript, MultisigTapscript, CSVMultisigTapscript, ConditionCSVMultisigTapscript, ConditionMultisigTapscript, CLTVMultisigTapscript, TapTreeCoder,
56
57
  // Ark PSBT fields
@@ -1,6 +1,24 @@
1
1
  import { RestIndexerProvider } from '../../providers/indexer.js';
2
2
  import { isExpired, isRecoverable, isSpendable, isSubdust, } from '../index.js';
3
3
  import { extendCoin, extendVirtualCoin } from '../utils.js';
4
+ export class WalletNotInitializedError extends Error {
5
+ constructor() {
6
+ super("Wallet handler not initialized");
7
+ this.name = "WalletNotInitializedError";
8
+ }
9
+ }
10
+ export class ReadonlyWalletError extends Error {
11
+ constructor() {
12
+ super("Read-only wallet: operation requires signing");
13
+ this.name = "ReadonlyWalletError";
14
+ }
15
+ }
16
+ export class DelegatorNotConfiguredError extends Error {
17
+ constructor() {
18
+ super("Delegator not configured");
19
+ this.name = "DelegatorNotConfiguredError";
20
+ }
21
+ }
4
22
  export const DEFAULT_MESSAGE_TAG = "WALLET_UPDATER";
5
23
  export class WalletMessageHandler {
6
24
  /**
@@ -21,7 +39,31 @@ export class WalletMessageHandler {
21
39
  this.walletRepository = repositories.walletRepository;
22
40
  }
23
41
  async stop() {
24
- // optional cleanup and persistence
42
+ if (this.incomingFundsSubscription) {
43
+ this.incomingFundsSubscription();
44
+ this.incomingFundsSubscription = undefined;
45
+ }
46
+ if (this.contractEventsSubscription) {
47
+ this.contractEventsSubscription();
48
+ this.contractEventsSubscription = undefined;
49
+ }
50
+ // Dispose the wallet to stop VtxoManager background tasks
51
+ // (auto-renewal, boarding UTXO polling) and ContractWatcher.
52
+ try {
53
+ if (this.wallet) {
54
+ await this.wallet.dispose();
55
+ }
56
+ else if (this.readonlyWallet) {
57
+ await this.readonlyWallet.dispose();
58
+ }
59
+ }
60
+ catch (_) {
61
+ // best-effort teardown
62
+ }
63
+ this.wallet = undefined;
64
+ this.readonlyWallet = undefined;
65
+ this.arkProvider = undefined;
66
+ this.indexerProvider = undefined;
25
67
  }
26
68
  async tick(_now) {
27
69
  const results = await Promise.allSettled(this.onNextTick.map((fn) => fn()));
@@ -44,7 +86,7 @@ export class WalletMessageHandler {
44
86
  }
45
87
  requireWallet() {
46
88
  if (!this.wallet) {
47
- throw new Error("Read-only wallet: operation requires signing");
89
+ throw new ReadonlyWalletError();
48
90
  }
49
91
  return this.wallet;
50
92
  }
@@ -66,7 +108,7 @@ export class WalletMessageHandler {
66
108
  if (!this.readonlyWallet) {
67
109
  return this.tagged({
68
110
  id,
69
- error: new Error("Wallet handler not initialized"),
111
+ error: new WalletNotInitializedError(),
70
112
  });
71
113
  }
72
114
  try {
@@ -294,7 +336,7 @@ export class WalletMessageHandler {
294
336
  const wallet = this.requireWallet();
295
337
  const delegatorManager = await wallet.getDelegatorManager();
296
338
  if (!delegatorManager) {
297
- throw new Error("Delegator not configured");
339
+ throw new DelegatorNotConfiguredError();
298
340
  }
299
341
  const info = await delegatorManager.getDelegateInfo();
300
342
  return this.tagged({
@@ -303,6 +345,83 @@ export class WalletMessageHandler {
303
345
  payload: { info },
304
346
  });
305
347
  }
348
+ case "RECOVER_VTXOS": {
349
+ const wallet = this.requireWallet();
350
+ const vtxoManager = await wallet.getVtxoManager();
351
+ const txid = await vtxoManager.recoverVtxos((e) => {
352
+ this.scheduleForNextTick(() => this.tagged({
353
+ id,
354
+ type: "RECOVER_VTXOS_EVENT",
355
+ payload: e,
356
+ }));
357
+ });
358
+ return this.tagged({
359
+ id,
360
+ type: "RECOVER_VTXOS_SUCCESS",
361
+ payload: { txid },
362
+ });
363
+ }
364
+ case "GET_RECOVERABLE_BALANCE": {
365
+ const wallet = this.requireWallet();
366
+ const vtxoManager = await wallet.getVtxoManager();
367
+ const balance = await vtxoManager.getRecoverableBalance();
368
+ return this.tagged({
369
+ id,
370
+ type: "RECOVERABLE_BALANCE",
371
+ payload: {
372
+ recoverable: balance.recoverable.toString(),
373
+ subdust: balance.subdust.toString(),
374
+ includesSubdust: balance.includesSubdust,
375
+ vtxoCount: balance.vtxoCount,
376
+ },
377
+ });
378
+ }
379
+ case "GET_EXPIRING_VTXOS": {
380
+ const wallet = this.requireWallet();
381
+ const vtxoManager = await wallet.getVtxoManager();
382
+ const vtxos = await vtxoManager.getExpiringVtxos(message.payload.thresholdMs);
383
+ return this.tagged({
384
+ id,
385
+ type: "EXPIRING_VTXOS",
386
+ payload: { vtxos },
387
+ });
388
+ }
389
+ case "RENEW_VTXOS": {
390
+ const wallet = this.requireWallet();
391
+ const vtxoManager = await wallet.getVtxoManager();
392
+ const txid = await vtxoManager.renewVtxos((e) => {
393
+ this.scheduleForNextTick(() => this.tagged({
394
+ id,
395
+ type: "RENEW_VTXOS_EVENT",
396
+ payload: e,
397
+ }));
398
+ });
399
+ return this.tagged({
400
+ id,
401
+ type: "RENEW_VTXOS_SUCCESS",
402
+ payload: { txid },
403
+ });
404
+ }
405
+ case "GET_EXPIRED_BOARDING_UTXOS": {
406
+ const wallet = this.requireWallet();
407
+ const vtxoManager = await wallet.getVtxoManager();
408
+ const utxos = await vtxoManager.getExpiredBoardingUtxos();
409
+ return this.tagged({
410
+ id,
411
+ type: "EXPIRED_BOARDING_UTXOS",
412
+ payload: { utxos },
413
+ });
414
+ }
415
+ case "SWEEP_EXPIRED_BOARDING_UTXOS": {
416
+ const wallet = this.requireWallet();
417
+ const vtxoManager = await wallet.getVtxoManager();
418
+ const txid = await vtxoManager.sweepExpiredBoardingUtxos();
419
+ return this.tagged({
420
+ id,
421
+ type: "SWEEP_EXPIRED_BOARDING_UTXOS_SUCCESS",
422
+ payload: { txid },
423
+ });
424
+ }
306
425
  default:
307
426
  console.error("Unknown message type", message);
308
427
  throw new Error("Unknown message");
@@ -478,6 +597,17 @@ export class WalletMessageHandler {
478
597
  }
479
598
  });
480
599
  await this.ensureContractEventBroadcasting();
600
+ // Eagerly start the VtxoManager so its background tasks (auto-renewal,
601
+ // boarding UTXO polling/sweep) run inside the service worker without
602
+ // waiting for a client to send a vtxo-manager message first.
603
+ if (this.wallet) {
604
+ try {
605
+ await this.wallet.getVtxoManager();
606
+ }
607
+ catch (error) {
608
+ console.error("Error starting VtxoManager:", error);
609
+ }
610
+ }
481
611
  }
482
612
  async handleSettle(message) {
483
613
  const wallet = this.requireWallet();
@@ -520,7 +650,7 @@ export class WalletMessageHandler {
520
650
  const wallet = this.requireWallet();
521
651
  const delegatorManager = await wallet.getDelegatorManager();
522
652
  if (!delegatorManager) {
523
- throw new Error("Delegator not configured");
653
+ throw new DelegatorNotConfiguredError();
524
654
  }
525
655
  const { vtxoOutpoints, destination, delegateAt } = message.payload;
526
656
  const allVtxos = await wallet.getVtxos();
@@ -547,7 +677,7 @@ export class WalletMessageHandler {
547
677
  }
548
678
  async handleGetVtxos(message) {
549
679
  if (!this.readonlyWallet) {
550
- throw new Error("Wallet handler not initialized");
680
+ throw new WalletNotInitializedError();
551
681
  }
552
682
  const vtxos = await this.getSpendableVtxos();
553
683
  const dustAmount = this.readonlyWallet.dustAmount;