@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.
- package/dist/cjs/identity/seedIdentity.js +2 -2
- package/dist/cjs/index.js +9 -2
- package/dist/cjs/wallet/serviceWorker/wallet-message-handler.js +140 -7
- package/dist/cjs/wallet/serviceWorker/wallet.js +264 -34
- package/dist/cjs/wallet/vtxo-manager.js +62 -45
- package/dist/cjs/wallet/wallet.js +94 -34
- package/dist/cjs/worker/errors.js +17 -0
- package/dist/cjs/worker/messageBus.js +7 -2
- package/dist/esm/identity/seedIdentity.js +2 -2
- package/dist/esm/index.js +3 -2
- package/dist/esm/wallet/serviceWorker/wallet-message-handler.js +136 -6
- package/dist/esm/wallet/serviceWorker/wallet.js +264 -34
- package/dist/esm/wallet/vtxo-manager.js +62 -45
- package/dist/esm/wallet/wallet.js +94 -34
- package/dist/esm/worker/errors.js +12 -0
- package/dist/esm/worker/messageBus.js +7 -2
- package/dist/types/identity/seedIdentity.d.ts +5 -2
- package/dist/types/index.d.ts +5 -4
- package/dist/types/wallet/serviceWorker/wallet-message-handler.d.ts +80 -3
- package/dist/types/wallet/serviceWorker/wallet.d.ts +8 -0
- package/dist/types/wallet/vtxo-manager.d.ts +24 -2
- package/dist/types/wallet/wallet.d.ts +10 -0
- package/dist/types/worker/errors.d.ts +6 -0
- package/package.json +1 -1
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
675
|
-
|
|
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
|
-
|
|
604
|
-
DEFAULT_SETTLEMENT_CONFIG.pollIntervalMs;
|
|
605
|
-
// Run once immediately, then on interval
|
|
600
|
+
// Run once immediately, then schedule next
|
|
606
601
|
this.pollBoardingUtxos();
|
|
607
|
-
|
|
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
|
|
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.
|
|
686
|
-
|
|
687
|
-
this.
|
|
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
|