@arkade-os/sdk 0.4.17 → 0.4.18
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/README.md +16 -6
- package/dist/cjs/contracts/arkcontract.js +0 -2
- package/dist/cjs/contracts/contractManager.js +111 -215
- package/dist/cjs/contracts/contractWatcher.js +86 -115
- package/dist/cjs/repositories/indexedDB/manager.js +6 -3
- package/dist/cjs/repositories/indexedDB/schema.js +47 -2
- package/dist/cjs/repositories/indexedDB/walletRepository.js +21 -2
- package/dist/cjs/repositories/realm/contractRepository.js +0 -4
- package/dist/cjs/repositories/realm/index.js +3 -1
- package/dist/cjs/repositories/realm/schemas.js +50 -1
- package/dist/cjs/repositories/realm/walletRepository.js +8 -4
- package/dist/cjs/repositories/scriptFromAddress.js +16 -0
- package/dist/cjs/repositories/sqlite/contractRepository.js +2 -6
- package/dist/cjs/repositories/sqlite/walletRepository.js +121 -33
- package/dist/cjs/utils/syncCursors.js +48 -56
- package/dist/cjs/wallet/expo/background.js +0 -13
- package/dist/cjs/wallet/expo/wallet.js +1 -6
- package/dist/cjs/wallet/serviceWorker/wallet-message-handler.js +16 -7
- package/dist/cjs/wallet/serviceWorker/wallet.js +19 -0
- package/dist/cjs/wallet/utils.js +41 -10
- package/dist/cjs/wallet/vtxo-manager.js +153 -39
- package/dist/cjs/wallet/wallet.js +72 -195
- package/dist/cjs/worker/expo/processors/contractPollProcessor.js +9 -13
- package/dist/cjs/worker/expo/taskRunner.js +2 -11
- package/dist/esm/contracts/arkcontract.js +0 -2
- package/dist/esm/contracts/contractManager.js +113 -217
- package/dist/esm/contracts/contractWatcher.js +86 -115
- package/dist/esm/repositories/indexedDB/manager.js +6 -3
- package/dist/esm/repositories/indexedDB/schema.js +46 -2
- package/dist/esm/repositories/indexedDB/walletRepository.js +21 -2
- package/dist/esm/repositories/realm/contractRepository.js +0 -4
- package/dist/esm/repositories/realm/index.js +1 -1
- package/dist/esm/repositories/realm/schemas.js +48 -0
- package/dist/esm/repositories/realm/walletRepository.js +8 -4
- package/dist/esm/repositories/scriptFromAddress.js +13 -0
- package/dist/esm/repositories/sqlite/contractRepository.js +2 -6
- package/dist/esm/repositories/sqlite/walletRepository.js +121 -33
- package/dist/esm/utils/syncCursors.js +47 -53
- package/dist/esm/wallet/expo/background.js +0 -13
- package/dist/esm/wallet/expo/wallet.js +2 -7
- package/dist/esm/wallet/serviceWorker/wallet-message-handler.js +17 -8
- package/dist/esm/wallet/serviceWorker/wallet.js +19 -0
- package/dist/esm/wallet/utils.js +41 -9
- package/dist/esm/wallet/vtxo-manager.js +153 -39
- package/dist/esm/wallet/wallet.js +75 -198
- package/dist/esm/worker/expo/processors/contractPollProcessor.js +9 -13
- package/dist/esm/worker/expo/taskRunner.js +3 -12
- package/dist/types/contracts/arkcontract.d.ts +0 -2
- package/dist/types/contracts/contractManager.d.ts +38 -9
- package/dist/types/contracts/contractWatcher.d.ts +22 -21
- package/dist/types/contracts/types.d.ts +0 -7
- package/dist/types/repositories/indexedDB/manager.d.ts +5 -2
- package/dist/types/repositories/indexedDB/schema.d.ts +3 -2
- package/dist/types/repositories/realm/index.d.ts +1 -1
- package/dist/types/repositories/realm/schemas.d.ts +41 -0
- package/dist/types/repositories/scriptFromAddress.d.ts +9 -0
- package/dist/types/repositories/serialization.d.ts +1 -1
- package/dist/types/repositories/sqlite/walletRepository.d.ts +22 -0
- package/dist/types/repositories/walletRepository.d.ts +10 -2
- package/dist/types/utils/syncCursors.d.ts +25 -23
- package/dist/types/wallet/index.d.ts +1 -1
- package/dist/types/wallet/serviceWorker/wallet-message-handler.d.ts +15 -3
- package/dist/types/wallet/utils.d.ts +20 -4
- package/dist/types/wallet/vtxo-manager.d.ts +16 -6
- package/dist/types/wallet/wallet.d.ts +5 -17
- package/dist/types/worker/expo/processors/contractPollProcessor.d.ts +9 -4
- package/dist/types/worker/expo/taskRunner.d.ts +6 -3
- package/package.json +1 -1
|
@@ -32,6 +32,38 @@ function assertSweepCapable(wallet) {
|
|
|
32
32
|
throw new Error("Boarding UTXO sweep requires a Wallet instance with boardingTapscript, onchainProvider, and network");
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* Web Locks name used to serialize boarding-poll work across same-origin
|
|
37
|
+
* browser contexts (tabs, service worker). Static because the goal is to
|
|
38
|
+
* deduplicate polls for the *same* wallet — two distinct wallets on the
|
|
39
|
+
* same origin will take turns, which is acceptable.
|
|
40
|
+
*/
|
|
41
|
+
const BOARDING_POLL_LOCK_NAME = "arkade-boarding-poll";
|
|
42
|
+
/**
|
|
43
|
+
* Run `fn` under an exclusive Web Lock when the runtime provides one
|
|
44
|
+
* (browser main thread, service worker). In environments without
|
|
45
|
+
* `navigator.locks` (Node, React Native) the callback runs immediately
|
|
46
|
+
* with no coordination.
|
|
47
|
+
*
|
|
48
|
+
* Uses `ifAvailable: true`: if another context already holds the lock,
|
|
49
|
+
* skip this cycle entirely rather than queueing — the other context will
|
|
50
|
+
* do the work and the next poll will re-check.
|
|
51
|
+
*/
|
|
52
|
+
async function runWithCrossInstanceLock(name, fn) {
|
|
53
|
+
const locks = typeof globalThis !== "undefined" &&
|
|
54
|
+
typeof globalThis.navigator !== "undefined"
|
|
55
|
+
? globalThis.navigator.locks
|
|
56
|
+
: undefined;
|
|
57
|
+
if (!locks) {
|
|
58
|
+
await fn();
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
await locks.request(name, { ifAvailable: true, mode: "exclusive" }, async (lock) => {
|
|
62
|
+
if (lock === null)
|
|
63
|
+
return;
|
|
64
|
+
await fn();
|
|
65
|
+
});
|
|
66
|
+
}
|
|
35
67
|
/** Default renewal threshold in seconds (3 days). */
|
|
36
68
|
exports.DEFAULT_THRESHOLD_SECONDS = 3 * 24 * 60 * 60;
|
|
37
69
|
/**
|
|
@@ -191,6 +223,15 @@ class VtxoManager {
|
|
|
191
223
|
// server emits new VTXOs → vtxo_received → renewVtxos() again → infinite loop.
|
|
192
224
|
this.renewalInProgress = false;
|
|
193
225
|
this.lastRenewalTimestamp = 0;
|
|
226
|
+
// Guards against a retry treadmill on the periodic-settle path: a failing
|
|
227
|
+
// settle would otherwise re-submit identical intents on every 60s poll,
|
|
228
|
+
// producing per-minute DeleteIntent RPCs forever. Mirrors the renewal
|
|
229
|
+
// cooldown but with exponential backoff on consecutive failures, so a
|
|
230
|
+
// persistently broken input eventually drops to the backoff cap instead
|
|
231
|
+
// of hammering the server. Shared across boarding + expiring-VTXO work
|
|
232
|
+
// because they now ride on the same settle intent.
|
|
233
|
+
this.lastPeriodicSettleTimestamp = 0;
|
|
234
|
+
this.consecutivePeriodicSettleFailures = 0;
|
|
194
235
|
// Normalize: prefer settlementConfig, fall back to renewalConfig, default to enabled
|
|
195
236
|
if (settlementConfig !== undefined) {
|
|
196
237
|
this.settlementConfig = settlementConfig;
|
|
@@ -412,10 +453,15 @@ class VtxoManager {
|
|
|
412
453
|
},
|
|
413
454
|
],
|
|
414
455
|
}, eventCallback);
|
|
415
|
-
this.lastRenewalTimestamp = Date.now();
|
|
416
456
|
return txid;
|
|
417
457
|
}
|
|
418
458
|
finally {
|
|
459
|
+
// Update cooldown on EVERY attempt (success or failure) so transient
|
|
460
|
+
// settle failures (stream close, connector mismatch, duplicated input)
|
|
461
|
+
// don't allow the next vtxo_received event to re-enter renewal
|
|
462
|
+
// immediately. Without this, a failed settle leaves lastRenewalTimestamp
|
|
463
|
+
// at its previous value and the cooldown check becomes a no-op.
|
|
464
|
+
this.lastRenewalTimestamp = Date.now();
|
|
419
465
|
this.renewalInProgress = false;
|
|
420
466
|
}
|
|
421
467
|
}
|
|
@@ -693,33 +739,42 @@ class VtxoManager {
|
|
|
693
739
|
this.pollDone = { promise, resolve: resolve };
|
|
694
740
|
let hadError = false;
|
|
695
741
|
try {
|
|
696
|
-
//
|
|
697
|
-
//
|
|
698
|
-
|
|
699
|
-
//
|
|
700
|
-
//
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
(this.settlementConfig?.boardingUtxoSweep ??
|
|
710
|
-
exports.DEFAULT_SETTLEMENT_CONFIG.boardingUtxoSweep);
|
|
711
|
-
if (sweepEnabled) {
|
|
742
|
+
// Cross-instance guard: in browser / service worker environments,
|
|
743
|
+
// serialize the poll body across tabs and SW contexts so only one
|
|
744
|
+
// of them registers intents per interval. Without this, every tab
|
|
745
|
+
// submits a parallel RegisterIntent for the same boarding input
|
|
746
|
+
// and N-1 of them collide on the server's duplicated-input check,
|
|
747
|
+
// each producing a DeleteIntent RPC. No-op outside the browser.
|
|
748
|
+
await runWithCrossInstanceLock(BOARDING_POLL_LOCK_NAME, async () => {
|
|
749
|
+
// Fetch boarding inputs once for the entire poll cycle so that
|
|
750
|
+
// settle and sweep don't each hit the network independently.
|
|
751
|
+
const boardingUtxos = await this.wallet.getBoardingUtxos();
|
|
752
|
+
// Settle new (unexpired) boarding inputs + any near-expiry
|
|
753
|
+
// VTXOs in a single intent, then sweep expired boarding
|
|
754
|
+
// inputs. Sequential to avoid racing for the same inputs.
|
|
712
755
|
try {
|
|
713
|
-
await this.
|
|
756
|
+
await this.runPeriodicSettle(boardingUtxos);
|
|
714
757
|
}
|
|
715
758
|
catch (e) {
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
759
|
+
hadError = true;
|
|
760
|
+
console.error("Error during periodic settle:", e);
|
|
761
|
+
}
|
|
762
|
+
const sweepEnabled = this.settlementConfig !== false &&
|
|
763
|
+
(this.settlementConfig?.boardingUtxoSweep ??
|
|
764
|
+
exports.DEFAULT_SETTLEMENT_CONFIG.boardingUtxoSweep);
|
|
765
|
+
if (sweepEnabled) {
|
|
766
|
+
try {
|
|
767
|
+
await this.sweepExpiredBoardingUtxos(boardingUtxos);
|
|
768
|
+
}
|
|
769
|
+
catch (e) {
|
|
770
|
+
if (!(e instanceof Error) ||
|
|
771
|
+
!e.message.includes("No expired boarding UTXOs")) {
|
|
772
|
+
hadError = true;
|
|
773
|
+
console.error("Error auto-sweeping boarding UTXOs:", e);
|
|
774
|
+
}
|
|
720
775
|
}
|
|
721
776
|
}
|
|
722
|
-
}
|
|
777
|
+
});
|
|
723
778
|
}
|
|
724
779
|
catch (e) {
|
|
725
780
|
hadError = true;
|
|
@@ -739,13 +794,19 @@ class VtxoManager {
|
|
|
739
794
|
}
|
|
740
795
|
}
|
|
741
796
|
/**
|
|
742
|
-
* Auto-settle new (unexpired) boarding inputs into
|
|
743
|
-
* Skips UTXOs that are already expired
|
|
744
|
-
*
|
|
745
|
-
*
|
|
746
|
-
*
|
|
797
|
+
* Auto-settle new (unexpired) boarding inputs AND near-expiry VTXOs into
|
|
798
|
+
* Arkade in a single intent. Skips boarding UTXOs that are already expired
|
|
799
|
+
* (those are handled by sweep) and those already in-flight (tracked in
|
|
800
|
+
* knownBoardingUtxos). If the event-driven renewal path is currently
|
|
801
|
+
* running, VTXOs are omitted from this cycle to avoid double-spending.
|
|
802
|
+
*
|
|
803
|
+
* Failure bookkeeping: after every settle *attempt*, lastPeriodicSettleTimestamp
|
|
804
|
+
* is armed and consecutive failures are counted so the next attempt is
|
|
805
|
+
* blocked by an exponentially growing cooldown (capped). This stops a
|
|
806
|
+
* persistently failing input from producing identical RegisterIntent +
|
|
807
|
+
* DeleteIntent retries on every 60s poll.
|
|
747
808
|
*/
|
|
748
|
-
async
|
|
809
|
+
async runPeriodicSettle(boardingUtxos) {
|
|
749
810
|
// Exclude expired boarding inputs — those should be swept, not settled.
|
|
750
811
|
// If we can't determine expired status, bail out entirely to avoid
|
|
751
812
|
// accidentally settling expired inputs (which would conflict with sweep).
|
|
@@ -763,22 +824,73 @@ class VtxoManager {
|
|
|
763
824
|
catch (e) {
|
|
764
825
|
throw e instanceof Error ? e : new Error(String(e));
|
|
765
826
|
}
|
|
766
|
-
const
|
|
827
|
+
const unsettledBoarding = boardingUtxos.filter((u) => !this.knownBoardingUtxos.has(`${u.txid}:${u.vout}`) &&
|
|
767
828
|
!expiredSet.has(`${u.txid}:${u.vout}`));
|
|
768
|
-
|
|
829
|
+
// Collect near-expiry VTXOs unless the event-driven path is mid-renewal.
|
|
830
|
+
// Skipping when renewalInProgress avoids double-submitting the same VTXOs.
|
|
831
|
+
let expiringVtxos = [];
|
|
832
|
+
if (!this.renewalInProgress) {
|
|
833
|
+
try {
|
|
834
|
+
expiringVtxos = await this.getExpiringVtxos();
|
|
835
|
+
}
|
|
836
|
+
catch (e) {
|
|
837
|
+
// Non-fatal: fall back to boarding-only settle.
|
|
838
|
+
console.error("Error fetching expiring VTXOs:", e);
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
if (unsettledBoarding.length === 0 && expiringVtxos.length === 0) {
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
// Respect the cooldown armed by the previous attempt. Cooldown grows
|
|
845
|
+
// exponentially with consecutive failures and is capped by
|
|
846
|
+
// PERIODIC_SETTLE_MAX_BACKOFF_MS.
|
|
847
|
+
const cooldownMs = Math.min(VtxoManager.PERIODIC_SETTLE_COOLDOWN_MS *
|
|
848
|
+
Math.pow(2, this.consecutivePeriodicSettleFailures), VtxoManager.PERIODIC_SETTLE_MAX_BACKOFF_MS);
|
|
849
|
+
if (Date.now() - this.lastPeriodicSettleTimestamp < cooldownMs) {
|
|
769
850
|
return;
|
|
851
|
+
}
|
|
770
852
|
const dustAmount = getDustAmount(this.wallet);
|
|
771
|
-
const
|
|
853
|
+
const boardingTotal = unsettledBoarding.reduce((sum, u) => sum + BigInt(u.value), 0n);
|
|
854
|
+
const vtxoTotal = expiringVtxos.reduce((sum, v) => sum + BigInt(v.value), 0n);
|
|
855
|
+
const totalAmount = boardingTotal + vtxoTotal;
|
|
772
856
|
if (totalAmount < dustAmount)
|
|
773
857
|
return;
|
|
774
858
|
const arkAddress = await this.wallet.getAddress();
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
859
|
+
const includesVtxos = expiringVtxos.length > 0;
|
|
860
|
+
// Block the event-driven renewal path while this settle is in flight
|
|
861
|
+
// when VTXOs are part of the intent. Mirrors renewVtxos()'s guard so
|
|
862
|
+
// the two paths can't race on the same VTXO inputs.
|
|
863
|
+
if (includesVtxos) {
|
|
864
|
+
this.renewalInProgress = true;
|
|
865
|
+
}
|
|
866
|
+
let success = false;
|
|
867
|
+
try {
|
|
868
|
+
await this.wallet.settle({
|
|
869
|
+
inputs: [...unsettledBoarding, ...expiringVtxos],
|
|
870
|
+
outputs: [{ address: arkAddress, amount: totalAmount }],
|
|
871
|
+
});
|
|
872
|
+
// Mark boarding inputs as known only after successful settle.
|
|
873
|
+
for (const u of unsettledBoarding) {
|
|
874
|
+
this.knownBoardingUtxos.add(`${u.txid}:${u.vout}`);
|
|
875
|
+
}
|
|
876
|
+
success = true;
|
|
877
|
+
}
|
|
878
|
+
finally {
|
|
879
|
+
this.lastPeriodicSettleTimestamp = Date.now();
|
|
880
|
+
if (includesVtxos) {
|
|
881
|
+
// Match event-path semantics: bump the renewal cooldown
|
|
882
|
+
// whether we succeeded or failed so a failed periodic settle
|
|
883
|
+
// doesn't let the next vtxo_received event re-enter renewal
|
|
884
|
+
// immediately.
|
|
885
|
+
this.lastRenewalTimestamp = Date.now();
|
|
886
|
+
this.renewalInProgress = false;
|
|
887
|
+
}
|
|
888
|
+
if (success) {
|
|
889
|
+
this.consecutivePeriodicSettleFailures = 0;
|
|
890
|
+
}
|
|
891
|
+
else {
|
|
892
|
+
this.consecutivePeriodicSettleFailures++;
|
|
893
|
+
}
|
|
782
894
|
}
|
|
783
895
|
}
|
|
784
896
|
async dispose() {
|
|
@@ -812,3 +924,5 @@ class VtxoManager {
|
|
|
812
924
|
exports.VtxoManager = VtxoManager;
|
|
813
925
|
VtxoManager.MAX_BACKOFF_MS = 5 * 60 * 1000; // 5 minutes
|
|
814
926
|
VtxoManager.RENEWAL_COOLDOWN_MS = 30000; // 30 seconds
|
|
927
|
+
VtxoManager.PERIODIC_SETTLE_COOLDOWN_MS = 30000;
|
|
928
|
+
VtxoManager.PERIODIC_SETTLE_MAX_BACKOFF_MS = 5 * 60 * 1000;
|
|
@@ -304,8 +304,10 @@ class ReadonlyWallet {
|
|
|
304
304
|
async getVtxos(filter) {
|
|
305
305
|
const f = filter ?? { withRecoverable: true, withUnrolled: false };
|
|
306
306
|
const contractManager = await this.getContractManager();
|
|
307
|
-
const
|
|
308
|
-
return
|
|
307
|
+
const vtxos = await contractManager.getContractsWithVtxos();
|
|
308
|
+
return vtxos
|
|
309
|
+
.flatMap((_) => _.vtxos)
|
|
310
|
+
.filter((vtxo) => {
|
|
309
311
|
if ((0, _1.isSpendable)(vtxo)) {
|
|
310
312
|
if (!f.withRecoverable &&
|
|
311
313
|
((0, _1.isRecoverable)(vtxo) || (0, _1.isExpired)(vtxo))) {
|
|
@@ -314,17 +316,15 @@ class ReadonlyWallet {
|
|
|
314
316
|
return true;
|
|
315
317
|
}
|
|
316
318
|
return !!(f.withUnrolled && vtxo.isUnrolled);
|
|
317
|
-
})
|
|
319
|
+
});
|
|
318
320
|
}
|
|
319
321
|
/**
|
|
320
322
|
* Return wallet transaction history derived from Arkade state and boarding transactions.
|
|
321
323
|
*/
|
|
322
324
|
async getTransactionHistory() {
|
|
323
|
-
|
|
324
|
-
const
|
|
325
|
-
const allVtxos =
|
|
326
|
-
? await this.walletRepository.getVtxos(address)
|
|
327
|
-
: fetchedExtended;
|
|
325
|
+
const contractManager = await this.getContractManager();
|
|
326
|
+
const response = await contractManager.getContractsWithVtxos();
|
|
327
|
+
const allVtxos = response.flatMap((_) => _.vtxos);
|
|
328
328
|
const { boardingTxs, commitmentsToIgnore } = await this.getBoardingTxs();
|
|
329
329
|
const getTxCreatedAt = (txid) => this.indexerProvider
|
|
330
330
|
.getVtxos({ outpoints: [{ txid, vout: 0 }] })
|
|
@@ -332,166 +332,11 @@ class ReadonlyWallet {
|
|
|
332
332
|
return (0, transactionHistory_1.buildTransactionHistory)(allVtxos, boardingTxs, commitmentsToIgnore, getTxCreatedAt);
|
|
333
333
|
}
|
|
334
334
|
/**
|
|
335
|
-
*
|
|
336
|
-
* cursor, or do a full bootstrap when no cursor exists. Upserts
|
|
337
|
-
* the result into the cache and advances the sync cursors.
|
|
338
|
-
*
|
|
339
|
-
* Concurrent calls are deduplicated: if a sync is already in flight,
|
|
340
|
-
* subsequent callers receive the same promise instead of triggering
|
|
341
|
-
* a second network round-trip.
|
|
342
|
-
*/
|
|
343
|
-
syncVtxos() {
|
|
344
|
-
if (this._syncVtxosInflight)
|
|
345
|
-
return this._syncVtxosInflight;
|
|
346
|
-
const p = this.doSyncVtxos().finally(() => {
|
|
347
|
-
this._syncVtxosInflight = undefined;
|
|
348
|
-
});
|
|
349
|
-
this._syncVtxosInflight = p;
|
|
350
|
-
return p;
|
|
351
|
-
}
|
|
352
|
-
async doSyncVtxos() {
|
|
353
|
-
const address = await this.getAddress();
|
|
354
|
-
// Batch cursor read with script map to avoid extra async hops
|
|
355
|
-
// before the fetch (background operations may run between hops).
|
|
356
|
-
const [scriptMap, cursors] = await Promise.all([
|
|
357
|
-
this.getScriptMap(),
|
|
358
|
-
(0, syncCursors_1.getAllSyncCursors)(this.walletRepository),
|
|
359
|
-
]);
|
|
360
|
-
const allScripts = [...scriptMap.keys()];
|
|
361
|
-
// Partition scripts into bootstrap (no cursor) and delta (has cursor).
|
|
362
|
-
const bootstrapScripts = [];
|
|
363
|
-
const deltaScripts = [];
|
|
364
|
-
for (const s of allScripts) {
|
|
365
|
-
if (cursors[s] === undefined) {
|
|
366
|
-
bootstrapScripts.push(s);
|
|
367
|
-
}
|
|
368
|
-
else {
|
|
369
|
-
deltaScripts.push(s);
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
const requestStartedAt = Date.now();
|
|
373
|
-
const allVtxos = [];
|
|
374
|
-
const extendWithScript = (vtxo) => {
|
|
375
|
-
const vtxoScript = vtxo.script
|
|
376
|
-
? scriptMap.get(vtxo.script)
|
|
377
|
-
: undefined;
|
|
378
|
-
if (!vtxoScript)
|
|
379
|
-
return undefined;
|
|
380
|
-
return {
|
|
381
|
-
...vtxo,
|
|
382
|
-
forfeitTapLeafScript: vtxoScript.forfeit(),
|
|
383
|
-
intentTapLeafScript: vtxoScript.forfeit(),
|
|
384
|
-
tapTree: vtxoScript.encode(),
|
|
385
|
-
};
|
|
386
|
-
};
|
|
387
|
-
// Full fetch for scripts with no cursor.
|
|
388
|
-
if (bootstrapScripts.length > 0) {
|
|
389
|
-
const response = await this.indexerProvider.getVtxos({
|
|
390
|
-
scripts: bootstrapScripts,
|
|
391
|
-
});
|
|
392
|
-
allVtxos.push(...response.vtxos);
|
|
393
|
-
}
|
|
394
|
-
// Delta fetch for scripts with an existing cursor.
|
|
395
|
-
let hasDelta = false;
|
|
396
|
-
if (deltaScripts.length > 0) {
|
|
397
|
-
const minCursor = Math.min(...deltaScripts.map((s) => cursors[s]));
|
|
398
|
-
const window = (0, syncCursors_1.computeSyncWindow)(minCursor);
|
|
399
|
-
if (window) {
|
|
400
|
-
hasDelta = true;
|
|
401
|
-
const response = await this.indexerProvider.getVtxos({
|
|
402
|
-
scripts: deltaScripts,
|
|
403
|
-
after: window.after,
|
|
404
|
-
});
|
|
405
|
-
allVtxos.push(...response.vtxos);
|
|
406
|
-
}
|
|
407
|
-
}
|
|
408
|
-
// Extend every fetched virtual output and upsert into the cache.
|
|
409
|
-
const fetchedExtended = [];
|
|
410
|
-
for (const vtxo of allVtxos) {
|
|
411
|
-
const extended = extendWithScript(vtxo);
|
|
412
|
-
if (extended)
|
|
413
|
-
fetchedExtended.push(extended);
|
|
414
|
-
}
|
|
415
|
-
// Save virtual outputs first, then advance cursors only on success.
|
|
416
|
-
const cutoff = (0, syncCursors_1.cursorCutoff)(requestStartedAt);
|
|
417
|
-
await this.walletRepository.saveVtxos(address, fetchedExtended);
|
|
418
|
-
await (0, syncCursors_1.advanceSyncCursors)(this.walletRepository, Object.fromEntries(allScripts.map((s) => [s, cutoff])));
|
|
419
|
-
// Delta-sync reconciliation: full re-fetch for delta scripts.
|
|
420
|
-
//
|
|
421
|
-
// The delta fetch (above) only returns virtual outputs changed after the
|
|
422
|
-
// cursor, so it can miss preconfirmed virtual outputs that were consumed
|
|
423
|
-
// by a round between syncs. Rather than layering targeted
|
|
424
|
-
// queries (pendingOnly, spendableOnly) with pagination guards
|
|
425
|
-
// and set algebra, we perform a single unfiltered re-fetch for
|
|
426
|
-
// delta scripts. This is slightly more data over the wire but
|
|
427
|
-
// gives us complete, authoritative state in one call and keeps
|
|
428
|
-
// the reconciliation logic simple.
|
|
429
|
-
//
|
|
430
|
-
// Any cached non-spent virtual output that is absent from the full
|
|
431
|
-
// result set is marked spent; any virtual output whose state changed
|
|
432
|
-
// (e.g. preconfirmed → settled) is updated in place.
|
|
433
|
-
if (hasDelta) {
|
|
434
|
-
const { vtxos: fullVtxos, page: fullPage } = await this.indexerProvider.getVtxos({
|
|
435
|
-
scripts: deltaScripts,
|
|
436
|
-
});
|
|
437
|
-
// Reconciliation is best-effort: if the response is
|
|
438
|
-
// paginated we don't have a complete picture, so we skip
|
|
439
|
-
// rather than act on partial data. Wallets with enough
|
|
440
|
-
// virtual outputs to exceed a single page rely solely on the
|
|
441
|
-
// cursor-based delta mechanism for state updates.
|
|
442
|
-
const fullSetComplete = !fullPage || fullPage.total <= 1;
|
|
443
|
-
if (fullSetComplete) {
|
|
444
|
-
const fullOutpoints = new Map(fullVtxos.map((v) => [`${v.txid}:${v.vout}`, v]));
|
|
445
|
-
const deltaScriptSet = new Set(deltaScripts);
|
|
446
|
-
const cachedVtxos = await this.walletRepository.getVtxos(address);
|
|
447
|
-
const reconciledExtended = [];
|
|
448
|
-
for (const cached of cachedVtxos) {
|
|
449
|
-
if (!cached.script ||
|
|
450
|
-
!deltaScriptSet.has(cached.script) ||
|
|
451
|
-
cached.isSpent) {
|
|
452
|
-
continue;
|
|
453
|
-
}
|
|
454
|
-
const outpoint = `${cached.txid}:${cached.vout}`;
|
|
455
|
-
const fresh = fullOutpoints.get(outpoint);
|
|
456
|
-
if (!fresh) {
|
|
457
|
-
// Server no longer knows about this virtual output —
|
|
458
|
-
// it was spent between syncs.
|
|
459
|
-
reconciledExtended.push({
|
|
460
|
-
...cached,
|
|
461
|
-
isSpent: true,
|
|
462
|
-
});
|
|
463
|
-
continue;
|
|
464
|
-
}
|
|
465
|
-
const extended = extendWithScript(fresh);
|
|
466
|
-
if (extended &&
|
|
467
|
-
extended.virtualStatus.state !==
|
|
468
|
-
cached.virtualStatus.state) {
|
|
469
|
-
// State transitioned (e.g. preconfirmed →
|
|
470
|
-
// settled) — update the cached entry.
|
|
471
|
-
reconciledExtended.push(extended);
|
|
472
|
-
}
|
|
473
|
-
}
|
|
474
|
-
if (reconciledExtended.length > 0) {
|
|
475
|
-
console.warn(`[ark-sdk] delta sync: reconciled ${reconciledExtended.length} stale VTXO(s) via full re-fetch`);
|
|
476
|
-
await this.walletRepository.saveVtxos(address, reconciledExtended);
|
|
477
|
-
}
|
|
478
|
-
}
|
|
479
|
-
else {
|
|
480
|
-
console.warn("[ark-sdk] delta sync: skipping reconciliation — full re-fetch was paginated");
|
|
481
|
-
}
|
|
482
|
-
}
|
|
483
|
-
return {
|
|
484
|
-
isDelta: hasDelta || bootstrapScripts.length === 0,
|
|
485
|
-
fetchedExtended,
|
|
486
|
-
address,
|
|
487
|
-
};
|
|
488
|
-
}
|
|
489
|
-
/**
|
|
490
|
-
* Clear all virtual output sync cursors, forcing a full re-bootstrap on next sync.
|
|
335
|
+
* Clear the global VTXO sync cursor, forcing a full re-bootstrap on next sync.
|
|
491
336
|
* Useful for recovery after indexer reprocessing or debugging.
|
|
492
337
|
*/
|
|
493
|
-
async
|
|
494
|
-
await (0, syncCursors_1.
|
|
338
|
+
async clearSyncCursor() {
|
|
339
|
+
await (0, syncCursors_1.clearSyncCursor)(this.walletRepository);
|
|
495
340
|
}
|
|
496
341
|
/**
|
|
497
342
|
* Build a transaction history view for the wallet's boarding address.
|
|
@@ -534,6 +379,7 @@ class ReadonlyWallet {
|
|
|
534
379
|
createdAt: tx.status.confirmed
|
|
535
380
|
? new Date(tx.status.block_time * 1000)
|
|
536
381
|
: new Date(0),
|
|
382
|
+
script: base_1.hex.encode(this.boardingTapscript.pkScript),
|
|
537
383
|
});
|
|
538
384
|
}
|
|
539
385
|
}
|
|
@@ -623,22 +469,41 @@ class ReadonlyWallet {
|
|
|
623
469
|
await this.indexerProvider?.unsubscribeForScripts(subscriptionId);
|
|
624
470
|
};
|
|
625
471
|
// Handle subscription updates asynchronously without blocking.
|
|
626
|
-
//
|
|
627
|
-
//
|
|
628
|
-
//
|
|
629
|
-
//
|
|
630
|
-
//
|
|
472
|
+
// Subscription covers all wallet scripts (default + delegate) plus
|
|
473
|
+
// any additional registered contracts. Virtual outputs carry a
|
|
474
|
+
// `script` field from the indexer which the contract manager
|
|
475
|
+
// resolves to the owning contract so the extension uses the
|
|
476
|
+
// correct forfeit/intent tapscripts.
|
|
631
477
|
(async () => {
|
|
632
478
|
try {
|
|
479
|
+
const cm = await this.getContractManager();
|
|
633
480
|
for await (const update of subscription) {
|
|
634
|
-
if (update.newVtxos?.length
|
|
635
|
-
update.spentVtxos?.length
|
|
481
|
+
if (update.newVtxos?.length === 0 &&
|
|
482
|
+
update.spentVtxos?.length === 0) {
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
// Isolate per-update annotation failures (e.g. a VTXO
|
|
486
|
+
// arriving for a contract we haven't registered yet).
|
|
487
|
+
// Without this a single bad update would kill the
|
|
488
|
+
// for-await loop and silently drop every subsequent
|
|
489
|
+
// subscription event for the session.
|
|
490
|
+
try {
|
|
491
|
+
// Default to `[]` so a one-sided update (e.g.
|
|
492
|
+
// only `newVtxos`) doesn't pass `undefined` into
|
|
493
|
+
// annotateVtxos and throw on `.length`.
|
|
494
|
+
const [newVtxos, spentVtxos] = await Promise.all([
|
|
495
|
+
cm.annotateVtxos(update.newVtxos ?? []),
|
|
496
|
+
cm.annotateVtxos(update.spentVtxos ?? []),
|
|
497
|
+
]);
|
|
636
498
|
eventCallback({
|
|
637
499
|
type: "vtxo",
|
|
638
|
-
newVtxos
|
|
639
|
-
spentVtxos
|
|
500
|
+
newVtxos,
|
|
501
|
+
spentVtxos,
|
|
640
502
|
});
|
|
641
503
|
}
|
|
504
|
+
catch (error) {
|
|
505
|
+
console.warn("Dropping subscription update after annotation failed; next sync will reconcile:", error);
|
|
506
|
+
}
|
|
642
507
|
}
|
|
643
508
|
}
|
|
644
509
|
catch (error) {
|
|
@@ -909,11 +774,10 @@ class Wallet extends ReadonlyWallet {
|
|
|
909
774
|
}
|
|
910
775
|
});
|
|
911
776
|
}
|
|
912
|
-
constructor(identity, network,
|
|
777
|
+
constructor(identity, network, onchainProvider, arkProvider, indexerProvider, arkServerPublicKey, offchainTapscript, boardingTapscript, serverUnrollScript, forfeitOutputScript, forfeitPubkey, dustAmount, walletRepository, contractRepository,
|
|
913
778
|
/** @deprecated Use settlementConfig */
|
|
914
779
|
renewalConfig, delegatorProvider, watcherConfig, settlementConfig) {
|
|
915
780
|
super(identity, network, onchainProvider, indexerProvider, arkServerPublicKey, offchainTapscript, boardingTapscript, dustAmount, walletRepository, contractRepository, delegatorProvider, watcherConfig);
|
|
916
|
-
this.networkName = networkName;
|
|
917
781
|
this.arkProvider = arkProvider;
|
|
918
782
|
this.serverUnrollScript = serverUnrollScript;
|
|
919
783
|
this.forfeitOutputScript = forfeitOutputScript;
|
|
@@ -1033,7 +897,7 @@ class Wallet extends ReadonlyWallet {
|
|
|
1033
897
|
const forfeitPubkey = base_1.hex.decode(setup.info.forfeitPubkey).slice(1);
|
|
1034
898
|
const forfeitAddress = (0, btc_signer_1.Address)(setup.network).decode(setup.info.forfeitAddress);
|
|
1035
899
|
const forfeitOutputScript = btc_signer_1.OutScript.encode(forfeitAddress);
|
|
1036
|
-
const wallet = new Wallet(config.identity, setup.network, setup.
|
|
900
|
+
const wallet = new Wallet(config.identity, setup.network, setup.onchainProvider, setup.arkProvider, setup.indexerProvider, setup.serverPubKey, setup.offchainTapscript, setup.boardingTapscript, serverUnrollScript, forfeitOutputScript, forfeitPubkey, setup.dustAmount, setup.walletRepository, setup.contractRepository, config.renewalConfig, config.delegatorProvider, config.watcherConfig, config.settlementConfig);
|
|
1037
901
|
await wallet.getVtxoManager();
|
|
1038
902
|
return wallet;
|
|
1039
903
|
}
|
|
@@ -1296,7 +1160,7 @@ class Wallet extends ReadonlyWallet {
|
|
|
1296
1160
|
const abortController = new AbortController();
|
|
1297
1161
|
try {
|
|
1298
1162
|
const stream = this.arkProvider.getEventStream(abortController.signal, topics);
|
|
1299
|
-
const intentId = await this.safeRegisterIntent(intent);
|
|
1163
|
+
const intentId = await this.safeRegisterIntent(intent, params.inputs);
|
|
1300
1164
|
const handler = this.createBatchHandler(intentId, params.inputs, recipients, session);
|
|
1301
1165
|
const commitmentTxid = await batch_1.Batch.join(stream, handler, {
|
|
1302
1166
|
abortController,
|
|
@@ -1309,8 +1173,16 @@ class Wallet extends ReadonlyWallet {
|
|
|
1309
1173
|
return commitmentTxid;
|
|
1310
1174
|
}
|
|
1311
1175
|
catch (error) {
|
|
1312
|
-
// delete the intent to not be stuck in the queue
|
|
1313
|
-
|
|
1176
|
+
// delete the intent to not be stuck in the queue. If deletion fails
|
|
1177
|
+
// the intent stays on the server and the next settle will hit
|
|
1178
|
+
// "duplicated input" in safeRegisterIntent — surface the failure
|
|
1179
|
+
// rather than silently swallowing it.
|
|
1180
|
+
const inputIds = params.inputs
|
|
1181
|
+
.map((i) => `${i.txid}:${i.vout}`)
|
|
1182
|
+
.join(",");
|
|
1183
|
+
await this.arkProvider.deleteIntent(deleteIntent).catch((e) => {
|
|
1184
|
+
console.warn(`Failed to delete intent after settle failure for inputs [${inputIds}]; intent may linger on server and cause 'duplicated input' on next settle`, e);
|
|
1185
|
+
});
|
|
1314
1186
|
throw error;
|
|
1315
1187
|
}
|
|
1316
1188
|
finally {
|
|
@@ -1500,7 +1372,7 @@ class Wallet extends ReadonlyWallet {
|
|
|
1500
1372
|
},
|
|
1501
1373
|
};
|
|
1502
1374
|
}
|
|
1503
|
-
async safeRegisterIntent(intent) {
|
|
1375
|
+
async safeRegisterIntent(intent, inputs) {
|
|
1504
1376
|
try {
|
|
1505
1377
|
return await this.arkProvider.registerIntent(intent);
|
|
1506
1378
|
}
|
|
@@ -1509,11 +1381,13 @@ class Wallet extends ReadonlyWallet {
|
|
|
1509
1381
|
if (error instanceof errors_1.ArkError &&
|
|
1510
1382
|
error.code === 0 &&
|
|
1511
1383
|
error.message.includes("duplicated input")) {
|
|
1512
|
-
//
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1384
|
+
// Clear any queued intent spending these exact inputs. The
|
|
1385
|
+
// previous implementation signed a proof over getVtxos() only,
|
|
1386
|
+
// which misses boarding UTXOs — the most common trigger for
|
|
1387
|
+
// "duplicated input" on the auto-settle path. Signing the
|
|
1388
|
+
// caller's own inputs keeps the proof surgical and correct
|
|
1389
|
+
// regardless of whether the stuck input is a VTXO or boarding.
|
|
1390
|
+
const deleteIntent = await this.makeDeleteIntentSignature(inputs);
|
|
1517
1391
|
await this.arkProvider.deleteIntent(deleteIntent);
|
|
1518
1392
|
// try again
|
|
1519
1393
|
return this.arkProvider.registerIntent(intent);
|
|
@@ -1581,9 +1455,7 @@ class Wallet extends ReadonlyWallet {
|
|
|
1581
1455
|
scripts: allScripts,
|
|
1582
1456
|
});
|
|
1583
1457
|
for (const vtxo of fetchedVtxos) {
|
|
1584
|
-
const vtxoScript = vtxo.script
|
|
1585
|
-
? scriptMap.get(vtxo.script)
|
|
1586
|
-
: undefined;
|
|
1458
|
+
const vtxoScript = scriptMap.get(vtxo.script);
|
|
1587
1459
|
if (!vtxoScript)
|
|
1588
1460
|
continue;
|
|
1589
1461
|
if (vtxo.virtualStatus.state === "swept" ||
|
|
@@ -1886,8 +1758,9 @@ class Wallet extends ReadonlyWallet {
|
|
|
1886
1758
|
console.warn(`updateDbAfterOffchainTx: inputs length (${inputs.length}) differs from signedCheckpointTxs length (${signedCheckpointTxs.length})`);
|
|
1887
1759
|
}
|
|
1888
1760
|
const safeLength = Math.min(inputs.length, signedCheckpointTxs.length);
|
|
1889
|
-
|
|
1890
|
-
|
|
1761
|
+
const cm = await this.getContractManager();
|
|
1762
|
+
const annotatedInputs = await cm.annotateVtxos(inputs);
|
|
1763
|
+
for (const [inputIndex, vtxo] of annotatedInputs.entries()) {
|
|
1891
1764
|
if (inputIndex < safeLength &&
|
|
1892
1765
|
signedCheckpointTxs[inputIndex]) {
|
|
1893
1766
|
const checkpoint = btc_signer_1.Transaction.fromPSBT(base_1.base64.decode(signedCheckpointTxs[inputIndex]));
|
|
@@ -1947,6 +1820,7 @@ class Wallet extends ReadonlyWallet {
|
|
|
1947
1820
|
confirmed: false,
|
|
1948
1821
|
},
|
|
1949
1822
|
assets: changeAssets,
|
|
1823
|
+
script: base_1.hex.encode(this.offchainTapscript.pkScript),
|
|
1950
1824
|
};
|
|
1951
1825
|
}
|
|
1952
1826
|
await this.walletRepository.saveVtxos(addr, changeVtxo ? [...spentVtxos, changeVtxo] : spentVtxos);
|
|
@@ -1977,10 +1851,14 @@ class Wallet extends ReadonlyWallet {
|
|
|
1977
1851
|
const inputArkTxIds = new Set();
|
|
1978
1852
|
const boardingUtxoToRemove = new Set();
|
|
1979
1853
|
const isVtxo = (input) => "virtualStatus" in input;
|
|
1854
|
+
const vtxoInputs = inputs.filter(isVtxo);
|
|
1855
|
+
const cm = await this.getContractManager();
|
|
1856
|
+
const annotatedVtxos = await cm.annotateVtxos(vtxoInputs);
|
|
1857
|
+
const annotatedByKey = new Map(annotatedVtxos.map((v) => [`${v.txid}:${v.vout}`, v]));
|
|
1980
1858
|
for (const input of inputs) {
|
|
1981
1859
|
if (isVtxo(input)) {
|
|
1982
1860
|
// virtual output = mark it settled
|
|
1983
|
-
const vtxo =
|
|
1861
|
+
const vtxo = annotatedByKey.get(`${input.txid}:${input.vout}`);
|
|
1984
1862
|
if (vtxo.arkTxId) {
|
|
1985
1863
|
inputArkTxIds.add(vtxo.arkTxId);
|
|
1986
1864
|
}
|
|
@@ -2067,7 +1945,7 @@ function selectVirtualCoins(coins, targetAmount) {
|
|
|
2067
1945
|
*/
|
|
2068
1946
|
async function waitForIncomingFunds(wallet) {
|
|
2069
1947
|
let stopFunc;
|
|
2070
|
-
|
|
1948
|
+
return new Promise((resolve) => {
|
|
2071
1949
|
wallet
|
|
2072
1950
|
.notifyIncomingFunds((coins) => {
|
|
2073
1951
|
resolve(coins);
|
|
@@ -2078,5 +1956,4 @@ async function waitForIncomingFunds(wallet) {
|
|
|
2078
1956
|
stopFunc = stop;
|
|
2079
1957
|
});
|
|
2080
1958
|
});
|
|
2081
|
-
return promise;
|
|
2082
1959
|
}
|