@arkade-os/sdk 0.4.10 → 0.4.12
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/contracts/contractManager.js +181 -25
- package/dist/cjs/providers/indexer.js +27 -4
- package/dist/cjs/utils/syncCursors.js +136 -0
- package/dist/cjs/wallet/delegator.js +9 -6
- package/dist/cjs/wallet/serviceWorker/wallet-message-handler.js +63 -33
- package/dist/cjs/wallet/serviceWorker/wallet.js +2 -1
- package/dist/cjs/wallet/vtxo-manager.js +30 -9
- package/dist/cjs/wallet/wallet.js +193 -38
- package/dist/esm/contracts/contractManager.js +181 -25
- package/dist/esm/providers/indexer.js +27 -4
- package/dist/esm/utils/syncCursors.js +125 -0
- package/dist/esm/wallet/delegator.js +9 -6
- package/dist/esm/wallet/serviceWorker/wallet-message-handler.js +63 -33
- package/dist/esm/wallet/serviceWorker/wallet.js +2 -1
- package/dist/esm/wallet/vtxo-manager.js +30 -9
- package/dist/esm/wallet/wallet.js +193 -38
- package/dist/types/contracts/contractManager.d.ts +28 -7
- package/dist/types/contracts/index.d.ts +1 -1
- package/dist/types/providers/indexer.d.ts +16 -14
- package/dist/types/utils/syncCursors.d.ts +58 -0
- package/dist/types/wallet/serviceWorker/wallet-message-handler.d.ts +14 -2
- package/dist/types/wallet/vtxo-manager.d.ts +3 -2
- package/dist/types/wallet/wallet.d.ts +20 -0
- package/package.json +2 -2
|
@@ -2,6 +2,8 @@ import { hex } from "@scure/base";
|
|
|
2
2
|
import { ContractWatcher } from './contractWatcher.js';
|
|
3
3
|
import { contractHandlers } from './handlers/index.js';
|
|
4
4
|
import { extendVtxoFromContract } from '../wallet/utils.js';
|
|
5
|
+
import { advanceSyncCursors, clearSyncCursors, computeSyncWindow, cursorCutoff, getAllSyncCursors, } from '../utils/syncCursors.js';
|
|
6
|
+
const DEFAULT_PAGE_SIZE = 500;
|
|
5
7
|
/**
|
|
6
8
|
* Central manager for contract lifecycle and operations.
|
|
7
9
|
*
|
|
@@ -71,10 +73,14 @@ export class ContractManager {
|
|
|
71
73
|
}
|
|
72
74
|
// Load persisted contracts
|
|
73
75
|
const contracts = await this.config.contractRepository.getContracts();
|
|
74
|
-
// fetch
|
|
75
|
-
//
|
|
76
|
-
|
|
77
|
-
|
|
76
|
+
// Delta-sync: fetch only VTXOs that changed since the last cursor,
|
|
77
|
+
// falling back to a full bootstrap for scripts seen for the first time.
|
|
78
|
+
await this.deltaSyncContracts(contracts);
|
|
79
|
+
// Reconcile the pending frontier: fetch all not-yet-finalized VTXOs
|
|
80
|
+
// to catch any that the delta window may have missed.
|
|
81
|
+
if (contracts.length > 0) {
|
|
82
|
+
await this.reconcilePendingFrontier(contracts);
|
|
83
|
+
}
|
|
78
84
|
// add all contracts to the watcher
|
|
79
85
|
const now = Date.now();
|
|
80
86
|
for (const contract of contracts) {
|
|
@@ -91,7 +97,9 @@ export class ContractManager {
|
|
|
91
97
|
this.initialized = true;
|
|
92
98
|
// Start watching automatically
|
|
93
99
|
this.stopWatcherFn = await this.watcher.startWatching((event) => {
|
|
94
|
-
this.handleContractEvent(event)
|
|
100
|
+
this.handleContractEvent(event).catch((error) => {
|
|
101
|
+
console.error("Error handling contract event:", error);
|
|
102
|
+
});
|
|
95
103
|
});
|
|
96
104
|
}
|
|
97
105
|
/**
|
|
@@ -138,7 +146,15 @@ export class ContractManager {
|
|
|
138
146
|
// Persist
|
|
139
147
|
await this.config.contractRepository.saveContract(contract);
|
|
140
148
|
// fetch all VTXOs (including spent/swept) for this contract
|
|
149
|
+
const requestStartedAt = Date.now();
|
|
141
150
|
await this.fetchContractVxosFromIndexer([contract], true);
|
|
151
|
+
// Advance the sync cursor so that the watcher's vtxo_received
|
|
152
|
+
// event (triggered by addContract below) doesn't re-bootstrap
|
|
153
|
+
// the same script via deltaSyncContracts.
|
|
154
|
+
const cutoff = cursorCutoff(requestStartedAt);
|
|
155
|
+
await advanceSyncCursors(this.config.walletRepository, {
|
|
156
|
+
[contract.script]: cutoff,
|
|
157
|
+
});
|
|
142
158
|
// Add to watcher
|
|
143
159
|
await this.watcher.addContract(contract);
|
|
144
160
|
return contract;
|
|
@@ -162,9 +178,9 @@ export class ContractManager {
|
|
|
162
178
|
const dbFilter = this.buildContractsDbFilter(filter ?? {});
|
|
163
179
|
return await this.config.contractRepository.getContracts(dbFilter);
|
|
164
180
|
}
|
|
165
|
-
async getContractsWithVtxos(filter) {
|
|
181
|
+
async getContractsWithVtxos(filter, pageSize) {
|
|
166
182
|
const contracts = await this.getContracts(filter);
|
|
167
|
-
const vtxos = await this.getVtxosForContracts(contracts);
|
|
183
|
+
const vtxos = await this.getVtxosForContracts(contracts, pageSize);
|
|
168
184
|
return contracts.map((contract) => ({
|
|
169
185
|
contract,
|
|
170
186
|
vtxos: vtxos.get(contract.script) ?? [],
|
|
@@ -304,12 +320,43 @@ export class ContractManager {
|
|
|
304
320
|
};
|
|
305
321
|
}
|
|
306
322
|
/**
|
|
307
|
-
* Force a
|
|
308
|
-
*
|
|
323
|
+
* Force a VTXO refresh from the indexer.
|
|
324
|
+
*
|
|
325
|
+
* Without options, clears all sync cursors and re-fetches every contract.
|
|
326
|
+
* With options, narrows the refresh to specific scripts and/or a time window.
|
|
309
327
|
*/
|
|
310
|
-
async refreshVtxos() {
|
|
311
|
-
|
|
312
|
-
|
|
328
|
+
async refreshVtxos(opts) {
|
|
329
|
+
let contracts = await this.config.contractRepository.getContracts();
|
|
330
|
+
if (opts?.scripts && opts.scripts.length > 0) {
|
|
331
|
+
const scriptSet = new Set(opts.scripts);
|
|
332
|
+
contracts = contracts.filter((c) => scriptSet.has(c.script));
|
|
333
|
+
}
|
|
334
|
+
const syncWindow = opts?.after !== undefined || opts?.before !== undefined
|
|
335
|
+
? {
|
|
336
|
+
after: opts.after ?? 0,
|
|
337
|
+
before: opts.before ?? Date.now(),
|
|
338
|
+
}
|
|
339
|
+
: undefined;
|
|
340
|
+
if (!syncWindow) {
|
|
341
|
+
// Full refresh — clear cursors so the next delta sync re-bootstraps.
|
|
342
|
+
if (opts?.scripts && opts.scripts.length > 0) {
|
|
343
|
+
await clearSyncCursors(this.config.walletRepository, opts.scripts);
|
|
344
|
+
}
|
|
345
|
+
else {
|
|
346
|
+
await clearSyncCursors(this.config.walletRepository);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
const requestStartedAt = Date.now();
|
|
350
|
+
const fetched = await this.fetchContractVxosFromIndexer(contracts, true, undefined, syncWindow);
|
|
351
|
+
// Persist cursors so subsequent incremental syncs don't re-bootstrap.
|
|
352
|
+
const cutoff = cursorCutoff(requestStartedAt);
|
|
353
|
+
const cursorUpdates = {};
|
|
354
|
+
for (const script of fetched.keys()) {
|
|
355
|
+
cursorUpdates[script] = cutoff;
|
|
356
|
+
}
|
|
357
|
+
if (Object.keys(cursorUpdates).length > 0) {
|
|
358
|
+
await advanceSyncCursors(this.config.walletRepository, cursorUpdates);
|
|
359
|
+
}
|
|
313
360
|
}
|
|
314
361
|
/**
|
|
315
362
|
* Check if currently watching.
|
|
@@ -335,14 +382,13 @@ export class ContractManager {
|
|
|
335
382
|
*/
|
|
336
383
|
async handleContractEvent(event) {
|
|
337
384
|
switch (event.type) {
|
|
338
|
-
//
|
|
385
|
+
// Delta-sync only the changed VTXOs for this contract.
|
|
339
386
|
case "vtxo_received":
|
|
340
387
|
case "vtxo_spent":
|
|
341
|
-
await this.
|
|
388
|
+
await this.deltaSyncContracts([event.contract]);
|
|
342
389
|
break;
|
|
343
390
|
case "connection_reset": {
|
|
344
|
-
//
|
|
345
|
-
// contracts so the repo stays consistent with bootstrap state
|
|
391
|
+
// After a reconnect we don't know what we missed — full refetch.
|
|
346
392
|
const activeWatchedContracts = this.watcher.getActiveContracts();
|
|
347
393
|
await this.fetchContractVxosFromIndexer(activeWatchedContracts, true);
|
|
348
394
|
break;
|
|
@@ -354,14 +400,100 @@ export class ContractManager {
|
|
|
354
400
|
// Forward to all callbacks
|
|
355
401
|
this.emitEvent(event);
|
|
356
402
|
}
|
|
357
|
-
async getVtxosForContracts(contracts) {
|
|
403
|
+
async getVtxosForContracts(contracts, pageSize) {
|
|
358
404
|
if (contracts.length === 0) {
|
|
359
405
|
return new Map();
|
|
360
406
|
}
|
|
361
|
-
return await this.fetchContractVxosFromIndexer(contracts, false);
|
|
407
|
+
return await this.fetchContractVxosFromIndexer(contracts, false, pageSize);
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* Incrementally sync VTXOs for the given contracts.
|
|
411
|
+
* Uses per-script cursors to fetch only what changed since the last sync.
|
|
412
|
+
* Scripts without a cursor are bootstrapped with a full fetch.
|
|
413
|
+
*/
|
|
414
|
+
async deltaSyncContracts(contracts, pageSize) {
|
|
415
|
+
if (contracts.length === 0)
|
|
416
|
+
return new Map();
|
|
417
|
+
const cursors = await getAllSyncCursors(this.config.walletRepository);
|
|
418
|
+
// Partition into bootstrap (no cursor) and delta (has cursor) groups.
|
|
419
|
+
const bootstrap = [];
|
|
420
|
+
const delta = [];
|
|
421
|
+
for (const c of contracts) {
|
|
422
|
+
if (cursors[c.script] !== undefined) {
|
|
423
|
+
delta.push(c);
|
|
424
|
+
}
|
|
425
|
+
else {
|
|
426
|
+
bootstrap.push(c);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
const result = new Map();
|
|
430
|
+
const cursorUpdates = {};
|
|
431
|
+
// Full bootstrap for new scripts.
|
|
432
|
+
if (bootstrap.length > 0) {
|
|
433
|
+
const requestStartedAt = Date.now();
|
|
434
|
+
const fetched = await this.fetchContractVxosFromIndexer(bootstrap, true);
|
|
435
|
+
const cutoff = cursorCutoff(requestStartedAt);
|
|
436
|
+
for (const [script, vtxos] of fetched) {
|
|
437
|
+
result.set(script, vtxos);
|
|
438
|
+
cursorUpdates[script] = cutoff;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
// Delta sync for scripts with an existing cursor.
|
|
442
|
+
if (delta.length > 0) {
|
|
443
|
+
// Use the oldest cursor so the shared window covers every script.
|
|
444
|
+
const minCursor = Math.min(...delta.map((c) => cursors[c.script]));
|
|
445
|
+
const window = computeSyncWindow(minCursor);
|
|
446
|
+
if (window) {
|
|
447
|
+
const requestStartedAt = Date.now();
|
|
448
|
+
const fetched = await this.fetchContractVxosFromIndexer(delta, true, pageSize, window);
|
|
449
|
+
const cutoff = cursorCutoff(requestStartedAt);
|
|
450
|
+
for (const [script, vtxos] of fetched) {
|
|
451
|
+
result.set(script, vtxos);
|
|
452
|
+
cursorUpdates[script] = cutoff;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
if (Object.keys(cursorUpdates).length > 0) {
|
|
457
|
+
await advanceSyncCursors(this.config.walletRepository, cursorUpdates);
|
|
458
|
+
}
|
|
459
|
+
return result;
|
|
362
460
|
}
|
|
363
|
-
|
|
364
|
-
|
|
461
|
+
/**
|
|
462
|
+
* Fetch all pending (not-yet-finalized) VTXOs and upsert them into the
|
|
463
|
+
* repository. This catches VTXOs whose state changed outside the delta
|
|
464
|
+
* window (e.g. a spend that hasn't settled yet).
|
|
465
|
+
*/
|
|
466
|
+
async reconcilePendingFrontier(contracts) {
|
|
467
|
+
const scripts = contracts.map((c) => c.script);
|
|
468
|
+
const scriptToContract = new Map(contracts.map((c) => [c.script, c]));
|
|
469
|
+
const { vtxos } = await this.config.indexerProvider.getVtxos({
|
|
470
|
+
scripts,
|
|
471
|
+
pendingOnly: true,
|
|
472
|
+
});
|
|
473
|
+
// Group by contract and upsert.
|
|
474
|
+
const byContract = new Map();
|
|
475
|
+
for (const vtxo of vtxos) {
|
|
476
|
+
if (!vtxo.script)
|
|
477
|
+
continue;
|
|
478
|
+
const contract = scriptToContract.get(vtxo.script);
|
|
479
|
+
if (!contract)
|
|
480
|
+
continue;
|
|
481
|
+
let arr = byContract.get(contract.address);
|
|
482
|
+
if (!arr) {
|
|
483
|
+
arr = [];
|
|
484
|
+
byContract.set(contract.address, arr);
|
|
485
|
+
}
|
|
486
|
+
arr.push({
|
|
487
|
+
...extendVtxoFromContract(vtxo, contract),
|
|
488
|
+
contractScript: contract.script,
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
for (const [addr, contractVtxos] of byContract) {
|
|
492
|
+
await this.config.walletRepository.saveVtxos(addr, contractVtxos);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
async fetchContractVxosFromIndexer(contracts, includeSpent, pageSize, syncWindow) {
|
|
496
|
+
const fetched = await this.fetchContractVtxosBulk(contracts, includeSpent, pageSize, syncWindow);
|
|
365
497
|
const result = new Map();
|
|
366
498
|
for (const [contractScript, vtxos] of fetched) {
|
|
367
499
|
result.set(contractScript, vtxos);
|
|
@@ -372,14 +504,14 @@ export class ContractManager {
|
|
|
372
504
|
}
|
|
373
505
|
return result;
|
|
374
506
|
}
|
|
375
|
-
async fetchContractVtxosBulk(contracts, includeSpent) {
|
|
507
|
+
async fetchContractVtxosBulk(contracts, includeSpent, pageSize = DEFAULT_PAGE_SIZE, syncWindow) {
|
|
376
508
|
if (contracts.length === 0) {
|
|
377
509
|
return new Map();
|
|
378
510
|
}
|
|
379
511
|
// For a single contract, use the paginated path directly.
|
|
380
512
|
if (contracts.length === 1) {
|
|
381
513
|
const contract = contracts[0];
|
|
382
|
-
const vtxos = await this.fetchContractVtxosPaginated(contract, includeSpent);
|
|
514
|
+
const vtxos = await this.fetchContractVtxosPaginated(contract, includeSpent, pageSize, syncWindow);
|
|
383
515
|
return new Map([[contract.script, vtxos]]);
|
|
384
516
|
}
|
|
385
517
|
// For multiple contracts, batch all scripts into a single indexer call
|
|
@@ -388,14 +520,24 @@ export class ContractManager {
|
|
|
388
520
|
const scriptToContract = new Map(contracts.map((c) => [c.script, c]));
|
|
389
521
|
const result = new Map(contracts.map((c) => [c.script, []]));
|
|
390
522
|
const scripts = contracts.map((c) => c.script);
|
|
391
|
-
const pageSize = 100;
|
|
392
523
|
const opts = includeSpent ? {} : { spendableOnly: true };
|
|
524
|
+
const windowOpts = syncWindow
|
|
525
|
+
? {
|
|
526
|
+
...(syncWindow.after !== undefined && {
|
|
527
|
+
after: syncWindow.after,
|
|
528
|
+
}),
|
|
529
|
+
...(syncWindow.before !== undefined && {
|
|
530
|
+
before: syncWindow.before,
|
|
531
|
+
}),
|
|
532
|
+
}
|
|
533
|
+
: {};
|
|
393
534
|
let pageIndex = 0;
|
|
394
535
|
let hasMore = true;
|
|
395
536
|
while (hasMore) {
|
|
396
537
|
const { vtxos, page } = await this.config.indexerProvider.getVtxos({
|
|
397
538
|
scripts,
|
|
398
539
|
...opts,
|
|
540
|
+
...windowOpts,
|
|
399
541
|
pageIndex,
|
|
400
542
|
pageSize,
|
|
401
543
|
});
|
|
@@ -414,19 +556,31 @@ export class ContractManager {
|
|
|
414
556
|
}
|
|
415
557
|
hasMore = page ? vtxos.length === pageSize : false;
|
|
416
558
|
pageIndex++;
|
|
559
|
+
if (hasMore)
|
|
560
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
417
561
|
}
|
|
418
562
|
return result;
|
|
419
563
|
}
|
|
420
|
-
async fetchContractVtxosPaginated(contract, includeSpent) {
|
|
421
|
-
const pageSize = 100;
|
|
564
|
+
async fetchContractVtxosPaginated(contract, includeSpent, pageSize = DEFAULT_PAGE_SIZE, syncWindow) {
|
|
422
565
|
const allVtxos = [];
|
|
423
566
|
let pageIndex = 0;
|
|
424
567
|
let hasMore = true;
|
|
425
568
|
const opts = includeSpent ? {} : { spendableOnly: true };
|
|
569
|
+
const windowOpts = syncWindow
|
|
570
|
+
? {
|
|
571
|
+
...(syncWindow.after !== undefined && {
|
|
572
|
+
after: syncWindow.after,
|
|
573
|
+
}),
|
|
574
|
+
...(syncWindow.before !== undefined && {
|
|
575
|
+
before: syncWindow.before,
|
|
576
|
+
}),
|
|
577
|
+
}
|
|
578
|
+
: {};
|
|
426
579
|
while (hasMore) {
|
|
427
580
|
const { vtxos, page } = await this.config.indexerProvider.getVtxos({
|
|
428
581
|
scripts: [contract.script],
|
|
429
582
|
...opts,
|
|
583
|
+
...windowOpts,
|
|
430
584
|
pageIndex,
|
|
431
585
|
pageSize,
|
|
432
586
|
});
|
|
@@ -438,6 +592,8 @@ export class ContractManager {
|
|
|
438
592
|
}
|
|
439
593
|
hasMore = page ? vtxos.length === pageSize : false;
|
|
440
594
|
pageIndex++;
|
|
595
|
+
if (hasMore)
|
|
596
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
441
597
|
}
|
|
442
598
|
return allVtxos;
|
|
443
599
|
}
|
|
@@ -251,23 +251,40 @@ export class RestIndexerProvider {
|
|
|
251
251
|
return data;
|
|
252
252
|
}
|
|
253
253
|
async getVtxos(opts) {
|
|
254
|
+
const hasScripts = (opts?.scripts?.length ?? 0) > 0;
|
|
255
|
+
const hasOutpoints = (opts?.outpoints?.length ?? 0) > 0;
|
|
254
256
|
// scripts and outpoints are mutually exclusive
|
|
255
|
-
if (
|
|
257
|
+
if (hasScripts && hasOutpoints) {
|
|
256
258
|
throw new Error("scripts and outpoints are mutually exclusive options");
|
|
257
259
|
}
|
|
258
|
-
if (!
|
|
260
|
+
if (!hasScripts && !hasOutpoints) {
|
|
259
261
|
throw new Error("Either scripts or outpoints must be provided");
|
|
260
262
|
}
|
|
263
|
+
const filterCount = [
|
|
264
|
+
opts?.spendableOnly,
|
|
265
|
+
opts?.spentOnly,
|
|
266
|
+
opts?.recoverableOnly,
|
|
267
|
+
].filter(Boolean).length;
|
|
268
|
+
if (filterCount > 1) {
|
|
269
|
+
throw new Error("spendableOnly, spentOnly, and recoverableOnly are mutually exclusive options");
|
|
270
|
+
}
|
|
271
|
+
if (opts?.after !== undefined &&
|
|
272
|
+
opts?.before !== undefined &&
|
|
273
|
+
opts.after !== 0 &&
|
|
274
|
+
opts.before !== 0 &&
|
|
275
|
+
opts.before <= opts.after) {
|
|
276
|
+
throw new Error("before must be greater than after");
|
|
277
|
+
}
|
|
261
278
|
let url = `${this.serverUrl}/v1/indexer/vtxos`;
|
|
262
279
|
const params = new URLSearchParams();
|
|
263
280
|
// Handle scripts with multi collection format
|
|
264
|
-
if (
|
|
281
|
+
if (hasScripts) {
|
|
265
282
|
opts.scripts.forEach((script) => {
|
|
266
283
|
params.append("scripts", script);
|
|
267
284
|
});
|
|
268
285
|
}
|
|
269
286
|
// Handle outpoints with multi collection format
|
|
270
|
-
if (
|
|
287
|
+
if (hasOutpoints) {
|
|
271
288
|
opts.outpoints.forEach((outpoint) => {
|
|
272
289
|
params.append("outpoints", `${outpoint.txid}:${outpoint.vout}`);
|
|
273
290
|
});
|
|
@@ -279,6 +296,12 @@ export class RestIndexerProvider {
|
|
|
279
296
|
params.append("spentOnly", opts.spentOnly.toString());
|
|
280
297
|
if (opts.recoverableOnly !== undefined)
|
|
281
298
|
params.append("recoverableOnly", opts.recoverableOnly.toString());
|
|
299
|
+
if (opts.pendingOnly !== undefined)
|
|
300
|
+
params.append("pendingOnly", opts.pendingOnly.toString());
|
|
301
|
+
if (opts.after !== undefined)
|
|
302
|
+
params.append("after", opts.after.toString());
|
|
303
|
+
if (opts.before !== undefined)
|
|
304
|
+
params.append("before", opts.before.toString());
|
|
282
305
|
if (opts.pageIndex !== undefined)
|
|
283
306
|
params.append("page.index", opts.pageIndex.toString());
|
|
284
307
|
if (opts.pageSize !== undefined)
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/** Lag behind real-time to avoid racing with indexer writes. */
|
|
2
|
+
export const SAFETY_LAG_MS = 30000;
|
|
3
|
+
/** Overlap window so boundary VTXOs are never missed. */
|
|
4
|
+
export const OVERLAP_MS = 60000;
|
|
5
|
+
/**
|
|
6
|
+
* Per-repository mutex that serializes wallet-state mutations so that
|
|
7
|
+
* concurrent read-modify-write cycles (e.g. advanceSyncCursors racing
|
|
8
|
+
* with clearSyncCursors or setPendingTxFlag) never silently overwrite
|
|
9
|
+
* each other's changes.
|
|
10
|
+
*/
|
|
11
|
+
const walletStateLocks = new WeakMap();
|
|
12
|
+
/**
|
|
13
|
+
* Atomically read, mutate, and persist wallet state.
|
|
14
|
+
* All callers that modify wallet state should go through this helper
|
|
15
|
+
* to avoid lost-update races between interleaved async operations.
|
|
16
|
+
*/
|
|
17
|
+
export async function updateWalletState(repo, updater) {
|
|
18
|
+
const prev = walletStateLocks.get(repo) ?? Promise.resolve();
|
|
19
|
+
const op = prev.then(async () => {
|
|
20
|
+
const state = (await repo.getWalletState()) ?? {};
|
|
21
|
+
await repo.saveWalletState(updater(state));
|
|
22
|
+
});
|
|
23
|
+
// Store a version that never rejects so the chain doesn't break.
|
|
24
|
+
walletStateLocks.set(repo, op.catch(() => { }));
|
|
25
|
+
return op;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Read the high-water mark for a single script.
|
|
29
|
+
* Returns `undefined` when the script has never been synced (bootstrap case).
|
|
30
|
+
*/
|
|
31
|
+
export async function getSyncCursor(repo, script) {
|
|
32
|
+
const state = await repo.getWalletState();
|
|
33
|
+
return state?.settings?.vtxoSyncCursors?.[script];
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Read cursors for every previously-synced script.
|
|
37
|
+
*/
|
|
38
|
+
export async function getAllSyncCursors(repo) {
|
|
39
|
+
const state = await repo.getWalletState();
|
|
40
|
+
return state?.settings?.vtxoSyncCursors ?? {};
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Advance the cursor for one script after a successful delta sync.
|
|
44
|
+
* `cursor` should be the `before` cutoff used in the request.
|
|
45
|
+
*/
|
|
46
|
+
export async function advanceSyncCursor(repo, script, cursor) {
|
|
47
|
+
await updateWalletState(repo, (state) => {
|
|
48
|
+
const existing = state.settings?.vtxoSyncCursors ?? {};
|
|
49
|
+
return {
|
|
50
|
+
...state,
|
|
51
|
+
settings: {
|
|
52
|
+
...state.settings,
|
|
53
|
+
vtxoSyncCursors: { ...existing, [script]: cursor },
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Advance cursors for multiple scripts in a single write.
|
|
60
|
+
*/
|
|
61
|
+
export async function advanceSyncCursors(repo, updates) {
|
|
62
|
+
await updateWalletState(repo, (state) => {
|
|
63
|
+
const existing = state.settings?.vtxoSyncCursors ?? {};
|
|
64
|
+
return {
|
|
65
|
+
...state,
|
|
66
|
+
settings: {
|
|
67
|
+
...state.settings,
|
|
68
|
+
vtxoSyncCursors: { ...existing, ...updates },
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Remove sync cursors, forcing a full re-bootstrap on next sync.
|
|
75
|
+
* When `scripts` is provided, only those cursors are cleared.
|
|
76
|
+
*/
|
|
77
|
+
export async function clearSyncCursors(repo, scripts) {
|
|
78
|
+
await updateWalletState(repo, (state) => {
|
|
79
|
+
if (!scripts) {
|
|
80
|
+
const { vtxoSyncCursors: _, ...restSettings } = state.settings ?? {};
|
|
81
|
+
return {
|
|
82
|
+
...state,
|
|
83
|
+
settings: restSettings,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
const existing = state.settings?.vtxoSyncCursors ?? {};
|
|
87
|
+
const filtered = { ...existing };
|
|
88
|
+
for (const s of scripts)
|
|
89
|
+
delete filtered[s];
|
|
90
|
+
return {
|
|
91
|
+
...state,
|
|
92
|
+
settings: {
|
|
93
|
+
...state.settings,
|
|
94
|
+
vtxoSyncCursors: filtered,
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Compute the `after` lower-bound for a delta sync query.
|
|
101
|
+
* Returns `undefined` when the script has no cursor (bootstrap needed).
|
|
102
|
+
*
|
|
103
|
+
* No upper bound (`before`) is applied to the query so that freshly
|
|
104
|
+
* created VTXOs are never excluded. The safety lag is applied only
|
|
105
|
+
* when advancing the cursor (see {@link cursorCutoff}).
|
|
106
|
+
*/
|
|
107
|
+
export function computeSyncWindow(cursor) {
|
|
108
|
+
if (cursor === undefined)
|
|
109
|
+
return undefined;
|
|
110
|
+
const after = Math.max(0, cursor - OVERLAP_MS);
|
|
111
|
+
return { after };
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* The safe high-water mark for cursor advancement.
|
|
115
|
+
* Lags behind real-time by {@link SAFETY_LAG_MS} so that VTXOs still
|
|
116
|
+
* being indexed are re-queried on the next sync.
|
|
117
|
+
*
|
|
118
|
+
* When `requestStartedAt` is provided the cutoff is frozen to the
|
|
119
|
+
* request start rather than wall-clock at commit time, preventing
|
|
120
|
+
* long-running paginated fetches from advancing the cursor past the
|
|
121
|
+
* data they actually observed.
|
|
122
|
+
*/
|
|
123
|
+
export function cursorCutoff(requestStartedAt) {
|
|
124
|
+
return (requestStartedAt ?? Date.now()) - SAFETY_LAG_MS;
|
|
125
|
+
}
|
|
@@ -21,10 +21,13 @@ export class DelegatorManagerImpl {
|
|
|
21
21
|
return { delegated: [], failed: [] };
|
|
22
22
|
}
|
|
23
23
|
const destinationScript = ArkAddress.decode(destination).pkScript;
|
|
24
|
+
// fetch server and delegator info once, shared across all groups
|
|
25
|
+
const arkInfo = await this.arkInfoProvider.getInfo();
|
|
26
|
+
const delegateInfo = await this.delegatorProvider.getDelegateInfo();
|
|
24
27
|
// if explicit delegateAt is provided, delegate all vtxos at once without sorting
|
|
25
28
|
if (delegateAt) {
|
|
26
29
|
try {
|
|
27
|
-
await delegate(this.identity, this.delegatorProvider,
|
|
30
|
+
await delegate(this.identity, this.delegatorProvider, arkInfo, delegateInfo, vtxos, destinationScript, delegateAt);
|
|
28
31
|
}
|
|
29
32
|
catch (error) {
|
|
30
33
|
return { delegated: [], failed: [{ outpoints: vtxos, error }] };
|
|
@@ -51,7 +54,7 @@ export class DelegatorManagerImpl {
|
|
|
51
54
|
// if no groups, it means we only need to delegate the recoverable vtxos
|
|
52
55
|
if (groupByExpiry.size === 0) {
|
|
53
56
|
try {
|
|
54
|
-
await delegate(this.identity, this.delegatorProvider,
|
|
57
|
+
await delegate(this.identity, this.delegatorProvider, arkInfo, delegateInfo, recoverableVtxos, destinationScript, delegateAt);
|
|
55
58
|
}
|
|
56
59
|
catch (error) {
|
|
57
60
|
return {
|
|
@@ -68,7 +71,7 @@ export class DelegatorManagerImpl {
|
|
|
68
71
|
...recoverableVtxos,
|
|
69
72
|
]);
|
|
70
73
|
const groupsList = Array.from(groupByExpiry.entries());
|
|
71
|
-
const result = await Promise.allSettled(groupsList.map(async ([, vtxosGroup]) => delegate(this.identity, this.delegatorProvider,
|
|
74
|
+
const result = await Promise.allSettled(groupsList.map(async ([, vtxosGroup]) => delegate(this.identity, this.delegatorProvider, arkInfo, delegateInfo, vtxosGroup, destinationScript)));
|
|
72
75
|
const delegated = [];
|
|
73
76
|
const failed = [];
|
|
74
77
|
for (const [index, resultItem] of result.entries()) {
|
|
@@ -90,7 +93,7 @@ export class DelegatorManagerImpl {
|
|
|
90
93
|
* should occur. If not provided, defaults to 12 hours before the earliest
|
|
91
94
|
* expiry time of the provided vtxos.
|
|
92
95
|
*/
|
|
93
|
-
async function delegate(identity, delegatorProvider,
|
|
96
|
+
async function delegate(identity, delegatorProvider, arkInfo, delegateInfo, vtxos, destinationScript, delegateAt) {
|
|
94
97
|
if (vtxos.length === 0) {
|
|
95
98
|
throw new Error("unable to delegate: no vtxos provided");
|
|
96
99
|
}
|
|
@@ -116,7 +119,7 @@ async function delegate(identity, delegatorProvider, arkInfoProvider, vtxos, des
|
|
|
116
119
|
}
|
|
117
120
|
}
|
|
118
121
|
}
|
|
119
|
-
const { fees, dust, forfeitAddress, network } =
|
|
122
|
+
const { fees, dust, forfeitAddress, network } = arkInfo;
|
|
120
123
|
const delegateAtSeconds = delegateAt.getTime() / 1000;
|
|
121
124
|
const estimator = new Estimator({
|
|
122
125
|
...fees.intentFee,
|
|
@@ -140,7 +143,7 @@ async function delegate(identity, delegatorProvider, arkInfoProvider, vtxos, des
|
|
|
140
143
|
}
|
|
141
144
|
amount += BigInt(coin.value) - BigInt(inputFee.value);
|
|
142
145
|
}
|
|
143
|
-
const { delegatorAddress, pubkey, fee } =
|
|
146
|
+
const { delegatorAddress, pubkey, fee } = delegateInfo;
|
|
144
147
|
const outputs = [];
|
|
145
148
|
const delegatorFee = BigInt(Number(fee));
|
|
146
149
|
if (delegatorFee > 0n) {
|