@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.
@@ -3,6 +3,37 @@ import { setupServiceWorker } from '../../worker/browser/utils.js';
3
3
  import { IndexedDBContractRepository, IndexedDBWalletRepository, } from '../../repositories/index.js';
4
4
  import { DEFAULT_MESSAGE_TAG, } from './wallet-message-handler.js';
5
5
  import { getRandomId } from '../utils.js';
6
+ import { ServiceWorkerTimeoutError } from '../../worker/errors.js';
7
+ // Check by error name instead of instanceof because postMessage uses the
8
+ // structured clone algorithm which strips the prototype chain — the page
9
+ // receives a plain Error, not the original MessageBusNotInitializedError.
10
+ function isMessageBusNotInitializedError(error) {
11
+ return (error instanceof Error && error.name === "MessageBusNotInitializedError");
12
+ }
13
+ const DEDUPABLE_REQUEST_TYPES = new Set([
14
+ "GET_ADDRESS",
15
+ "GET_BALANCE",
16
+ "GET_BOARDING_ADDRESS",
17
+ "GET_BOARDING_UTXOS",
18
+ "GET_STATUS",
19
+ "GET_TRANSACTION_HISTORY",
20
+ "IS_CONTRACT_MANAGER_WATCHING",
21
+ "GET_DELEGATE_INFO",
22
+ "GET_RECOVERABLE_BALANCE",
23
+ "GET_EXPIRED_BOARDING_UTXOS",
24
+ "GET_VTXOS",
25
+ "GET_CONTRACTS",
26
+ "GET_CONTRACTS_WITH_VTXOS",
27
+ "GET_SPENDABLE_PATHS",
28
+ "GET_ALL_SPENDING_PATHS",
29
+ "GET_ASSET_DETAILS",
30
+ "GET_EXPIRING_VTXOS",
31
+ "RELOAD_WALLET",
32
+ ]);
33
+ function getRequestDedupKey(request) {
34
+ const { id, tag, ...rest } = request;
35
+ return JSON.stringify(rest);
36
+ }
6
37
  const isPrivateKeyIdentity = (identity) => {
7
38
  return typeof identity.toHex === "function";
8
39
  };
@@ -79,7 +110,7 @@ const initializeMessageBus = (serviceWorker, config, timeoutMs = 2000) => {
79
110
  };
80
111
  const timeoutId = setTimeout(() => {
81
112
  cleanup();
82
- reject(new Error("MessageBus timed out!"));
113
+ reject(new ServiceWorkerTimeoutError("MessageBus timed out"));
83
114
  }, timeoutMs);
84
115
  navigator.serviceWorker.addEventListener("message", onMessage);
85
116
  serviceWorker.postMessage(initCmd);
@@ -95,6 +126,8 @@ export class ServiceWorkerReadonlyWallet {
95
126
  this.initConfig = null;
96
127
  this.initWalletPayload = null;
97
128
  this.reinitPromise = null;
129
+ this.pingPromise = null;
130
+ this.inflightRequests = new Map();
98
131
  this.identity = identity;
99
132
  this.walletRepository = walletRepository;
100
133
  this.contractRepository = contractRepository;
@@ -188,7 +221,7 @@ export class ServiceWorkerReadonlyWallet {
188
221
  };
189
222
  const timeoutId = setTimeout(() => {
190
223
  cleanup();
191
- reject(new Error(`Service worker message timed out (${request.type})`));
224
+ reject(new ServiceWorkerTimeoutError(`Service worker message timed out (${request.type})`));
192
225
  }, 30000);
193
226
  const messageHandler = (event) => {
194
227
  const response = event.data;
@@ -207,18 +240,137 @@ export class ServiceWorkerReadonlyWallet {
207
240
  this.serviceWorker.postMessage(request);
208
241
  });
209
242
  }
243
+ // Like sendMessageDirect but supports streaming responses: intermediate
244
+ // messages are forwarded via onEvent while the promise resolves on the
245
+ // first response for which isComplete returns true. The timeout resets
246
+ // on every intermediate event so long-running but progressing operations
247
+ // don't time out prematurely.
248
+ sendMessageStreaming(request, onEvent, isComplete) {
249
+ return new Promise((resolve, reject) => {
250
+ const resetTimeout = () => {
251
+ clearTimeout(timeoutId);
252
+ timeoutId = setTimeout(() => {
253
+ cleanup();
254
+ reject(new ServiceWorkerTimeoutError(`Service worker message timed out (${request.type})`));
255
+ }, 30000);
256
+ };
257
+ const cleanup = () => {
258
+ clearTimeout(timeoutId);
259
+ navigator.serviceWorker.removeEventListener("message", messageHandler);
260
+ };
261
+ let timeoutId;
262
+ resetTimeout();
263
+ const messageHandler = (event) => {
264
+ const response = event.data;
265
+ if (request.id !== response.id)
266
+ return;
267
+ if (response.error) {
268
+ cleanup();
269
+ reject(response.error);
270
+ return;
271
+ }
272
+ if (isComplete(response)) {
273
+ cleanup();
274
+ resolve(response);
275
+ }
276
+ else {
277
+ resetTimeout();
278
+ onEvent(response);
279
+ }
280
+ };
281
+ navigator.serviceWorker.addEventListener("message", messageHandler);
282
+ this.serviceWorker.postMessage(request);
283
+ });
284
+ }
285
+ async sendMessage(request) {
286
+ if (!DEDUPABLE_REQUEST_TYPES.has(request.type)) {
287
+ return this.sendMessageWithRetry(request);
288
+ }
289
+ const key = getRequestDedupKey(request);
290
+ const existing = this.inflightRequests.get(key);
291
+ if (existing)
292
+ return existing;
293
+ const promise = this.sendMessageWithRetry(request).finally(() => {
294
+ this.inflightRequests.delete(key);
295
+ });
296
+ this.inflightRequests.set(key, promise);
297
+ return promise;
298
+ }
299
+ pingServiceWorker() {
300
+ if (this.pingPromise)
301
+ return this.pingPromise;
302
+ this.pingPromise = new Promise((resolve, reject) => {
303
+ const pingId = getRandomId();
304
+ const cleanup = () => {
305
+ clearTimeout(timeoutId);
306
+ navigator.serviceWorker.removeEventListener("message", onMessage);
307
+ };
308
+ const timeoutId = setTimeout(() => {
309
+ cleanup();
310
+ reject(new ServiceWorkerTimeoutError("Service worker ping timed out"));
311
+ }, 2000);
312
+ const onMessage = (event) => {
313
+ if (event.data?.id === pingId && event.data?.tag === "PONG") {
314
+ cleanup();
315
+ resolve();
316
+ }
317
+ };
318
+ navigator.serviceWorker.addEventListener("message", onMessage);
319
+ this.serviceWorker.postMessage({
320
+ id: pingId,
321
+ tag: "PING",
322
+ });
323
+ }).finally(() => {
324
+ this.pingPromise = null;
325
+ });
326
+ return this.pingPromise;
327
+ }
210
328
  // send a message, retrying up to 2 times if the service worker was
211
329
  // killed and restarted by the OS (mobile browsers do this aggressively)
212
- async sendMessage(request) {
330
+ async sendMessageWithRetry(request) {
331
+ // Skip the preflight ping during the initial INIT_WALLET call:
332
+ // create() hasn't set initConfig yet, so reinitialize() would throw.
333
+ if (this.initConfig) {
334
+ try {
335
+ await this.pingServiceWorker();
336
+ }
337
+ catch {
338
+ await this.reinitialize();
339
+ }
340
+ }
213
341
  const maxRetries = 2;
214
342
  for (let attempt = 0;; attempt++) {
215
343
  try {
216
344
  return await this.sendMessageDirect(request);
217
345
  }
218
346
  catch (error) {
219
- const isNotInitialized = typeof error?.message === "string" &&
220
- error.message.includes("MessageBus not initialized");
221
- if (!isNotInitialized || attempt >= maxRetries) {
347
+ if (!isMessageBusNotInitializedError(error) ||
348
+ attempt >= maxRetries) {
349
+ throw error;
350
+ }
351
+ await this.reinitialize();
352
+ }
353
+ }
354
+ }
355
+ // Like sendMessage but for streaming responses — retries with
356
+ // reinitialize when the service worker has been killed/restarted.
357
+ async sendMessageWithEvents(request, onEvent, isComplete) {
358
+ if (this.initConfig) {
359
+ try {
360
+ await this.pingServiceWorker();
361
+ }
362
+ catch {
363
+ await this.reinitialize();
364
+ }
365
+ }
366
+ const maxRetries = 2;
367
+ for (let attempt = 0;; attempt++) {
368
+ try {
369
+ return await this.sendMessageStreaming(request, onEvent, isComplete);
370
+ }
371
+ catch (error) {
372
+ if (!isMessageBusNotInitializedError(error) ||
373
+ attempt >= maxRetries) {
222
374
  throw error;
223
375
  }
224
376
  await this.reinitialize();
@@ -671,34 +823,8 @@ export class ServiceWorkerWallet extends ServiceWorkerReadonlyWallet {
671
823
  payload: { params },
672
824
  };
673
825
  try {
674
- return new Promise((resolve, reject) => {
675
- const messageHandler = (event) => {
676
- const response = event.data;
677
- if (response.id !== message.id) {
678
- return;
679
- }
680
- if (response.error) {
681
- navigator.serviceWorker.removeEventListener("message", messageHandler);
682
- reject(response.error);
683
- return;
684
- }
685
- switch (response.type) {
686
- case "SETTLE_EVENT":
687
- if (callback) {
688
- callback(response.payload);
689
- }
690
- break;
691
- case "SETTLE_SUCCESS":
692
- navigator.serviceWorker.removeEventListener("message", messageHandler);
693
- resolve(response.payload.txid);
694
- break;
695
- default:
696
- console.error(`Unexpected response type for SETTLE request: ${response.type}`);
697
- }
698
- };
699
- navigator.serviceWorker.addEventListener("message", messageHandler);
700
- this.serviceWorker.postMessage(message);
701
- });
826
+ const response = await this.sendMessageWithEvents(message, (resp) => callback?.(resp.payload), (resp) => resp.type === "SETTLE_SUCCESS");
827
+ return response.payload.txid;
702
828
  }
703
829
  catch (error) {
704
830
  throw new Error(`Settlement failed: ${error}`);
@@ -772,4 +898,108 @@ export class ServiceWorkerWallet extends ServiceWorkerReadonlyWallet {
772
898
  };
773
899
  return manager;
774
900
  }
901
+ async getVtxoManager() {
902
+ const wallet = this;
903
+ const messageTag = this.messageTag;
904
+ const manager = {
905
+ async recoverVtxos(eventCallback) {
906
+ const message = {
907
+ tag: messageTag,
908
+ type: "RECOVER_VTXOS",
909
+ id: getRandomId(),
910
+ };
911
+ try {
912
+ const response = await wallet.sendMessageWithEvents(message, (resp) => eventCallback?.(resp.payload), (resp) => resp.type === "RECOVER_VTXOS_SUCCESS");
913
+ return response.payload.txid;
914
+ }
915
+ catch (e) {
916
+ throw new Error(`Failed to recover vtxos: ${e}`);
917
+ }
918
+ },
919
+ async getRecoverableBalance() {
920
+ const message = {
921
+ tag: messageTag,
922
+ type: "GET_RECOVERABLE_BALANCE",
923
+ id: getRandomId(),
924
+ };
925
+ try {
926
+ const response = await wallet.sendMessage(message);
927
+ const payload = response
928
+ .payload;
929
+ return {
930
+ recoverable: BigInt(payload.recoverable),
931
+ subdust: BigInt(payload.subdust),
932
+ includesSubdust: payload.includesSubdust,
933
+ vtxoCount: payload.vtxoCount,
934
+ };
935
+ }
936
+ catch (e) {
937
+ throw new Error(`Failed to get recoverable balance: ${e}`);
938
+ }
939
+ },
940
+ async getExpiringVtxos(thresholdMs) {
941
+ const message = {
942
+ tag: messageTag,
943
+ type: "GET_EXPIRING_VTXOS",
944
+ id: getRandomId(),
945
+ payload: { thresholdMs },
946
+ };
947
+ try {
948
+ const response = await wallet.sendMessage(message);
949
+ return response.payload.vtxos;
950
+ }
951
+ catch (e) {
952
+ throw new Error(`Failed to get expiring vtxos: ${e}`);
953
+ }
954
+ },
955
+ async renewVtxos(eventCallback) {
956
+ const message = {
957
+ tag: messageTag,
958
+ type: "RENEW_VTXOS",
959
+ id: getRandomId(),
960
+ };
961
+ try {
962
+ const response = await wallet.sendMessageWithEvents(message, (resp) => eventCallback?.(resp.payload), (resp) => resp.type === "RENEW_VTXOS_SUCCESS");
963
+ return response.payload.txid;
964
+ }
965
+ catch (e) {
966
+ throw new Error(`Failed to renew vtxos: ${e}`);
967
+ }
968
+ },
969
+ async getExpiredBoardingUtxos() {
970
+ const message = {
971
+ tag: messageTag,
972
+ type: "GET_EXPIRED_BOARDING_UTXOS",
973
+ id: getRandomId(),
974
+ };
975
+ try {
976
+ const response = await wallet.sendMessage(message);
977
+ return response.payload
978
+ .utxos;
979
+ }
980
+ catch (e) {
981
+ throw new Error(`Failed to get expired boarding utxos: ${e}`);
982
+ }
983
+ },
984
+ async sweepExpiredBoardingUtxos() {
985
+ const message = {
986
+ tag: messageTag,
987
+ type: "SWEEP_EXPIRED_BOARDING_UTXOS",
988
+ id: getRandomId(),
989
+ };
990
+ try {
991
+ const response = await wallet.sendMessage(message);
992
+ return response
993
+ .payload.txid;
994
+ }
995
+ catch (e) {
996
+ throw new Error(`Failed to sweep expired boarding utxos: ${e}`);
997
+ }
998
+ },
999
+ async dispose() {
1000
+ return;
1001
+ },
1002
+ };
1003
+ return manager;
1004
+ }
775
1005
  }
@@ -143,43 +143,6 @@ export function getExpiringAndRecoverableVtxos(vtxos, thresholdMs, dustAmount) {
143
143
  (isSpendable(vtxo) && isExpired(vtxo)) ||
144
144
  isSubdust(vtxo, dustAmount));
145
145
  }
146
- /**
147
- * VtxoManager is a unified class for managing VTXO lifecycle operations including
148
- * recovery of swept/expired VTXOs and renewal to prevent expiration.
149
- *
150
- * Key Features:
151
- * - **Recovery**: Reclaim swept or expired VTXOs back to the wallet
152
- * - **Renewal**: Refresh VTXO expiration time before they expire
153
- * - **Smart subdust handling**: Automatically includes subdust VTXOs when economically viable
154
- * - **Expiry monitoring**: Check for VTXOs that are expiring soon
155
- *
156
- * VTXOs become recoverable when:
157
- * - The Ark server sweeps them (virtualStatus.state === "swept") and they remain spendable
158
- * - They are preconfirmed subdust (to consolidate small amounts without locking liquidity on settled VTXOs)
159
- *
160
- * @example
161
- * ```typescript
162
- * // Initialize with renewal config
163
- * const manager = new VtxoManager(wallet, {
164
- * enabled: true,
165
- * thresholdMs: 86400000
166
- * });
167
- *
168
- * // Check recoverable balance
169
- * const balance = await manager.getRecoverableBalance();
170
- * if (balance.recoverable > 0n) {
171
- * console.log(`Can recover ${balance.recoverable} sats`);
172
- * const txid = await manager.recoverVtxos();
173
- * }
174
- *
175
- * // Check for expiring VTXOs
176
- * const expiring = await manager.getExpiringVtxos();
177
- * if (expiring.length > 0) {
178
- * console.log(`${expiring.length} VTXOs expiring soon`);
179
- * const txid = await manager.renewVtxos();
180
- * }
181
- * ```
182
- */
183
146
  export class VtxoManager {
184
147
  constructor(wallet,
185
148
  /** @deprecated Use settlementConfig instead */
@@ -189,6 +152,7 @@ export class VtxoManager {
189
152
  this.knownBoardingUtxos = new Set();
190
153
  this.sweptBoardingUtxos = new Set();
191
154
  this.pollInProgress = false;
155
+ this.consecutivePollFailures = 0;
192
156
  // Normalize: prefer settlementConfig, fall back to renewalConfig, default to enabled
193
157
  if (settlementConfig !== undefined) {
194
158
  this.settlementConfig = settlementConfig;
@@ -577,6 +541,25 @@ export class VtxoManager {
577
541
  return;
578
542
  }
579
543
  this.renewVtxos().catch((e) => {
544
+ if (e instanceof Error) {
545
+ if (e.message.includes("No VTXOs available to renew")) {
546
+ // Not an error, just no VTXO eligible for renewal.
547
+ return;
548
+ }
549
+ if (e.message.includes("is below dust threshold")) {
550
+ // Not an error, just below dust threshold.
551
+ // As more VTXOs are received, the threshold will be raised.
552
+ return;
553
+ }
554
+ if (e.message.includes("VTXO_ALREADY_REGISTERED") ||
555
+ e.message.includes("duplicated input")) {
556
+ // VTXO is already being used in a concurrent
557
+ // user-initiated operation. Skip silently — the
558
+ // wallet's tx lock serializes these, but the
559
+ // renewal will retry on the next cycle.
560
+ return;
561
+ }
562
+ }
580
563
  console.error("Error renewing VTXOs:", e);
581
564
  });
582
565
  delegatorManager
@@ -592,19 +575,36 @@ export class VtxoManager {
592
575
  return undefined;
593
576
  }
594
577
  }
578
+ /** Computes the next poll delay, applying exponential backoff on failures. */
579
+ getNextPollDelay() {
580
+ if (this.settlementConfig === false)
581
+ return 0;
582
+ const baseMs = this.settlementConfig.pollIntervalMs ??
583
+ DEFAULT_SETTLEMENT_CONFIG.pollIntervalMs;
584
+ if (this.consecutivePollFailures === 0)
585
+ return baseMs;
586
+ const backoff = Math.min(baseMs * Math.pow(2, this.consecutivePollFailures), VtxoManager.MAX_BACKOFF_MS);
587
+ return backoff;
588
+ }
595
589
  /**
596
590
  * Starts a polling loop that:
597
591
  * 1. Auto-settles new boarding UTXOs into Ark
598
592
  * 2. Sweeps expired boarding UTXOs (when boardingUtxoSweep is enabled)
593
+ *
594
+ * Uses setTimeout chaining (not setInterval) so a slow/blocked poll
595
+ * cannot stack up and the next delay can incorporate backoff.
599
596
  */
600
597
  startBoardingUtxoPoll() {
601
598
  if (this.settlementConfig === false)
602
599
  return;
603
- const intervalMs = this.settlementConfig.pollIntervalMs ??
604
- DEFAULT_SETTLEMENT_CONFIG.pollIntervalMs;
605
- // Run once immediately, then on interval
600
+ // Run once immediately, then schedule next
606
601
  this.pollBoardingUtxos();
607
- this.pollIntervalId = setInterval(() => this.pollBoardingUtxos(), intervalMs);
602
+ }
603
+ schedulePoll() {
604
+ if (this.settlementConfig === false)
605
+ return;
606
+ const delay = this.getNextPollDelay();
607
+ this.pollTimeoutId = setTimeout(() => this.pollBoardingUtxos(), delay);
608
608
  }
609
609
  async pollBoardingUtxos() {
610
610
  // Guard: wallet must support boarding UTXO + sweep operations
@@ -614,6 +614,7 @@ export class VtxoManager {
614
614
  if (this.pollInProgress)
615
615
  return;
616
616
  this.pollInProgress = true;
617
+ let hadError = false;
617
618
  try {
618
619
  // Settle new (unexpired) UTXOs first, then sweep expired ones.
619
620
  // Sequential to avoid racing for the same UTXOs.
@@ -621,6 +622,7 @@ export class VtxoManager {
621
622
  await this.settleBoardingUtxos();
622
623
  }
623
624
  catch (e) {
625
+ hadError = true;
624
626
  console.error("Error auto-settling boarding UTXOs:", e);
625
627
  }
626
628
  const sweepEnabled = this.settlementConfig !== false &&
@@ -633,13 +635,21 @@ export class VtxoManager {
633
635
  catch (e) {
634
636
  if (!(e instanceof Error) ||
635
637
  !e.message.includes("No expired boarding UTXOs")) {
638
+ hadError = true;
636
639
  console.error("Error auto-sweeping boarding UTXOs:", e);
637
640
  }
638
641
  }
639
642
  }
640
643
  }
641
644
  finally {
645
+ if (hadError) {
646
+ this.consecutivePollFailures++;
647
+ }
648
+ else {
649
+ this.consecutivePollFailures = 0;
650
+ }
642
651
  this.pollInProgress = false;
652
+ this.schedulePoll();
643
653
  }
644
654
  }
645
655
  /**
@@ -656,7 +666,13 @@ export class VtxoManager {
656
666
  // accidentally settling expired UTXOs (which would conflict with sweep).
657
667
  let expiredSet;
658
668
  try {
659
- const expired = await this.getExpiredBoardingUtxos();
669
+ const boardingTimelock = this.getBoardingTimelock();
670
+ let chainTipHeight;
671
+ if (boardingTimelock.type === "blocks") {
672
+ const tip = await this.getOnchainProvider().getChainTip();
673
+ chainTipHeight = tip.height;
674
+ }
675
+ const expired = boardingUtxos.filter((utxo) => hasBoardingTxExpired(utxo, boardingTimelock, chainTipHeight));
660
676
  expiredSet = new Set(expired.map((u) => `${u.txid}:${u.vout}`));
661
677
  }
662
678
  catch {
@@ -682,9 +698,9 @@ export class VtxoManager {
682
698
  }
683
699
  async dispose() {
684
700
  this.disposePromise ?? (this.disposePromise = (async () => {
685
- if (this.pollIntervalId) {
686
- clearInterval(this.pollIntervalId);
687
- this.pollIntervalId = undefined;
701
+ if (this.pollTimeoutId) {
702
+ clearTimeout(this.pollTimeoutId);
703
+ this.pollTimeoutId = undefined;
688
704
  }
689
705
  const subscription = await this.contractEventsSubscriptionReady;
690
706
  this.contractEventsSubscription = undefined;
@@ -696,3 +712,4 @@ export class VtxoManager {
696
712
  await this.dispose();
697
713
  }
698
714
  }
715
+ VtxoManager.MAX_BACKOFF_MS = 5 * 60 * 1000; // 5 minutes