@arkade-os/sdk 0.4.18 → 0.4.19
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/providers/ark.js +36 -33
- package/dist/cjs/wallet/vtxo-manager.js +79 -11
- package/dist/cjs/wallet/wallet.js +77 -16
- package/dist/esm/providers/ark.js +36 -33
- package/dist/esm/wallet/vtxo-manager.js +79 -11
- package/dist/esm/wallet/wallet.js +77 -16
- package/dist/types/wallet/vtxo-manager.d.ts +13 -0
- package/dist/types/wallet/wallet.d.ts +3 -0
- package/package.json +1 -1
|
@@ -231,28 +231,21 @@ class RestArkProvider {
|
|
|
231
231
|
const queryParams = topics.length > 0
|
|
232
232
|
? `?${topics.map((topic) => `topics=${encodeURIComponent(topic)}`).join("&")}`
|
|
233
233
|
: "";
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
|
|
238
|
-
|
|
234
|
+
// The EventSource is allocated inside the generator body so that
|
|
235
|
+
// abandoning the returned iterator before iteration starts does not
|
|
236
|
+
// leak the underlying SSE connection. `return()` is overridden below
|
|
237
|
+
// so that closing the generator also closes the connection even when
|
|
238
|
+
// the body is currently suspended at an await point.
|
|
239
|
+
let eventSource = null;
|
|
239
240
|
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
|
240
241
|
const self = this;
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
? eagerIterator
|
|
249
|
-
: (0, utils_1.eventSourceIterator)(eventSource);
|
|
250
|
-
firstIteration = false;
|
|
251
|
-
try {
|
|
252
|
-
const abortHandler = () => {
|
|
253
|
-
eventSource.close();
|
|
254
|
-
};
|
|
255
|
-
signal?.addEventListener("abort", abortHandler);
|
|
242
|
+
const gen = (async function* () {
|
|
243
|
+
const abortHandler = () => eventSource?.close();
|
|
244
|
+
signal?.addEventListener("abort", abortHandler);
|
|
245
|
+
try {
|
|
246
|
+
while (!signal?.aborted) {
|
|
247
|
+
eventSource = new EventSource(url + queryParams);
|
|
248
|
+
const iterator = (0, utils_1.eventSourceIterator)(eventSource);
|
|
256
249
|
try {
|
|
257
250
|
for await (const event of iterator) {
|
|
258
251
|
if (signal?.aborted)
|
|
@@ -270,25 +263,35 @@ class RestArkProvider {
|
|
|
270
263
|
}
|
|
271
264
|
}
|
|
272
265
|
}
|
|
266
|
+
catch (error) {
|
|
267
|
+
if (error instanceof Error &&
|
|
268
|
+
error.name === "AbortError") {
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
// ignore timeout errors, they're expected when the server is not sending anything for 5 min
|
|
272
|
+
if (isFetchTimeoutError(error)) {
|
|
273
|
+
console.debug("Timeout error ignored");
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
console.error("Event stream error:", error);
|
|
277
|
+
throw error;
|
|
278
|
+
}
|
|
273
279
|
finally {
|
|
274
|
-
signal?.removeEventListener("abort", abortHandler);
|
|
275
280
|
eventSource.close();
|
|
276
281
|
}
|
|
277
282
|
}
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
// ignore timeout errors, they're expected when the server is not sending anything for 5 min
|
|
283
|
-
if (isFetchTimeoutError(error)) {
|
|
284
|
-
console.debug("Timeout error ignored");
|
|
285
|
-
continue;
|
|
286
|
-
}
|
|
287
|
-
console.error("Event stream error:", error);
|
|
288
|
-
throw error;
|
|
289
|
-
}
|
|
283
|
+
}
|
|
284
|
+
finally {
|
|
285
|
+
signal?.removeEventListener("abort", abortHandler);
|
|
286
|
+
eventSource?.close();
|
|
290
287
|
}
|
|
291
288
|
})();
|
|
289
|
+
const origReturn = gen.return.bind(gen);
|
|
290
|
+
gen.return = (value) => {
|
|
291
|
+
eventSource?.close();
|
|
292
|
+
return origReturn(value);
|
|
293
|
+
};
|
|
294
|
+
return gen;
|
|
292
295
|
}
|
|
293
296
|
async *getTransactionsStream(signal) {
|
|
294
297
|
const url = `${this.serverUrl}/v1/txs`;
|
|
@@ -232,6 +232,11 @@ class VtxoManager {
|
|
|
232
232
|
// because they now ride on the same settle intent.
|
|
233
233
|
this.lastPeriodicSettleTimestamp = 0;
|
|
234
234
|
this.consecutivePeriodicSettleFailures = 0;
|
|
235
|
+
// Throttle for the VTXO_ALREADY_SPENT -> refreshVtxos() reconciliation.
|
|
236
|
+
// The server's authoritative view says our local cache is stale, so we
|
|
237
|
+
// trigger a full refresh to advance the global sync cursor. Rate-limit
|
|
238
|
+
// to guard against a buggy indexer cycling us into a refresh storm.
|
|
239
|
+
this.lastVtxoSpentRefreshTimestamp = 0;
|
|
235
240
|
// Normalize: prefer settlementConfig, fall back to renewalConfig, default to enabled
|
|
236
241
|
if (settlementConfig !== undefined) {
|
|
237
242
|
this.settlementConfig = settlementConfig;
|
|
@@ -667,7 +672,6 @@ class VtxoManager {
|
|
|
667
672
|
return;
|
|
668
673
|
}
|
|
669
674
|
if (e.message.includes("VTXO_ALREADY_REGISTERED") ||
|
|
670
|
-
e.message.includes("VTXO_ALREADY_SPENT") ||
|
|
671
675
|
e.message.includes("duplicated input")) {
|
|
672
676
|
// Virtual output is already being used in a concurrent
|
|
673
677
|
// user-initiated operation. Skip silently — the
|
|
@@ -675,6 +679,14 @@ class VtxoManager {
|
|
|
675
679
|
// renewal will retry on the next cycle.
|
|
676
680
|
return;
|
|
677
681
|
}
|
|
682
|
+
if (e.message.includes("VTXO_ALREADY_SPENT")) {
|
|
683
|
+
// Our local VTXO cache is stale vs. the
|
|
684
|
+
// server's authoritative view. Trigger a
|
|
685
|
+
// throttled refresh to reconcile, then skip
|
|
686
|
+
// — the next cycle will see fresh data.
|
|
687
|
+
void this.maybeRefreshAfterVtxoSpent();
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
678
690
|
}
|
|
679
691
|
console.error("Error renewing VTXOs:", e);
|
|
680
692
|
});
|
|
@@ -692,6 +704,39 @@ class VtxoManager {
|
|
|
692
704
|
return undefined;
|
|
693
705
|
}
|
|
694
706
|
}
|
|
707
|
+
/**
|
|
708
|
+
* VTXO_ALREADY_SPENT means the server's authoritative view of VTXO state
|
|
709
|
+
* is ahead of ours — cross-instance race, pre-lock snapshot drift, or an
|
|
710
|
+
* SSE gap left stale data in the local cache. Silent-swallowing guarantees
|
|
711
|
+
* the same error on the next cycle because nothing reconciles the cache,
|
|
712
|
+
* so instead we trigger a full refreshVtxos() to advance the global sync
|
|
713
|
+
* cursor. Throttled to prevent a buggy indexer from causing a refresh
|
|
714
|
+
* storm.
|
|
715
|
+
*/
|
|
716
|
+
maybeRefreshAfterVtxoSpent() {
|
|
717
|
+
if (this.vtxoSpentRefreshPromise) {
|
|
718
|
+
return this.vtxoSpentRefreshPromise;
|
|
719
|
+
}
|
|
720
|
+
const now = Date.now();
|
|
721
|
+
if (now - this.lastVtxoSpentRefreshTimestamp <
|
|
722
|
+
VtxoManager.VTXO_SPENT_REFRESH_COOLDOWN_MS) {
|
|
723
|
+
return Promise.resolve();
|
|
724
|
+
}
|
|
725
|
+
this.lastVtxoSpentRefreshTimestamp = now;
|
|
726
|
+
this.vtxoSpentRefreshPromise = (async () => {
|
|
727
|
+
try {
|
|
728
|
+
const contractManager = await this.wallet.getContractManager();
|
|
729
|
+
await contractManager.refreshVtxos();
|
|
730
|
+
}
|
|
731
|
+
catch (e) {
|
|
732
|
+
console.error("Error refreshing VTXOs after VTXO_ALREADY_SPENT:", e);
|
|
733
|
+
}
|
|
734
|
+
finally {
|
|
735
|
+
this.vtxoSpentRefreshPromise = undefined;
|
|
736
|
+
}
|
|
737
|
+
})();
|
|
738
|
+
return this.vtxoSpentRefreshPromise;
|
|
739
|
+
}
|
|
695
740
|
/** Computes the next poll delay, applying exponential backoff on failures. */
|
|
696
741
|
getNextPollDelay() {
|
|
697
742
|
if (this.settlementConfig === false)
|
|
@@ -824,7 +869,8 @@ class VtxoManager {
|
|
|
824
869
|
catch (e) {
|
|
825
870
|
throw e instanceof Error ? e : new Error(String(e));
|
|
826
871
|
}
|
|
827
|
-
const unsettledBoarding = boardingUtxos.filter((u) =>
|
|
872
|
+
const unsettledBoarding = boardingUtxos.filter((u) => u.status.confirmed &&
|
|
873
|
+
!this.knownBoardingUtxos.has(`${u.txid}:${u.vout}`) &&
|
|
828
874
|
!expiredSet.has(`${u.txid}:${u.vout}`));
|
|
829
875
|
// Collect near-expiry VTXOs unless the event-driven path is mid-renewal.
|
|
830
876
|
// Skipping when renewalInProgress avoids double-submitting the same VTXOs.
|
|
@@ -864,16 +910,34 @@ class VtxoManager {
|
|
|
864
910
|
this.renewalInProgress = true;
|
|
865
911
|
}
|
|
866
912
|
let success = false;
|
|
913
|
+
let staleCacheSkip = false;
|
|
867
914
|
try {
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
915
|
+
try {
|
|
916
|
+
await this.wallet.settle({
|
|
917
|
+
inputs: [...unsettledBoarding, ...expiringVtxos],
|
|
918
|
+
outputs: [{ address: arkAddress, amount: totalAmount }],
|
|
919
|
+
});
|
|
920
|
+
// Mark boarding inputs as known only after successful settle.
|
|
921
|
+
for (const u of unsettledBoarding) {
|
|
922
|
+
this.knownBoardingUtxos.add(`${u.txid}:${u.vout}`);
|
|
923
|
+
}
|
|
924
|
+
success = true;
|
|
925
|
+
}
|
|
926
|
+
catch (e) {
|
|
927
|
+
if (e instanceof Error &&
|
|
928
|
+
e.message.includes("VTXO_ALREADY_SPENT")) {
|
|
929
|
+
// Local VTXO cache is stale vs. the server's
|
|
930
|
+
// authoritative view — not a transient failure.
|
|
931
|
+
// Trigger a throttled refresh and skip this cycle
|
|
932
|
+
// without bumping the failure counter, so the next
|
|
933
|
+
// poll can retry once the cache reconciles.
|
|
934
|
+
staleCacheSkip = true;
|
|
935
|
+
void this.maybeRefreshAfterVtxoSpent();
|
|
936
|
+
}
|
|
937
|
+
else {
|
|
938
|
+
throw e;
|
|
939
|
+
}
|
|
875
940
|
}
|
|
876
|
-
success = true;
|
|
877
941
|
}
|
|
878
942
|
finally {
|
|
879
943
|
this.lastPeriodicSettleTimestamp = Date.now();
|
|
@@ -888,7 +952,10 @@ class VtxoManager {
|
|
|
888
952
|
if (success) {
|
|
889
953
|
this.consecutivePeriodicSettleFailures = 0;
|
|
890
954
|
}
|
|
891
|
-
else {
|
|
955
|
+
else if (!staleCacheSkip) {
|
|
956
|
+
// Don't bump on stale-cache skip: it's not a transient
|
|
957
|
+
// failure, and the next cycle should try immediately
|
|
958
|
+
// after the refresh lands.
|
|
892
959
|
this.consecutivePeriodicSettleFailures++;
|
|
893
960
|
}
|
|
894
961
|
}
|
|
@@ -926,3 +993,4 @@ VtxoManager.MAX_BACKOFF_MS = 5 * 60 * 1000; // 5 minutes
|
|
|
926
993
|
VtxoManager.RENEWAL_COOLDOWN_MS = 30000; // 30 seconds
|
|
927
994
|
VtxoManager.PERIODIC_SETTLE_COOLDOWN_MS = 30000;
|
|
928
995
|
VtxoManager.PERIODIC_SETTLE_MAX_BACKOFF_MS = 5 * 60 * 1000;
|
|
996
|
+
VtxoManager.VTXO_SPENT_REFRESH_COOLDOWN_MS = 30000;
|
|
@@ -68,6 +68,12 @@ class ReadonlyWallet {
|
|
|
68
68
|
this.walletRepository = walletRepository;
|
|
69
69
|
this.contractRepository = contractRepository;
|
|
70
70
|
this.delegatorProvider = delegatorProvider;
|
|
71
|
+
// Outpoints ("txid:vout") committed to an in-flight settle/send. Filtered
|
|
72
|
+
// from getVtxos() so concurrent callers (UI, VtxoManager auto-renewal,
|
|
73
|
+
// another send/settle racing the _txLock) can't reselect coins that are
|
|
74
|
+
// already on their way out. The set is in-memory only: a process crash
|
|
75
|
+
// clears it, and a stale entry only hides a VTXO (never spends one).
|
|
76
|
+
this._pendingSpendOutpoints = new Set();
|
|
71
77
|
// Guard: detect identity/server network mismatch for descriptor-based identities.
|
|
72
78
|
// This duplicates the check in setupWalletConfig() so that subclasses
|
|
73
79
|
// bypassing the factory still get the safety net.
|
|
@@ -308,6 +314,9 @@ class ReadonlyWallet {
|
|
|
308
314
|
return vtxos
|
|
309
315
|
.flatMap((_) => _.vtxos)
|
|
310
316
|
.filter((vtxo) => {
|
|
317
|
+
if (this._pendingSpendOutpoints.has(`${vtxo.txid}:${vtxo.vout}`)) {
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
311
320
|
if ((0, _1.isSpendable)(vtxo)) {
|
|
312
321
|
if (!f.withRecoverable &&
|
|
313
322
|
((0, _1.isRecoverable)(vtxo) || (0, _1.isExpired)(vtxo))) {
|
|
@@ -760,6 +769,20 @@ exports.ReadonlyWallet = ReadonlyWallet;
|
|
|
760
769
|
* ```
|
|
761
770
|
*/
|
|
762
771
|
class Wallet extends ReadonlyWallet {
|
|
772
|
+
_addPendingSpends(inputs) {
|
|
773
|
+
for (const input of inputs) {
|
|
774
|
+
if ("virtualStatus" in input) {
|
|
775
|
+
this._pendingSpendOutpoints.add(`${input.txid}:${input.vout}`);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
_removePendingSpends(inputs) {
|
|
780
|
+
for (const input of inputs) {
|
|
781
|
+
if ("virtualStatus" in input) {
|
|
782
|
+
this._pendingSpendOutpoints.delete(`${input.txid}:${input.vout}`);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
}
|
|
763
786
|
_withTxLock(fn) {
|
|
764
787
|
let release;
|
|
765
788
|
const lock = new Promise((r) => (release = r));
|
|
@@ -975,9 +998,15 @@ class Wallet extends ReadonlyWallet {
|
|
|
975
998
|
amount: BigInt(selected.changeAmount),
|
|
976
999
|
});
|
|
977
1000
|
}
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
1001
|
+
this._addPendingSpends(selected.inputs);
|
|
1002
|
+
try {
|
|
1003
|
+
const { arkTxid, signedCheckpointTxs } = await this.buildAndSubmitOffchainTx(selected.inputs, outputs);
|
|
1004
|
+
await this.updateDbAfterOffchainTx(selected.inputs, arkTxid, signedCheckpointTxs, params.amount, selected.changeAmount, selected.changeAmount > 0n ? outputs.length - 1 : 0);
|
|
1005
|
+
return arkTxid;
|
|
1006
|
+
}
|
|
1007
|
+
finally {
|
|
1008
|
+
this._removePendingSpends(selected.inputs);
|
|
1009
|
+
}
|
|
981
1010
|
});
|
|
982
1011
|
}
|
|
983
1012
|
return this.send({
|
|
@@ -1023,7 +1052,8 @@ class Wallet extends ReadonlyWallet {
|
|
|
1023
1052
|
const tip = await this.onchainProvider.getChainTip();
|
|
1024
1053
|
chainTipHeight = tip.height;
|
|
1025
1054
|
}
|
|
1026
|
-
const boardingUtxos = (await this.getBoardingUtxos()).filter((utxo) =>
|
|
1055
|
+
const boardingUtxos = (await this.getBoardingUtxos()).filter((utxo) => utxo.status.confirmed &&
|
|
1056
|
+
!(0, arkTransaction_1.hasBoardingTxExpired)(utxo, boardingTimelock, chainTipHeight));
|
|
1027
1057
|
const filteredBoardingUtxos = [];
|
|
1028
1058
|
for (const utxo of boardingUtxos) {
|
|
1029
1059
|
const inputFee = estimator.evalOnchainInput({
|
|
@@ -1158,11 +1188,29 @@ class Wallet extends ReadonlyWallet {
|
|
|
1158
1188
|
...params.inputs.map((input) => `${input.txid}:${input.vout}`),
|
|
1159
1189
|
];
|
|
1160
1190
|
const abortController = new AbortController();
|
|
1191
|
+
let stream;
|
|
1192
|
+
// Optimistically hide these inputs from concurrent getVtxos() callers
|
|
1193
|
+
// while the settlement is in flight. Set before safeRegisterIntent so
|
|
1194
|
+
// there's no window between intent registration and coin-visibility.
|
|
1195
|
+
this._addPendingSpends(params.inputs);
|
|
1161
1196
|
try {
|
|
1162
|
-
|
|
1197
|
+
stream = this.arkProvider.getEventStream(abortController.signal, topics);
|
|
1198
|
+
// Prime the iterator so the provider opens the SSE subscription
|
|
1199
|
+
// before safeRegisterIntent can trigger server-side batch events.
|
|
1200
|
+
const firstNext = stream.next();
|
|
1201
|
+
// If settle exits before Batch.join consumes the primed result,
|
|
1202
|
+
// keep the orphaned promise from surfacing as an unhandled rejection.
|
|
1203
|
+
void firstNext.catch(() => { });
|
|
1204
|
+
const primedStream = (async function* () {
|
|
1205
|
+
const first = await firstNext;
|
|
1206
|
+
if (!first.done) {
|
|
1207
|
+
yield first.value;
|
|
1208
|
+
}
|
|
1209
|
+
yield* stream;
|
|
1210
|
+
})();
|
|
1163
1211
|
const intentId = await this.safeRegisterIntent(intent, params.inputs);
|
|
1164
1212
|
const handler = this.createBatchHandler(intentId, params.inputs, recipients, session);
|
|
1165
|
-
const commitmentTxid = await batch_1.Batch.join(
|
|
1213
|
+
const commitmentTxid = await batch_1.Batch.join(primedStream, handler, {
|
|
1166
1214
|
abortController,
|
|
1167
1215
|
skipVtxoTreeSigning: !hasOffchainOutputs,
|
|
1168
1216
|
eventCallback: eventCallback
|
|
@@ -1186,23 +1234,28 @@ class Wallet extends ReadonlyWallet {
|
|
|
1186
1234
|
throw error;
|
|
1187
1235
|
}
|
|
1188
1236
|
finally {
|
|
1189
|
-
//
|
|
1237
|
+
// Clear state first so a synchronous handler firing from abort()
|
|
1238
|
+
// never observes a stale pending-spend set.
|
|
1239
|
+
this._removePendingSpends(params.inputs);
|
|
1240
|
+
// close the stream — abort() fires the in-body handler if the
|
|
1241
|
+
// generator has started iterating; return() also releases the
|
|
1242
|
+
// eager resource if the body is still suspended or never ran
|
|
1243
|
+
// (e.g. safeRegisterIntent threw before Batch.join was called).
|
|
1190
1244
|
abortController.abort();
|
|
1245
|
+
await stream?.return?.().catch(() => { });
|
|
1191
1246
|
}
|
|
1192
1247
|
}
|
|
1193
1248
|
async handleSettlementFinalizationEvent(event, inputs, forfeitOutputScript, connectorsGraph) {
|
|
1194
1249
|
// the signed forfeits transactions to submit
|
|
1195
1250
|
const signedForfeits = [];
|
|
1196
|
-
const
|
|
1251
|
+
const isVtxo = (input) => "virtualStatus" in input;
|
|
1197
1252
|
let settlementPsbt = btc_signer_1.Transaction.fromPSBT(base_1.base64.decode(event.commitmentTx));
|
|
1198
1253
|
let hasBoardingUtxos = false;
|
|
1199
1254
|
let connectorIndex = 0;
|
|
1200
1255
|
const connectorsLeaves = connectorsGraph?.leaves() || [];
|
|
1201
1256
|
for (const input of inputs) {
|
|
1202
|
-
// check if the input is an offchain "virtual" coin
|
|
1203
|
-
const vtxo = vtxos.find((vtxo) => vtxo.txid === input.txid && vtxo.vout === input.vout);
|
|
1204
1257
|
// boarding input, we need to sign the settlement tx
|
|
1205
|
-
if (!
|
|
1258
|
+
if (!isVtxo(input)) {
|
|
1206
1259
|
for (let i = 0; i < settlementPsbt.inputsLength; i++) {
|
|
1207
1260
|
const settlementInput = settlementPsbt.getInput(i);
|
|
1208
1261
|
if (!settlementInput.txid ||
|
|
@@ -1226,7 +1279,7 @@ class Wallet extends ReadonlyWallet {
|
|
|
1226
1279
|
}
|
|
1227
1280
|
continue;
|
|
1228
1281
|
}
|
|
1229
|
-
if ((0, _1.isRecoverable)(
|
|
1282
|
+
if ((0, _1.isRecoverable)(input) || (0, _1.isSubdust)(input, this.dustAmount)) {
|
|
1230
1283
|
// recoverable or subdust coin, we don't need to create a forfeit tx
|
|
1231
1284
|
continue;
|
|
1232
1285
|
}
|
|
@@ -1253,7 +1306,7 @@ class Wallet extends ReadonlyWallet {
|
|
|
1253
1306
|
txid: input.txid,
|
|
1254
1307
|
index: input.vout,
|
|
1255
1308
|
witnessUtxo: {
|
|
1256
|
-
amount: BigInt(
|
|
1309
|
+
amount: BigInt(input.value),
|
|
1257
1310
|
script: base_2.VtxoScript.decode(input.tapTree).pkScript,
|
|
1258
1311
|
},
|
|
1259
1312
|
sighashType: btc_signer_1.SigHash.DEFAULT,
|
|
@@ -1682,9 +1735,17 @@ class Wallet extends ReadonlyWallet {
|
|
|
1682
1735
|
outputs.push(extension_1.Extension.create([assetPacket]).txOut());
|
|
1683
1736
|
}
|
|
1684
1737
|
const sentAmount = recipients.reduce((sum, r) => sum + r.amount, 0);
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1738
|
+
// Optimistically hide selected coins from concurrent getVtxos() while
|
|
1739
|
+
// the offchain tx is in flight.
|
|
1740
|
+
this._addPendingSpends(selectedCoins);
|
|
1741
|
+
try {
|
|
1742
|
+
const { arkTxid, signedCheckpointTxs } = await this.buildAndSubmitOffchainTx(selectedCoins, outputs);
|
|
1743
|
+
await this.updateDbAfterOffchainTx(selectedCoins, arkTxid, signedCheckpointTxs, sentAmount, BigInt(changeAmount), changeReceiver ? changeIndex : 0, changeReceiver?.assets);
|
|
1744
|
+
return arkTxid;
|
|
1745
|
+
}
|
|
1746
|
+
finally {
|
|
1747
|
+
this._removePendingSpends(selectedCoins);
|
|
1748
|
+
}
|
|
1688
1749
|
}
|
|
1689
1750
|
/**
|
|
1690
1751
|
* Build an offchain transaction from the given inputs and outputs,
|
|
@@ -227,28 +227,21 @@ export class RestArkProvider {
|
|
|
227
227
|
const queryParams = topics.length > 0
|
|
228
228
|
? `?${topics.map((topic) => `topics=${encodeURIComponent(topic)}`).join("&")}`
|
|
229
229
|
: "";
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
|
|
234
|
-
|
|
230
|
+
// The EventSource is allocated inside the generator body so that
|
|
231
|
+
// abandoning the returned iterator before iteration starts does not
|
|
232
|
+
// leak the underlying SSE connection. `return()` is overridden below
|
|
233
|
+
// so that closing the generator also closes the connection even when
|
|
234
|
+
// the body is currently suspended at an await point.
|
|
235
|
+
let eventSource = null;
|
|
235
236
|
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
|
236
237
|
const self = this;
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
? eagerIterator
|
|
245
|
-
: eventSourceIterator(eventSource);
|
|
246
|
-
firstIteration = false;
|
|
247
|
-
try {
|
|
248
|
-
const abortHandler = () => {
|
|
249
|
-
eventSource.close();
|
|
250
|
-
};
|
|
251
|
-
signal?.addEventListener("abort", abortHandler);
|
|
238
|
+
const gen = (async function* () {
|
|
239
|
+
const abortHandler = () => eventSource?.close();
|
|
240
|
+
signal?.addEventListener("abort", abortHandler);
|
|
241
|
+
try {
|
|
242
|
+
while (!signal?.aborted) {
|
|
243
|
+
eventSource = new EventSource(url + queryParams);
|
|
244
|
+
const iterator = eventSourceIterator(eventSource);
|
|
252
245
|
try {
|
|
253
246
|
for await (const event of iterator) {
|
|
254
247
|
if (signal?.aborted)
|
|
@@ -266,25 +259,35 @@ export class RestArkProvider {
|
|
|
266
259
|
}
|
|
267
260
|
}
|
|
268
261
|
}
|
|
262
|
+
catch (error) {
|
|
263
|
+
if (error instanceof Error &&
|
|
264
|
+
error.name === "AbortError") {
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
// ignore timeout errors, they're expected when the server is not sending anything for 5 min
|
|
268
|
+
if (isFetchTimeoutError(error)) {
|
|
269
|
+
console.debug("Timeout error ignored");
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
console.error("Event stream error:", error);
|
|
273
|
+
throw error;
|
|
274
|
+
}
|
|
269
275
|
finally {
|
|
270
|
-
signal?.removeEventListener("abort", abortHandler);
|
|
271
276
|
eventSource.close();
|
|
272
277
|
}
|
|
273
278
|
}
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
// ignore timeout errors, they're expected when the server is not sending anything for 5 min
|
|
279
|
-
if (isFetchTimeoutError(error)) {
|
|
280
|
-
console.debug("Timeout error ignored");
|
|
281
|
-
continue;
|
|
282
|
-
}
|
|
283
|
-
console.error("Event stream error:", error);
|
|
284
|
-
throw error;
|
|
285
|
-
}
|
|
279
|
+
}
|
|
280
|
+
finally {
|
|
281
|
+
signal?.removeEventListener("abort", abortHandler);
|
|
282
|
+
eventSource?.close();
|
|
286
283
|
}
|
|
287
284
|
})();
|
|
285
|
+
const origReturn = gen.return.bind(gen);
|
|
286
|
+
gen.return = (value) => {
|
|
287
|
+
eventSource?.close();
|
|
288
|
+
return origReturn(value);
|
|
289
|
+
};
|
|
290
|
+
return gen;
|
|
288
291
|
}
|
|
289
292
|
async *getTransactionsStream(signal) {
|
|
290
293
|
const url = `${this.serverUrl}/v1/txs`;
|
|
@@ -227,6 +227,11 @@ export class VtxoManager {
|
|
|
227
227
|
// because they now ride on the same settle intent.
|
|
228
228
|
this.lastPeriodicSettleTimestamp = 0;
|
|
229
229
|
this.consecutivePeriodicSettleFailures = 0;
|
|
230
|
+
// Throttle for the VTXO_ALREADY_SPENT -> refreshVtxos() reconciliation.
|
|
231
|
+
// The server's authoritative view says our local cache is stale, so we
|
|
232
|
+
// trigger a full refresh to advance the global sync cursor. Rate-limit
|
|
233
|
+
// to guard against a buggy indexer cycling us into a refresh storm.
|
|
234
|
+
this.lastVtxoSpentRefreshTimestamp = 0;
|
|
230
235
|
// Normalize: prefer settlementConfig, fall back to renewalConfig, default to enabled
|
|
231
236
|
if (settlementConfig !== undefined) {
|
|
232
237
|
this.settlementConfig = settlementConfig;
|
|
@@ -662,7 +667,6 @@ export class VtxoManager {
|
|
|
662
667
|
return;
|
|
663
668
|
}
|
|
664
669
|
if (e.message.includes("VTXO_ALREADY_REGISTERED") ||
|
|
665
|
-
e.message.includes("VTXO_ALREADY_SPENT") ||
|
|
666
670
|
e.message.includes("duplicated input")) {
|
|
667
671
|
// Virtual output is already being used in a concurrent
|
|
668
672
|
// user-initiated operation. Skip silently — the
|
|
@@ -670,6 +674,14 @@ export class VtxoManager {
|
|
|
670
674
|
// renewal will retry on the next cycle.
|
|
671
675
|
return;
|
|
672
676
|
}
|
|
677
|
+
if (e.message.includes("VTXO_ALREADY_SPENT")) {
|
|
678
|
+
// Our local VTXO cache is stale vs. the
|
|
679
|
+
// server's authoritative view. Trigger a
|
|
680
|
+
// throttled refresh to reconcile, then skip
|
|
681
|
+
// — the next cycle will see fresh data.
|
|
682
|
+
void this.maybeRefreshAfterVtxoSpent();
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
673
685
|
}
|
|
674
686
|
console.error("Error renewing VTXOs:", e);
|
|
675
687
|
});
|
|
@@ -687,6 +699,39 @@ export class VtxoManager {
|
|
|
687
699
|
return undefined;
|
|
688
700
|
}
|
|
689
701
|
}
|
|
702
|
+
/**
|
|
703
|
+
* VTXO_ALREADY_SPENT means the server's authoritative view of VTXO state
|
|
704
|
+
* is ahead of ours — cross-instance race, pre-lock snapshot drift, or an
|
|
705
|
+
* SSE gap left stale data in the local cache. Silent-swallowing guarantees
|
|
706
|
+
* the same error on the next cycle because nothing reconciles the cache,
|
|
707
|
+
* so instead we trigger a full refreshVtxos() to advance the global sync
|
|
708
|
+
* cursor. Throttled to prevent a buggy indexer from causing a refresh
|
|
709
|
+
* storm.
|
|
710
|
+
*/
|
|
711
|
+
maybeRefreshAfterVtxoSpent() {
|
|
712
|
+
if (this.vtxoSpentRefreshPromise) {
|
|
713
|
+
return this.vtxoSpentRefreshPromise;
|
|
714
|
+
}
|
|
715
|
+
const now = Date.now();
|
|
716
|
+
if (now - this.lastVtxoSpentRefreshTimestamp <
|
|
717
|
+
VtxoManager.VTXO_SPENT_REFRESH_COOLDOWN_MS) {
|
|
718
|
+
return Promise.resolve();
|
|
719
|
+
}
|
|
720
|
+
this.lastVtxoSpentRefreshTimestamp = now;
|
|
721
|
+
this.vtxoSpentRefreshPromise = (async () => {
|
|
722
|
+
try {
|
|
723
|
+
const contractManager = await this.wallet.getContractManager();
|
|
724
|
+
await contractManager.refreshVtxos();
|
|
725
|
+
}
|
|
726
|
+
catch (e) {
|
|
727
|
+
console.error("Error refreshing VTXOs after VTXO_ALREADY_SPENT:", e);
|
|
728
|
+
}
|
|
729
|
+
finally {
|
|
730
|
+
this.vtxoSpentRefreshPromise = undefined;
|
|
731
|
+
}
|
|
732
|
+
})();
|
|
733
|
+
return this.vtxoSpentRefreshPromise;
|
|
734
|
+
}
|
|
690
735
|
/** Computes the next poll delay, applying exponential backoff on failures. */
|
|
691
736
|
getNextPollDelay() {
|
|
692
737
|
if (this.settlementConfig === false)
|
|
@@ -819,7 +864,8 @@ export class VtxoManager {
|
|
|
819
864
|
catch (e) {
|
|
820
865
|
throw e instanceof Error ? e : new Error(String(e));
|
|
821
866
|
}
|
|
822
|
-
const unsettledBoarding = boardingUtxos.filter((u) =>
|
|
867
|
+
const unsettledBoarding = boardingUtxos.filter((u) => u.status.confirmed &&
|
|
868
|
+
!this.knownBoardingUtxos.has(`${u.txid}:${u.vout}`) &&
|
|
823
869
|
!expiredSet.has(`${u.txid}:${u.vout}`));
|
|
824
870
|
// Collect near-expiry VTXOs unless the event-driven path is mid-renewal.
|
|
825
871
|
// Skipping when renewalInProgress avoids double-submitting the same VTXOs.
|
|
@@ -859,16 +905,34 @@ export class VtxoManager {
|
|
|
859
905
|
this.renewalInProgress = true;
|
|
860
906
|
}
|
|
861
907
|
let success = false;
|
|
908
|
+
let staleCacheSkip = false;
|
|
862
909
|
try {
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
910
|
+
try {
|
|
911
|
+
await this.wallet.settle({
|
|
912
|
+
inputs: [...unsettledBoarding, ...expiringVtxos],
|
|
913
|
+
outputs: [{ address: arkAddress, amount: totalAmount }],
|
|
914
|
+
});
|
|
915
|
+
// Mark boarding inputs as known only after successful settle.
|
|
916
|
+
for (const u of unsettledBoarding) {
|
|
917
|
+
this.knownBoardingUtxos.add(`${u.txid}:${u.vout}`);
|
|
918
|
+
}
|
|
919
|
+
success = true;
|
|
920
|
+
}
|
|
921
|
+
catch (e) {
|
|
922
|
+
if (e instanceof Error &&
|
|
923
|
+
e.message.includes("VTXO_ALREADY_SPENT")) {
|
|
924
|
+
// Local VTXO cache is stale vs. the server's
|
|
925
|
+
// authoritative view — not a transient failure.
|
|
926
|
+
// Trigger a throttled refresh and skip this cycle
|
|
927
|
+
// without bumping the failure counter, so the next
|
|
928
|
+
// poll can retry once the cache reconciles.
|
|
929
|
+
staleCacheSkip = true;
|
|
930
|
+
void this.maybeRefreshAfterVtxoSpent();
|
|
931
|
+
}
|
|
932
|
+
else {
|
|
933
|
+
throw e;
|
|
934
|
+
}
|
|
870
935
|
}
|
|
871
|
-
success = true;
|
|
872
936
|
}
|
|
873
937
|
finally {
|
|
874
938
|
this.lastPeriodicSettleTimestamp = Date.now();
|
|
@@ -883,7 +947,10 @@ export class VtxoManager {
|
|
|
883
947
|
if (success) {
|
|
884
948
|
this.consecutivePeriodicSettleFailures = 0;
|
|
885
949
|
}
|
|
886
|
-
else {
|
|
950
|
+
else if (!staleCacheSkip) {
|
|
951
|
+
// Don't bump on stale-cache skip: it's not a transient
|
|
952
|
+
// failure, and the next cycle should try immediately
|
|
953
|
+
// after the refresh lands.
|
|
887
954
|
this.consecutivePeriodicSettleFailures++;
|
|
888
955
|
}
|
|
889
956
|
}
|
|
@@ -920,3 +987,4 @@ VtxoManager.MAX_BACKOFF_MS = 5 * 60 * 1000; // 5 minutes
|
|
|
920
987
|
VtxoManager.RENEWAL_COOLDOWN_MS = 30000; // 30 seconds
|
|
921
988
|
VtxoManager.PERIODIC_SETTLE_COOLDOWN_MS = 30000;
|
|
922
989
|
VtxoManager.PERIODIC_SETTLE_MAX_BACKOFF_MS = 5 * 60 * 1000;
|
|
990
|
+
VtxoManager.VTXO_SPENT_REFRESH_COOLDOWN_MS = 30000;
|
|
@@ -63,6 +63,12 @@ export class ReadonlyWallet {
|
|
|
63
63
|
this.walletRepository = walletRepository;
|
|
64
64
|
this.contractRepository = contractRepository;
|
|
65
65
|
this.delegatorProvider = delegatorProvider;
|
|
66
|
+
// Outpoints ("txid:vout") committed to an in-flight settle/send. Filtered
|
|
67
|
+
// from getVtxos() so concurrent callers (UI, VtxoManager auto-renewal,
|
|
68
|
+
// another send/settle racing the _txLock) can't reselect coins that are
|
|
69
|
+
// already on their way out. The set is in-memory only: a process crash
|
|
70
|
+
// clears it, and a stale entry only hides a VTXO (never spends one).
|
|
71
|
+
this._pendingSpendOutpoints = new Set();
|
|
66
72
|
// Guard: detect identity/server network mismatch for descriptor-based identities.
|
|
67
73
|
// This duplicates the check in setupWalletConfig() so that subclasses
|
|
68
74
|
// bypassing the factory still get the safety net.
|
|
@@ -303,6 +309,9 @@ export class ReadonlyWallet {
|
|
|
303
309
|
return vtxos
|
|
304
310
|
.flatMap((_) => _.vtxos)
|
|
305
311
|
.filter((vtxo) => {
|
|
312
|
+
if (this._pendingSpendOutpoints.has(`${vtxo.txid}:${vtxo.vout}`)) {
|
|
313
|
+
return false;
|
|
314
|
+
}
|
|
306
315
|
if (isSpendable(vtxo)) {
|
|
307
316
|
if (!f.withRecoverable &&
|
|
308
317
|
(isRecoverable(vtxo) || isExpired(vtxo))) {
|
|
@@ -754,6 +763,20 @@ export class ReadonlyWallet {
|
|
|
754
763
|
* ```
|
|
755
764
|
*/
|
|
756
765
|
export class Wallet extends ReadonlyWallet {
|
|
766
|
+
_addPendingSpends(inputs) {
|
|
767
|
+
for (const input of inputs) {
|
|
768
|
+
if ("virtualStatus" in input) {
|
|
769
|
+
this._pendingSpendOutpoints.add(`${input.txid}:${input.vout}`);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
_removePendingSpends(inputs) {
|
|
774
|
+
for (const input of inputs) {
|
|
775
|
+
if ("virtualStatus" in input) {
|
|
776
|
+
this._pendingSpendOutpoints.delete(`${input.txid}:${input.vout}`);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
}
|
|
757
780
|
_withTxLock(fn) {
|
|
758
781
|
let release;
|
|
759
782
|
const lock = new Promise((r) => (release = r));
|
|
@@ -969,9 +992,15 @@ export class Wallet extends ReadonlyWallet {
|
|
|
969
992
|
amount: BigInt(selected.changeAmount),
|
|
970
993
|
});
|
|
971
994
|
}
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
995
|
+
this._addPendingSpends(selected.inputs);
|
|
996
|
+
try {
|
|
997
|
+
const { arkTxid, signedCheckpointTxs } = await this.buildAndSubmitOffchainTx(selected.inputs, outputs);
|
|
998
|
+
await this.updateDbAfterOffchainTx(selected.inputs, arkTxid, signedCheckpointTxs, params.amount, selected.changeAmount, selected.changeAmount > 0n ? outputs.length - 1 : 0);
|
|
999
|
+
return arkTxid;
|
|
1000
|
+
}
|
|
1001
|
+
finally {
|
|
1002
|
+
this._removePendingSpends(selected.inputs);
|
|
1003
|
+
}
|
|
975
1004
|
});
|
|
976
1005
|
}
|
|
977
1006
|
return this.send({
|
|
@@ -1017,7 +1046,8 @@ export class Wallet extends ReadonlyWallet {
|
|
|
1017
1046
|
const tip = await this.onchainProvider.getChainTip();
|
|
1018
1047
|
chainTipHeight = tip.height;
|
|
1019
1048
|
}
|
|
1020
|
-
const boardingUtxos = (await this.getBoardingUtxos()).filter((utxo) =>
|
|
1049
|
+
const boardingUtxos = (await this.getBoardingUtxos()).filter((utxo) => utxo.status.confirmed &&
|
|
1050
|
+
!hasBoardingTxExpired(utxo, boardingTimelock, chainTipHeight));
|
|
1021
1051
|
const filteredBoardingUtxos = [];
|
|
1022
1052
|
for (const utxo of boardingUtxos) {
|
|
1023
1053
|
const inputFee = estimator.evalOnchainInput({
|
|
@@ -1152,11 +1182,29 @@ export class Wallet extends ReadonlyWallet {
|
|
|
1152
1182
|
...params.inputs.map((input) => `${input.txid}:${input.vout}`),
|
|
1153
1183
|
];
|
|
1154
1184
|
const abortController = new AbortController();
|
|
1185
|
+
let stream;
|
|
1186
|
+
// Optimistically hide these inputs from concurrent getVtxos() callers
|
|
1187
|
+
// while the settlement is in flight. Set before safeRegisterIntent so
|
|
1188
|
+
// there's no window between intent registration and coin-visibility.
|
|
1189
|
+
this._addPendingSpends(params.inputs);
|
|
1155
1190
|
try {
|
|
1156
|
-
|
|
1191
|
+
stream = this.arkProvider.getEventStream(abortController.signal, topics);
|
|
1192
|
+
// Prime the iterator so the provider opens the SSE subscription
|
|
1193
|
+
// before safeRegisterIntent can trigger server-side batch events.
|
|
1194
|
+
const firstNext = stream.next();
|
|
1195
|
+
// If settle exits before Batch.join consumes the primed result,
|
|
1196
|
+
// keep the orphaned promise from surfacing as an unhandled rejection.
|
|
1197
|
+
void firstNext.catch(() => { });
|
|
1198
|
+
const primedStream = (async function* () {
|
|
1199
|
+
const first = await firstNext;
|
|
1200
|
+
if (!first.done) {
|
|
1201
|
+
yield first.value;
|
|
1202
|
+
}
|
|
1203
|
+
yield* stream;
|
|
1204
|
+
})();
|
|
1157
1205
|
const intentId = await this.safeRegisterIntent(intent, params.inputs);
|
|
1158
1206
|
const handler = this.createBatchHandler(intentId, params.inputs, recipients, session);
|
|
1159
|
-
const commitmentTxid = await Batch.join(
|
|
1207
|
+
const commitmentTxid = await Batch.join(primedStream, handler, {
|
|
1160
1208
|
abortController,
|
|
1161
1209
|
skipVtxoTreeSigning: !hasOffchainOutputs,
|
|
1162
1210
|
eventCallback: eventCallback
|
|
@@ -1180,23 +1228,28 @@ export class Wallet extends ReadonlyWallet {
|
|
|
1180
1228
|
throw error;
|
|
1181
1229
|
}
|
|
1182
1230
|
finally {
|
|
1183
|
-
//
|
|
1231
|
+
// Clear state first so a synchronous handler firing from abort()
|
|
1232
|
+
// never observes a stale pending-spend set.
|
|
1233
|
+
this._removePendingSpends(params.inputs);
|
|
1234
|
+
// close the stream — abort() fires the in-body handler if the
|
|
1235
|
+
// generator has started iterating; return() also releases the
|
|
1236
|
+
// eager resource if the body is still suspended or never ran
|
|
1237
|
+
// (e.g. safeRegisterIntent threw before Batch.join was called).
|
|
1184
1238
|
abortController.abort();
|
|
1239
|
+
await stream?.return?.().catch(() => { });
|
|
1185
1240
|
}
|
|
1186
1241
|
}
|
|
1187
1242
|
async handleSettlementFinalizationEvent(event, inputs, forfeitOutputScript, connectorsGraph) {
|
|
1188
1243
|
// the signed forfeits transactions to submit
|
|
1189
1244
|
const signedForfeits = [];
|
|
1190
|
-
const
|
|
1245
|
+
const isVtxo = (input) => "virtualStatus" in input;
|
|
1191
1246
|
let settlementPsbt = Transaction.fromPSBT(base64.decode(event.commitmentTx));
|
|
1192
1247
|
let hasBoardingUtxos = false;
|
|
1193
1248
|
let connectorIndex = 0;
|
|
1194
1249
|
const connectorsLeaves = connectorsGraph?.leaves() || [];
|
|
1195
1250
|
for (const input of inputs) {
|
|
1196
|
-
// check if the input is an offchain "virtual" coin
|
|
1197
|
-
const vtxo = vtxos.find((vtxo) => vtxo.txid === input.txid && vtxo.vout === input.vout);
|
|
1198
1251
|
// boarding input, we need to sign the settlement tx
|
|
1199
|
-
if (!
|
|
1252
|
+
if (!isVtxo(input)) {
|
|
1200
1253
|
for (let i = 0; i < settlementPsbt.inputsLength; i++) {
|
|
1201
1254
|
const settlementInput = settlementPsbt.getInput(i);
|
|
1202
1255
|
if (!settlementInput.txid ||
|
|
@@ -1220,7 +1273,7 @@ export class Wallet extends ReadonlyWallet {
|
|
|
1220
1273
|
}
|
|
1221
1274
|
continue;
|
|
1222
1275
|
}
|
|
1223
|
-
if (isRecoverable(
|
|
1276
|
+
if (isRecoverable(input) || isSubdust(input, this.dustAmount)) {
|
|
1224
1277
|
// recoverable or subdust coin, we don't need to create a forfeit tx
|
|
1225
1278
|
continue;
|
|
1226
1279
|
}
|
|
@@ -1247,7 +1300,7 @@ export class Wallet extends ReadonlyWallet {
|
|
|
1247
1300
|
txid: input.txid,
|
|
1248
1301
|
index: input.vout,
|
|
1249
1302
|
witnessUtxo: {
|
|
1250
|
-
amount: BigInt(
|
|
1303
|
+
amount: BigInt(input.value),
|
|
1251
1304
|
script: VtxoScript.decode(input.tapTree).pkScript,
|
|
1252
1305
|
},
|
|
1253
1306
|
sighashType: SigHash.DEFAULT,
|
|
@@ -1676,9 +1729,17 @@ export class Wallet extends ReadonlyWallet {
|
|
|
1676
1729
|
outputs.push(Extension.create([assetPacket]).txOut());
|
|
1677
1730
|
}
|
|
1678
1731
|
const sentAmount = recipients.reduce((sum, r) => sum + r.amount, 0);
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1732
|
+
// Optimistically hide selected coins from concurrent getVtxos() while
|
|
1733
|
+
// the offchain tx is in flight.
|
|
1734
|
+
this._addPendingSpends(selectedCoins);
|
|
1735
|
+
try {
|
|
1736
|
+
const { arkTxid, signedCheckpointTxs } = await this.buildAndSubmitOffchainTx(selectedCoins, outputs);
|
|
1737
|
+
await this.updateDbAfterOffchainTx(selectedCoins, arkTxid, signedCheckpointTxs, sentAmount, BigInt(changeAmount), changeReceiver ? changeIndex : 0, changeReceiver?.assets);
|
|
1738
|
+
return arkTxid;
|
|
1739
|
+
}
|
|
1740
|
+
finally {
|
|
1741
|
+
this._removePendingSpends(selectedCoins);
|
|
1742
|
+
}
|
|
1682
1743
|
}
|
|
1683
1744
|
/**
|
|
1684
1745
|
* Build an offchain transaction from the given inputs and outputs,
|
|
@@ -224,6 +224,9 @@ export declare class VtxoManager implements AsyncDisposable, IVtxoManager {
|
|
|
224
224
|
private consecutivePeriodicSettleFailures;
|
|
225
225
|
private static readonly PERIODIC_SETTLE_COOLDOWN_MS;
|
|
226
226
|
private static readonly PERIODIC_SETTLE_MAX_BACKOFF_MS;
|
|
227
|
+
private lastVtxoSpentRefreshTimestamp;
|
|
228
|
+
private vtxoSpentRefreshPromise?;
|
|
229
|
+
private static readonly VTXO_SPENT_REFRESH_COOLDOWN_MS;
|
|
227
230
|
constructor(wallet: IWallet,
|
|
228
231
|
/** @deprecated Use settlementConfig instead */
|
|
229
232
|
renewalConfig?: RenewalConfig | undefined, settlementConfig?: SettlementConfig | false);
|
|
@@ -402,6 +405,16 @@ export declare class VtxoManager implements AsyncDisposable, IVtxoManager {
|
|
|
402
405
|
/** Returns the wallet's identity for transaction signing. */
|
|
403
406
|
private getIdentity;
|
|
404
407
|
private initializeSubscription;
|
|
408
|
+
/**
|
|
409
|
+
* VTXO_ALREADY_SPENT means the server's authoritative view of VTXO state
|
|
410
|
+
* is ahead of ours — cross-instance race, pre-lock snapshot drift, or an
|
|
411
|
+
* SSE gap left stale data in the local cache. Silent-swallowing guarantees
|
|
412
|
+
* the same error on the next cycle because nothing reconciles the cache,
|
|
413
|
+
* so instead we trigger a full refreshVtxos() to advance the global sync
|
|
414
|
+
* cursor. Throttled to prevent a buggy indexer from causing a refresh
|
|
415
|
+
* storm.
|
|
416
|
+
*/
|
|
417
|
+
private maybeRefreshAfterVtxoSpent;
|
|
405
418
|
/** Computes the next poll delay, applying exponential backoff on failures. */
|
|
406
419
|
private getNextPollDelay;
|
|
407
420
|
/**
|
|
@@ -44,6 +44,7 @@ export declare class ReadonlyWallet implements IReadonlyWallet {
|
|
|
44
44
|
protected readonly watcherConfig?: ReadonlyWalletConfig["watcherConfig"];
|
|
45
45
|
private readonly _assetManager;
|
|
46
46
|
private _syncVtxosInflight?;
|
|
47
|
+
protected _pendingSpendOutpoints: Set<string>;
|
|
47
48
|
get assetManager(): IReadonlyAssetManager;
|
|
48
49
|
protected constructor(identity: ReadonlyIdentity, network: Network, onchainProvider: OnchainProvider, indexerProvider: IndexerProvider, arkServerPublicKey: Bytes, offchainTapscript: DefaultVtxo.Script | DelegateVtxo.Script, boardingTapscript: DefaultVtxo.Script, dustAmount: bigint, walletRepository: WalletRepository, contractRepository: ContractRepository, delegatorProvider?: DelegatorProvider | undefined, watcherConfig?: ReadonlyWalletConfig["watcherConfig"]);
|
|
49
50
|
/**
|
|
@@ -217,6 +218,8 @@ export declare class Wallet extends ReadonlyWallet implements IWallet {
|
|
|
217
218
|
* same VTXO inputs.
|
|
218
219
|
*/
|
|
219
220
|
private _txLock;
|
|
221
|
+
private _addPendingSpends;
|
|
222
|
+
private _removePendingSpends;
|
|
220
223
|
private _withTxLock;
|
|
221
224
|
/** @deprecated Use settlementConfig instead */
|
|
222
225
|
readonly renewalConfig: Required<Omit<WalletConfig["renewalConfig"], "enabled">> & {
|