@arkade-os/sdk 0.4.23 → 0.4.25
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 +21 -1
- package/dist/cjs/contracts/contractManager.js +66 -5
- package/dist/cjs/contracts/contractWatcher.js +9 -3
- package/dist/cjs/contracts/handlers/default.js +3 -2
- package/dist/cjs/contracts/handlers/delegate.js +3 -2
- package/dist/cjs/contracts/handlers/helpers.js +2 -58
- package/dist/cjs/contracts/handlers/vhtlc.js +7 -6
- package/dist/cjs/contracts/vtxoOwnership.js +78 -0
- package/dist/cjs/index.js +3 -3
- package/dist/cjs/repositories/inMemory/walletRepository.js +35 -0
- package/dist/cjs/repositories/indexedDB/walletRepository.js +117 -0
- package/dist/cjs/repositories/realm/walletRepository.js +28 -0
- package/dist/cjs/repositories/sqlite/walletRepository.js +23 -0
- package/dist/cjs/script/base.js +12 -47
- package/dist/cjs/script/tapscript.js +97 -73
- package/dist/cjs/utils/timelock.js +59 -0
- package/dist/cjs/utils/unknownFields.js +2 -39
- package/dist/cjs/wallet/serviceWorker/wallet-message-handler.js +71 -10
- package/dist/cjs/wallet/serviceWorker/wallet.js +10 -0
- package/dist/cjs/wallet/unroll.js +79 -67
- package/dist/cjs/wallet/vtxo-manager.js +112 -16
- package/dist/cjs/wallet/wallet.js +64 -8
- package/dist/cjs/worker/expo/processors/contractPollProcessor.js +7 -2
- package/dist/esm/contracts/contractManager.js +66 -5
- package/dist/esm/contracts/contractWatcher.js +9 -3
- package/dist/esm/contracts/handlers/default.js +2 -1
- package/dist/esm/contracts/handlers/delegate.js +2 -1
- package/dist/esm/contracts/handlers/helpers.js +1 -22
- package/dist/esm/contracts/handlers/vhtlc.js +2 -1
- package/dist/esm/contracts/vtxoOwnership.js +69 -0
- package/dist/esm/index.js +1 -1
- package/dist/esm/repositories/inMemory/walletRepository.js +35 -0
- package/dist/esm/repositories/indexedDB/walletRepository.js +117 -0
- package/dist/esm/repositories/realm/walletRepository.js +28 -0
- package/dist/esm/repositories/sqlite/walletRepository.js +23 -0
- package/dist/esm/script/base.js +12 -14
- package/dist/esm/script/tapscript.js +97 -40
- package/dist/esm/utils/timelock.js +22 -0
- package/dist/esm/utils/unknownFields.js +2 -6
- package/dist/esm/wallet/serviceWorker/wallet-message-handler.js +71 -10
- package/dist/esm/wallet/serviceWorker/wallet.js +10 -0
- package/dist/esm/wallet/unroll.js +78 -67
- package/dist/esm/wallet/vtxo-manager.js +112 -16
- package/dist/esm/wallet/wallet.js +62 -6
- package/dist/esm/worker/expo/processors/contractPollProcessor.js +7 -2
- package/dist/types/contracts/contractManager.d.ts +17 -1
- package/dist/types/contracts/handlers/helpers.d.ts +0 -9
- package/dist/types/contracts/vtxoOwnership.d.ts +33 -0
- package/dist/types/index.d.ts +1 -1
- package/dist/types/repositories/inMemory/walletRepository.d.ts +4 -1
- package/dist/types/repositories/indexedDB/walletRepository.d.ts +4 -1
- package/dist/types/repositories/realm/walletRepository.d.ts +4 -1
- package/dist/types/repositories/sqlite/walletRepository.d.ts +4 -1
- package/dist/types/repositories/walletRepository.d.ts +21 -0
- package/dist/types/script/tapscript.d.ts +4 -0
- package/dist/types/utils/timelock.d.ts +9 -0
- package/dist/types/wallet/serviceWorker/wallet-message-handler.d.ts +14 -2
- package/dist/types/wallet/unroll.d.ts +10 -0
- package/dist/types/wallet/vtxo-manager.d.ts +32 -5
- package/package.json +1 -1
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.Unroll = void 0;
|
|
4
|
+
exports.prepareUnrollTransaction = prepareUnrollTransaction;
|
|
4
5
|
const base_1 = require("@scure/base");
|
|
5
6
|
const btc_signer_1 = require("@scure/btc-signer");
|
|
6
|
-
const
|
|
7
|
+
const timelock_1 = require("../utils/timelock");
|
|
7
8
|
const indexer_1 = require("../providers/indexer");
|
|
8
9
|
const base_2 = require("../script/base");
|
|
9
10
|
const txSizeEstimator_1 = require("../utils/txSizeEstimator");
|
|
@@ -129,10 +130,12 @@ var Unroll;
|
|
|
129
130
|
// finalize Arkade transaction
|
|
130
131
|
tx.finalize();
|
|
131
132
|
}
|
|
133
|
+
const pkg = await this.bumper.bumpP2A(tx);
|
|
132
134
|
return {
|
|
133
135
|
type: StepType.UNROLL,
|
|
134
136
|
tx,
|
|
135
|
-
|
|
137
|
+
pkg,
|
|
138
|
+
do: doUnroll(this.explorer, pkg),
|
|
136
139
|
};
|
|
137
140
|
}
|
|
138
141
|
/**
|
|
@@ -164,79 +167,88 @@ var Unroll;
|
|
|
164
167
|
* @returns the txid of the transaction spending the unrolled funds
|
|
165
168
|
*/
|
|
166
169
|
async function completeUnroll(wallet, vtxoTxids, outputAddress) {
|
|
167
|
-
const
|
|
168
|
-
let vtxos = await wallet.getVtxos({ withUnrolled: true });
|
|
169
|
-
vtxos = vtxos.filter((vtxo) => vtxoTxids.includes(vtxo.txid));
|
|
170
|
-
if (vtxos.length === 0) {
|
|
171
|
-
throw new Error("No vtxos to complete unroll");
|
|
172
|
-
}
|
|
173
|
-
const inputs = [];
|
|
174
|
-
let totalAmount = 0n;
|
|
175
|
-
const txWeightEstimator = txSizeEstimator_1.TxWeightEstimator.create();
|
|
176
|
-
for (const vtxo of vtxos) {
|
|
177
|
-
if (!vtxo.isUnrolled) {
|
|
178
|
-
throw new Error(`Vtxo ${vtxo.txid}:${vtxo.vout} is not fully unrolled, use unroll first`);
|
|
179
|
-
}
|
|
180
|
-
const txStatus = await wallet.onchainProvider.getTxStatus(vtxo.txid);
|
|
181
|
-
if (!txStatus.confirmed) {
|
|
182
|
-
throw new Error(`tx ${vtxo.txid} is not confirmed`);
|
|
183
|
-
}
|
|
184
|
-
const exit = availableExitPath({ height: txStatus.blockHeight, time: txStatus.blockTime }, chainTip, vtxo);
|
|
185
|
-
if (!exit) {
|
|
186
|
-
throw new Error(`no available exit path found for vtxo ${vtxo.txid}:${vtxo.vout}`);
|
|
187
|
-
}
|
|
188
|
-
const spendingLeaf = base_2.VtxoScript.decode(vtxo.tapTree).findLeaf(base_1.hex.encode(exit.script));
|
|
189
|
-
if (!spendingLeaf) {
|
|
190
|
-
throw new Error(`spending leaf not found for vtxo ${vtxo.txid}:${vtxo.vout}`);
|
|
191
|
-
}
|
|
192
|
-
totalAmount += BigInt(vtxo.value);
|
|
193
|
-
const sequence = (0, helpers_1.timelockToSequence)(exit.params.timelock);
|
|
194
|
-
inputs.push({
|
|
195
|
-
txid: vtxo.txid,
|
|
196
|
-
index: vtxo.vout,
|
|
197
|
-
tapLeafScript: [spendingLeaf],
|
|
198
|
-
sequence,
|
|
199
|
-
witnessUtxo: {
|
|
200
|
-
amount: BigInt(vtxo.value),
|
|
201
|
-
script: base_2.VtxoScript.decode(vtxo.tapTree).pkScript,
|
|
202
|
-
},
|
|
203
|
-
sighashType: btc_signer_1.SigHash.DEFAULT,
|
|
204
|
-
});
|
|
205
|
-
txWeightEstimator.addTapscriptInput(64, spendingLeaf[1].length, btc_signer_1.TaprootControlBlock.encode(spendingLeaf[0]).length);
|
|
206
|
-
}
|
|
207
|
-
const tx = new transaction_1.Transaction({ version: 2 });
|
|
208
|
-
for (const input of inputs) {
|
|
209
|
-
tx.addInput(input);
|
|
210
|
-
}
|
|
211
|
-
txWeightEstimator.addOutputAddress(outputAddress, wallet.network);
|
|
212
|
-
let feeRate = await wallet.onchainProvider.getFeeRate();
|
|
213
|
-
if (!feeRate || feeRate < wallet_1.Wallet.MIN_FEE_RATE) {
|
|
214
|
-
feeRate = wallet_1.Wallet.MIN_FEE_RATE;
|
|
215
|
-
}
|
|
216
|
-
const feeAmount = txWeightEstimator.vsize().fee(BigInt(feeRate));
|
|
217
|
-
if (feeAmount > totalAmount) {
|
|
218
|
-
throw new Error("fee amount is greater than the total amount");
|
|
219
|
-
}
|
|
220
|
-
const sendAmount = totalAmount - feeAmount;
|
|
221
|
-
if (sendAmount < BigInt(utils_1.DUST_AMOUNT)) {
|
|
222
|
-
throw new Error("send amount is less than dust amount");
|
|
223
|
-
}
|
|
224
|
-
tx.addOutputAddress(outputAddress, sendAmount);
|
|
225
|
-
const signedTx = await wallet.identity.sign(tx);
|
|
226
|
-
signedTx.finalize();
|
|
170
|
+
const signedTx = await prepareUnrollTransaction(wallet, vtxoTxids, outputAddress);
|
|
227
171
|
await wallet.onchainProvider.broadcastTransaction(signedTx.hex);
|
|
228
172
|
return signedTx.id;
|
|
229
173
|
}
|
|
230
174
|
Unroll.completeUnroll = completeUnroll;
|
|
231
175
|
})(Unroll || (exports.Unroll = Unroll = {}));
|
|
176
|
+
/**
|
|
177
|
+
* Prepares the transaction that spends the CSV path to complete unrolling a VTXO.
|
|
178
|
+
* @param wallet the wallet owning the VTXO(s)
|
|
179
|
+
* @param vtxoTxIds the txids of the VTXO(s) to complete unroll
|
|
180
|
+
* @param outputAddress the address to send the unrolled funds to
|
|
181
|
+
* @throws if the VTXO(s) are not fully unrolled, if the txids are not found, if the tx is not confirmed, if no exit path is found or not available
|
|
182
|
+
* @returns the transaction spending the unrolled funds
|
|
183
|
+
*/
|
|
184
|
+
async function prepareUnrollTransaction(wallet, vtxoTxIds, outputAddress) {
|
|
185
|
+
const chainTip = await wallet.onchainProvider.getChainTip();
|
|
186
|
+
let vtxos = await wallet.getVtxos({ withUnrolled: true });
|
|
187
|
+
vtxos = vtxos.filter((vtxo) => vtxoTxIds.includes(vtxo.txid));
|
|
188
|
+
if (vtxos.length === 0) {
|
|
189
|
+
throw new Error("No vtxos to complete unroll");
|
|
190
|
+
}
|
|
191
|
+
const inputs = [];
|
|
192
|
+
let totalAmount = 0n;
|
|
193
|
+
const txWeightEstimator = txSizeEstimator_1.TxWeightEstimator.create();
|
|
194
|
+
for (const vtxo of vtxos) {
|
|
195
|
+
if (!vtxo.isUnrolled) {
|
|
196
|
+
throw new Error(`Vtxo ${vtxo.txid}:${vtxo.vout} is not fully unrolled, use unroll first`);
|
|
197
|
+
}
|
|
198
|
+
const txStatus = await wallet.onchainProvider.getTxStatus(vtxo.txid);
|
|
199
|
+
if (!txStatus.confirmed) {
|
|
200
|
+
throw new Error(`tx ${vtxo.txid} is not confirmed`);
|
|
201
|
+
}
|
|
202
|
+
const exit = availableExitPath({ height: txStatus.blockHeight, time: txStatus.blockTime }, chainTip, vtxo);
|
|
203
|
+
if (!exit) {
|
|
204
|
+
throw new Error(`no available exit path found for vtxo ${vtxo.txid}:${vtxo.vout}`);
|
|
205
|
+
}
|
|
206
|
+
const spendingLeaf = base_2.VtxoScript.decode(vtxo.tapTree).findLeaf(base_1.hex.encode(exit.script));
|
|
207
|
+
if (!spendingLeaf) {
|
|
208
|
+
throw new Error(`spending leaf not found for vtxo ${vtxo.txid}:${vtxo.vout}`);
|
|
209
|
+
}
|
|
210
|
+
totalAmount += BigInt(vtxo.value);
|
|
211
|
+
const sequence = (0, timelock_1.timelockToSequence)(exit.params.timelock);
|
|
212
|
+
inputs.push({
|
|
213
|
+
txid: vtxo.txid,
|
|
214
|
+
index: vtxo.vout,
|
|
215
|
+
tapLeafScript: [spendingLeaf],
|
|
216
|
+
sequence,
|
|
217
|
+
witnessUtxo: {
|
|
218
|
+
amount: BigInt(vtxo.value),
|
|
219
|
+
script: base_2.VtxoScript.decode(vtxo.tapTree).pkScript,
|
|
220
|
+
},
|
|
221
|
+
sighashType: btc_signer_1.SigHash.DEFAULT,
|
|
222
|
+
});
|
|
223
|
+
txWeightEstimator.addTapscriptInput(64, spendingLeaf[1].length, btc_signer_1.TaprootControlBlock.encode(spendingLeaf[0]).length);
|
|
224
|
+
}
|
|
225
|
+
const tx = new transaction_1.Transaction({ version: 2 });
|
|
226
|
+
for (const input of inputs) {
|
|
227
|
+
tx.addInput(input);
|
|
228
|
+
}
|
|
229
|
+
txWeightEstimator.addOutputAddress(outputAddress, wallet.network);
|
|
230
|
+
let feeRate = await wallet.onchainProvider.getFeeRate();
|
|
231
|
+
if (!feeRate || feeRate < wallet_1.Wallet.MIN_FEE_RATE) {
|
|
232
|
+
feeRate = wallet_1.Wallet.MIN_FEE_RATE;
|
|
233
|
+
}
|
|
234
|
+
const feeAmount = txWeightEstimator.vsize().fee(BigInt(feeRate));
|
|
235
|
+
if (feeAmount > totalAmount) {
|
|
236
|
+
throw new Error("fee amount is greater than the total amount");
|
|
237
|
+
}
|
|
238
|
+
const sendAmount = totalAmount - feeAmount;
|
|
239
|
+
if (sendAmount < BigInt(utils_1.DUST_AMOUNT)) {
|
|
240
|
+
throw new Error("send amount is less than dust amount");
|
|
241
|
+
}
|
|
242
|
+
tx.addOutputAddress(outputAddress, sendAmount, wallet.network);
|
|
243
|
+
const signedTx = await wallet.identity.sign(tx);
|
|
244
|
+
signedTx.finalize();
|
|
245
|
+
return signedTx;
|
|
246
|
+
}
|
|
232
247
|
function sleep(ms) {
|
|
233
248
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
234
249
|
}
|
|
235
|
-
function doUnroll(
|
|
236
|
-
return
|
|
237
|
-
const [parent, child] = await bumper.bumpP2A(tx);
|
|
238
|
-
await onchainProvider.broadcastTransaction(parent, child);
|
|
239
|
-
};
|
|
250
|
+
function doUnroll(onchainProvider, pkg) {
|
|
251
|
+
return () => onchainProvider.broadcastTransaction(...pkg).then(() => undefined);
|
|
240
252
|
}
|
|
241
253
|
function doWait(onchainProvider, txid) {
|
|
242
254
|
return () => {
|
|
@@ -4,6 +4,7 @@ exports.VtxoManager = exports.DEFAULT_SETTLEMENT_CONFIG = exports.DEFAULT_RENEWA
|
|
|
4
4
|
exports.isVtxoExpiringSoon = isVtxoExpiringSoon;
|
|
5
5
|
exports.getExpiringAndRecoverableVtxos = getExpiringAndRecoverableVtxos;
|
|
6
6
|
const _1 = require(".");
|
|
7
|
+
const errors_1 = require("../providers/errors");
|
|
7
8
|
const arkTransaction_1 = require("../utils/arkTransaction");
|
|
8
9
|
const tapscript_1 = require("../script/tapscript");
|
|
9
10
|
const base_1 = require("@scure/base");
|
|
@@ -437,10 +438,22 @@ class VtxoManager {
|
|
|
437
438
|
try {
|
|
438
439
|
// Get all virtual outputs (including recoverable ones)
|
|
439
440
|
// Use default threshold to bypass settlementConfig gate (manual API should always work)
|
|
440
|
-
const
|
|
441
|
+
const threshold = this.settlementConfig !== false &&
|
|
441
442
|
this.settlementConfig?.vtxoThreshold !== undefined
|
|
442
443
|
? this.settlementConfig.vtxoThreshold * 1000
|
|
443
|
-
: exports.DEFAULT_RENEWAL_CONFIG.thresholdMs
|
|
444
|
+
: exports.DEFAULT_RENEWAL_CONFIG.thresholdMs;
|
|
445
|
+
let vtxos = await this.getExpiringVtxos(threshold);
|
|
446
|
+
if (vtxos.length === 0) {
|
|
447
|
+
throw new Error("No VTXOs available to renew");
|
|
448
|
+
}
|
|
449
|
+
// Pre-flight: validate the chosen inputs against the indexer's
|
|
450
|
+
// authoritative state before submitting. The cursor-derived
|
|
451
|
+
// delta sync filters by `created_at`, so a VTXO created
|
|
452
|
+
// before the cursor and spent recently can sit in the local
|
|
453
|
+
// cache forever; settling against it yields a guaranteed
|
|
454
|
+
// VTXO_ALREADY_SPENT 400. Refreshing the candidates here
|
|
455
|
+
// catches that BEFORE the network round-trip.
|
|
456
|
+
vtxos = await this.revalidateBeforeSettle(vtxos, threshold);
|
|
444
457
|
if (vtxos.length === 0) {
|
|
445
458
|
throw new Error("No VTXOs available to renew");
|
|
446
459
|
}
|
|
@@ -689,9 +702,11 @@ class VtxoManager {
|
|
|
689
702
|
if (e.message.includes("VTXO_ALREADY_SPENT")) {
|
|
690
703
|
// Our local VTXO cache is stale vs. the
|
|
691
704
|
// server's authoritative view. Trigger a
|
|
692
|
-
// throttled refresh
|
|
693
|
-
//
|
|
694
|
-
|
|
705
|
+
// throttled, targeted refresh on the
|
|
706
|
+
// offending outpoint (if the server told
|
|
707
|
+
// us which one), then skip — the next
|
|
708
|
+
// cycle will see fresh data.
|
|
709
|
+
void this.maybeRefreshAfterVtxoSpent(this.extractSpentOutpoint(e));
|
|
695
710
|
return;
|
|
696
711
|
}
|
|
697
712
|
}
|
|
@@ -716,13 +731,20 @@ class VtxoManager {
|
|
|
716
731
|
/**
|
|
717
732
|
* VTXO_ALREADY_SPENT means the server's authoritative view of VTXO state
|
|
718
733
|
* is ahead of ours — cross-instance race, pre-lock snapshot drift, or an
|
|
719
|
-
* SSE gap left stale data in the local cache. Silent-swallowing
|
|
720
|
-
* the same error on the next cycle because nothing
|
|
721
|
-
*
|
|
722
|
-
*
|
|
723
|
-
*
|
|
734
|
+
* SSE gap left stale data in the local cache. Silent-swallowing
|
|
735
|
+
* guarantees the same error on the next cycle because nothing
|
|
736
|
+
* reconciles the cache.
|
|
737
|
+
*
|
|
738
|
+
* The cursor-derived delta sync filters by `created_at`, so a VTXO that
|
|
739
|
+
* was created before the cursor but spent recently can never be
|
|
740
|
+
* reconciled by `refreshVtxos()`. Use `refreshOutpoints` for surgical
|
|
741
|
+
* recovery: query the indexer for the specific stale outpoint and
|
|
742
|
+
* upsert its authoritative state into the wallet repository.
|
|
743
|
+
*
|
|
744
|
+
* Throttled because the same VTXO can fire repeatedly before the
|
|
745
|
+
* upsert observably propagates through the renewal selector.
|
|
724
746
|
*/
|
|
725
|
-
maybeRefreshAfterVtxoSpent() {
|
|
747
|
+
maybeRefreshAfterVtxoSpent(spentOutpoint) {
|
|
726
748
|
if (this.vtxoSpentRefreshPromise) {
|
|
727
749
|
return this.vtxoSpentRefreshPromise;
|
|
728
750
|
}
|
|
@@ -735,7 +757,13 @@ class VtxoManager {
|
|
|
735
757
|
this.vtxoSpentRefreshPromise = (async () => {
|
|
736
758
|
try {
|
|
737
759
|
const contractManager = await this.wallet.getContractManager();
|
|
738
|
-
|
|
760
|
+
if (spentOutpoint) {
|
|
761
|
+
await contractManager.refreshOutpoints([spentOutpoint]);
|
|
762
|
+
}
|
|
763
|
+
else {
|
|
764
|
+
// No outpoint metadata — fall back to the broader refresh.
|
|
765
|
+
await contractManager.refreshVtxos();
|
|
766
|
+
}
|
|
739
767
|
}
|
|
740
768
|
catch (e) {
|
|
741
769
|
console.error("Error refreshing VTXOs after VTXO_ALREADY_SPENT:", e);
|
|
@@ -746,6 +774,66 @@ class VtxoManager {
|
|
|
746
774
|
})();
|
|
747
775
|
return this.vtxoSpentRefreshPromise;
|
|
748
776
|
}
|
|
777
|
+
/**
|
|
778
|
+
* Extract the offending VTXO outpoint from a `VTXO_ALREADY_SPENT` error,
|
|
779
|
+
* if the server attached one in `metadata.vtxo_outpoint`. Returns
|
|
780
|
+
* `undefined` when the error isn't a parsed ArkError, isn't this code,
|
|
781
|
+
* or doesn't carry the metadata.
|
|
782
|
+
*/
|
|
783
|
+
extractSpentOutpoint(error) {
|
|
784
|
+
const ark = (0, errors_1.maybeArkError)(error);
|
|
785
|
+
if (!ark || ark.name !== "VTXO_ALREADY_SPENT")
|
|
786
|
+
return undefined;
|
|
787
|
+
const raw = ark.metadata?.vtxo_outpoint;
|
|
788
|
+
if (typeof raw !== "string")
|
|
789
|
+
return undefined;
|
|
790
|
+
const [txid, voutStr] = raw.split(":");
|
|
791
|
+
if (!txid || !voutStr)
|
|
792
|
+
return undefined;
|
|
793
|
+
const vout = Number(voutStr);
|
|
794
|
+
if (!Number.isInteger(vout) || vout < 0)
|
|
795
|
+
return undefined;
|
|
796
|
+
return { txid, vout };
|
|
797
|
+
}
|
|
798
|
+
/**
|
|
799
|
+
* Reconcile the chosen VTXOs with the indexer's authoritative state
|
|
800
|
+
* before submitting a settle intent. Pulls the canonical record for
|
|
801
|
+
* each candidate outpoint via {@link IContractManager.refreshOutpoints}
|
|
802
|
+
* (which upserts the result into the wallet repository), then
|
|
803
|
+
* re-selects through the standard expiring-vtxo filter so anything
|
|
804
|
+
* the refresh flagged as spent is dropped.
|
|
805
|
+
*
|
|
806
|
+
* Best-effort: a failed refresh just falls back to the original
|
|
807
|
+
* candidates and lets the post-submit `VTXO_ALREADY_SPENT` recovery
|
|
808
|
+
* handle whatever slipped through.
|
|
809
|
+
*/
|
|
810
|
+
async revalidateBeforeSettle(candidates, thresholdMs) {
|
|
811
|
+
if (candidates.length === 0)
|
|
812
|
+
return candidates;
|
|
813
|
+
try {
|
|
814
|
+
const cm = await this.wallet.getContractManager();
|
|
815
|
+
await cm.refreshOutpoints(candidates.map((v) => ({ txid: v.txid, vout: v.vout })));
|
|
816
|
+
}
|
|
817
|
+
catch (e) {
|
|
818
|
+
console.error("Error pre-validating VTXOs before settle:", e);
|
|
819
|
+
return candidates;
|
|
820
|
+
}
|
|
821
|
+
// Re-select from the now-fresh local cache. Anything previously
|
|
822
|
+
// selected but spent gets filtered out by the standard
|
|
823
|
+
// `isSpendable`/`isSpent` checks inside getVtxos / getExpiringVtxos.
|
|
824
|
+
try {
|
|
825
|
+
const refreshed = await this.getExpiringVtxos(thresholdMs);
|
|
826
|
+
const candidateKeys = new Set(candidates.map((v) => `${v.txid}:${v.vout}`));
|
|
827
|
+
// Restrict to vtxos that were also in the original candidate set
|
|
828
|
+
// — `getExpiringVtxos` may surface NEW vtxos and we don't want
|
|
829
|
+
// pre-flight to silently expand the input set.
|
|
830
|
+
return refreshed.filter((v) => candidateKeys.has(`${v.txid}:${v.vout}`));
|
|
831
|
+
}
|
|
832
|
+
catch (e) {
|
|
833
|
+
console.error("Error re-selecting VTXOs after pre-validate:", e);
|
|
834
|
+
return candidates;
|
|
835
|
+
}
|
|
836
|
+
}
|
|
749
837
|
/** Computes the next poll delay, applying exponential backoff on failures. */
|
|
750
838
|
getNextPollDelay() {
|
|
751
839
|
if (this.settlementConfig === false)
|
|
@@ -887,6 +975,13 @@ class VtxoManager {
|
|
|
887
975
|
if (!this.renewalInProgress) {
|
|
888
976
|
try {
|
|
889
977
|
expiringVtxos = await this.getExpiringVtxos();
|
|
978
|
+
// Pre-flight validation: see comment in `renewVtxos`. The
|
|
979
|
+
// local cache may carry vtxos that the indexer already
|
|
980
|
+
// marks spent because the cursor-derived delta sync only
|
|
981
|
+
// catches `created_at`-recent updates, not status changes
|
|
982
|
+
// for older VTXOs.
|
|
983
|
+
expiringVtxos =
|
|
984
|
+
await this.revalidateBeforeSettle(expiringVtxos);
|
|
890
985
|
}
|
|
891
986
|
catch (e) {
|
|
892
987
|
// Non-fatal: fall back to boarding-only settle.
|
|
@@ -978,11 +1073,12 @@ class VtxoManager {
|
|
|
978
1073
|
e.message.includes("VTXO_ALREADY_SPENT")) {
|
|
979
1074
|
// Local VTXO cache is stale vs. the server's
|
|
980
1075
|
// authoritative view — not a transient failure.
|
|
981
|
-
// Trigger a throttled refresh
|
|
982
|
-
//
|
|
983
|
-
//
|
|
1076
|
+
// Trigger a throttled, targeted refresh on the
|
|
1077
|
+
// offending outpoint and skip this cycle without
|
|
1078
|
+
// bumping the failure counter, so the next poll
|
|
1079
|
+
// can retry once the cache reconciles.
|
|
984
1080
|
staleCacheSkip = true;
|
|
985
|
-
void this.maybeRefreshAfterVtxoSpent();
|
|
1081
|
+
void this.maybeRefreshAfterVtxoSpent(this.extractSpentOutpoint(e));
|
|
986
1082
|
}
|
|
987
1083
|
else {
|
|
988
1084
|
throw e;
|
|
@@ -37,8 +37,9 @@ const delegator_1 = require("./delegator");
|
|
|
37
37
|
const repositories_1 = require("../repositories");
|
|
38
38
|
const contractManager_1 = require("../contracts/contractManager");
|
|
39
39
|
const handlers_1 = require("../contracts/handlers");
|
|
40
|
-
const
|
|
40
|
+
const timelock_1 = require("../utils/timelock");
|
|
41
41
|
const syncCursors_1 = require("../utils/syncCursors");
|
|
42
|
+
const vtxoOwnership_1 = require("../contracts/vtxoOwnership");
|
|
42
43
|
const getArkadeServerUrl = ({ arkServerUrl, }) => arkServerUrl || _1.DEFAULT_ARKADE_SERVER_URL;
|
|
43
44
|
exports.getArkadeServerUrl = getArkadeServerUrl;
|
|
44
45
|
// Historical unilateral exit delay for mainnet (~7 days in seconds).
|
|
@@ -55,7 +56,7 @@ function dedupeTimelocks(timelocks) {
|
|
|
55
56
|
const seen = new Set();
|
|
56
57
|
const deduped = [];
|
|
57
58
|
for (const timelock of timelocks) {
|
|
58
|
-
const sequence = (0,
|
|
59
|
+
const sequence = (0, timelock_1.timelockToSequence)(timelock).toString();
|
|
59
60
|
if (seen.has(sequence))
|
|
60
61
|
continue;
|
|
61
62
|
seen.add(sequence);
|
|
@@ -648,7 +649,7 @@ class ReadonlyWallet {
|
|
|
648
649
|
watcherConfig: this.watcherConfig,
|
|
649
650
|
});
|
|
650
651
|
for (const csvTimelock of this.walletContractTimelocks) {
|
|
651
|
-
const csvTimelockStr = (0,
|
|
652
|
+
const csvTimelockStr = (0, timelock_1.timelockToSequence)(csvTimelock).toString();
|
|
652
653
|
const defaultScript = new default_1.DefaultVtxo.Script({
|
|
653
654
|
pubKey: this.offchainTapscript.options.pubKey,
|
|
654
655
|
serverPubKey: this.offchainTapscript.options.serverPubKey,
|
|
@@ -1830,7 +1831,7 @@ class Wallet extends ReadonlyWallet {
|
|
|
1830
1831
|
}
|
|
1831
1832
|
}
|
|
1832
1833
|
const createdAt = Date.now();
|
|
1833
|
-
const
|
|
1834
|
+
const primaryAddr = this.arkAddress.encode();
|
|
1834
1835
|
// Only save a change virtual output for preconfirmed coins (those with a batchExpiry).
|
|
1835
1836
|
// Inputs without a batchExpiry are already settled/unrolled and don't need tracking.
|
|
1836
1837
|
let changeVtxo;
|
|
@@ -1857,8 +1858,37 @@ class Wallet extends ReadonlyWallet {
|
|
|
1857
1858
|
script: base_1.hex.encode(this.offchainTapscript.pkScript),
|
|
1858
1859
|
};
|
|
1859
1860
|
}
|
|
1860
|
-
|
|
1861
|
-
|
|
1861
|
+
// Route spent rows to their owning contract bucket. The wallet's
|
|
1862
|
+
// primary contract is registered with the manager at boot, so
|
|
1863
|
+
// `addrByScript` already includes it; in a multi-contract spend
|
|
1864
|
+
// each input may belong to a different contract.
|
|
1865
|
+
const contracts = await cm.getContracts();
|
|
1866
|
+
const addrByScript = new Map(contracts.map((c) => [c.script, c.address]));
|
|
1867
|
+
const spentByScript = new Map();
|
|
1868
|
+
for (const v of spentVtxos) {
|
|
1869
|
+
if (!v.script) {
|
|
1870
|
+
throw new Error(`Wallet.updateDbAfterOffchainTx: spent VTXO ${v.txid}:${v.vout} has no script`);
|
|
1871
|
+
}
|
|
1872
|
+
const arr = spentByScript.get(v.script) ?? [];
|
|
1873
|
+
arr.push(v);
|
|
1874
|
+
spentByScript.set(v.script, arr);
|
|
1875
|
+
}
|
|
1876
|
+
for (const [script, vtxos] of spentByScript) {
|
|
1877
|
+
// User-initiated send path: a wrong-script row here means the
|
|
1878
|
+
// wallet is about to record ownership against the wrong
|
|
1879
|
+
// contract — fail loudly rather than persist inconsistent state.
|
|
1880
|
+
(0, vtxoOwnership_1.validateVtxosForScript)(vtxos, script, "Wallet.updateDbAfterOffchainTx");
|
|
1881
|
+
const targetAddr = addrByScript.get(script);
|
|
1882
|
+
if (!targetAddr) {
|
|
1883
|
+
throw new Error(`Wallet.updateDbAfterOffchainTx: no contract owns script ${script}`);
|
|
1884
|
+
}
|
|
1885
|
+
await (0, vtxoOwnership_1.saveVtxosForContract)(this.walletRepository, { script, address: targetAddr }, vtxos);
|
|
1886
|
+
}
|
|
1887
|
+
// Change is always primary-script by construction.
|
|
1888
|
+
if (changeVtxo) {
|
|
1889
|
+
await (0, vtxoOwnership_1.saveVtxosForContract)(this.walletRepository, { script: changeVtxo.script, address: primaryAddr }, [changeVtxo]);
|
|
1890
|
+
}
|
|
1891
|
+
await this.walletRepository.saveTransactions(primaryAddr, [
|
|
1862
1892
|
{
|
|
1863
1893
|
key: {
|
|
1864
1894
|
boardingTxid: "",
|
|
@@ -1874,12 +1904,12 @@ class Wallet extends ReadonlyWallet {
|
|
|
1874
1904
|
}
|
|
1875
1905
|
catch (e) {
|
|
1876
1906
|
console.warn("error saving offchain tx to repository", e);
|
|
1907
|
+
throw e;
|
|
1877
1908
|
}
|
|
1878
1909
|
}
|
|
1879
1910
|
// mark virtual outputs as spent/settled, remove boarding inputs
|
|
1880
1911
|
async updateDbAfterSettle(inputs, commitmentTxid) {
|
|
1881
1912
|
try {
|
|
1882
|
-
const addr = this.arkAddress.encode();
|
|
1883
1913
|
const boardingAddress = await this.getBoardingAddress();
|
|
1884
1914
|
const spentVtxos = [];
|
|
1885
1915
|
const inputArkTxIds = new Set();
|
|
@@ -1912,7 +1942,32 @@ class Wallet extends ReadonlyWallet {
|
|
|
1912
1942
|
}
|
|
1913
1943
|
}
|
|
1914
1944
|
if (spentVtxos.length > 0) {
|
|
1915
|
-
|
|
1945
|
+
// Route settled rows to their owning contract bucket. In a
|
|
1946
|
+
// multi-contract settle the inputs may belong to several
|
|
1947
|
+
// contracts; the wallet's primary contract is registered with
|
|
1948
|
+
// the manager at boot, so its address is in `addrByScript`
|
|
1949
|
+
// alongside the rest.
|
|
1950
|
+
const contracts = await cm.getContracts();
|
|
1951
|
+
const addrByScript = new Map(contracts.map((c) => [c.script, c.address]));
|
|
1952
|
+
const byScript = new Map();
|
|
1953
|
+
for (const v of spentVtxos) {
|
|
1954
|
+
if (!v.script) {
|
|
1955
|
+
throw new Error(`Wallet.updateDbAfterSettle: spent VTXO ${v.txid}:${v.vout} has no script`);
|
|
1956
|
+
}
|
|
1957
|
+
const arr = byScript.get(v.script) ?? [];
|
|
1958
|
+
arr.push(v);
|
|
1959
|
+
byScript.set(v.script, arr);
|
|
1960
|
+
}
|
|
1961
|
+
for (const [script, vtxos] of byScript) {
|
|
1962
|
+
// User-initiated settle path: refuse to record a settle
|
|
1963
|
+
// against the wrong script.
|
|
1964
|
+
(0, vtxoOwnership_1.validateVtxosForScript)(vtxos, script, "Wallet.updateDbAfterSettle");
|
|
1965
|
+
const targetAddr = addrByScript.get(script);
|
|
1966
|
+
if (!targetAddr) {
|
|
1967
|
+
throw new Error(`Wallet.updateDbAfterSettle: no contract owns script ${script}`);
|
|
1968
|
+
}
|
|
1969
|
+
await (0, vtxoOwnership_1.saveVtxosForContract)(this.walletRepository, { script, address: targetAddr }, vtxos);
|
|
1970
|
+
}
|
|
1916
1971
|
}
|
|
1917
1972
|
if (boardingUtxoToRemove.size > 0) {
|
|
1918
1973
|
const currentUtxos = await this.walletRepository.getUtxos(boardingAddress);
|
|
@@ -1926,6 +1981,7 @@ class Wallet extends ReadonlyWallet {
|
|
|
1926
1981
|
}
|
|
1927
1982
|
catch (e) {
|
|
1928
1983
|
console.warn("error updating repository after settle", e);
|
|
1984
|
+
throw e;
|
|
1929
1985
|
}
|
|
1930
1986
|
}
|
|
1931
1987
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.contractPollProcessor = exports.CONTRACT_POLL_TASK_TYPE = void 0;
|
|
4
|
+
const vtxoOwnership_1 = require("../../../contracts/vtxoOwnership");
|
|
4
5
|
exports.CONTRACT_POLL_TASK_TYPE = "contract-poll";
|
|
5
6
|
/**
|
|
6
7
|
* Polls the indexer for the latest VTXO state of every contract and
|
|
@@ -43,8 +44,12 @@ exports.contractPollProcessor = {
|
|
|
43
44
|
hasMore = page ? vtxos.length === pageSize : false;
|
|
44
45
|
pageIndex++;
|
|
45
46
|
}
|
|
46
|
-
|
|
47
|
-
|
|
47
|
+
// Skip wrong-script rows (legacy duplicates or indexer drift)
|
|
48
|
+
// before persisting; the loop must keep going for the remaining
|
|
49
|
+
// contracts even when one row is rejected.
|
|
50
|
+
const filtered = (0, vtxoOwnership_1.warnAndFilterVtxosForScript)(allVtxos, contract.script, "contractPollProcessor");
|
|
51
|
+
await (0, vtxoOwnership_1.saveVtxosForContract)(walletRepository, contract, filtered);
|
|
52
|
+
vtxosSaved += filtered.length;
|
|
48
53
|
contractsProcessed++;
|
|
49
54
|
}
|
|
50
55
|
return {
|
|
@@ -3,6 +3,7 @@ import { ContractWatcher } from './contractWatcher.js';
|
|
|
3
3
|
import { contractHandlers } from './handlers/index.js';
|
|
4
4
|
import { extendVirtualCoinForContract } from '../wallet/utils.js';
|
|
5
5
|
import { advanceSyncCursor, computeSyncWindow, cursorCutoff, getSyncCursor, } from '../utils/syncCursors.js';
|
|
6
|
+
import { getVtxosForContract, saveVtxosForContract, warnAndFilterVtxosForScript, } from './vtxoOwnership.js';
|
|
6
7
|
const DEFAULT_PAGE_SIZE = 500;
|
|
7
8
|
/**
|
|
8
9
|
* Central manager for contract lifecycle and operations.
|
|
@@ -354,11 +355,61 @@ export class ContractManager {
|
|
|
354
355
|
const contracts = opts?.scripts
|
|
355
356
|
? await this.getContracts({ script: opts.scripts })
|
|
356
357
|
: undefined;
|
|
358
|
+
// Only forward an explicit window when the caller supplied one. An
|
|
359
|
+
// empty `{ after: undefined, before: undefined }` would short-circuit
|
|
360
|
+
// both the cursor-derived `?after=` query in `syncContracts` (because
|
|
361
|
+
// `??` doesn't fire on a non-nullish object) AND the cursor-advance
|
|
362
|
+
// gate (which requires `options.window === undefined`), turning every
|
|
363
|
+
// `refreshVtxos()` call into an unbounded full re-scan whose cursor
|
|
364
|
+
// never moves forward.
|
|
365
|
+
const hasExplicitWindow = opts?.after !== undefined || opts?.before !== undefined;
|
|
357
366
|
await this.syncContracts({
|
|
358
367
|
contracts,
|
|
359
|
-
window:
|
|
368
|
+
window: hasExplicitWindow
|
|
369
|
+
? { after: opts?.after, before: opts?.before }
|
|
370
|
+
: undefined,
|
|
360
371
|
});
|
|
361
372
|
}
|
|
373
|
+
async refreshOutpoints(outpoints) {
|
|
374
|
+
if (outpoints.length === 0)
|
|
375
|
+
return;
|
|
376
|
+
const { vtxos } = await this.config.indexerProvider.getVtxos({
|
|
377
|
+
outpoints,
|
|
378
|
+
});
|
|
379
|
+
if (vtxos.length === 0)
|
|
380
|
+
return;
|
|
381
|
+
// Filter to outputs whose script we own. Map them to their owning
|
|
382
|
+
// contract so we can write through to the right per-address entry
|
|
383
|
+
// in the wallet repository.
|
|
384
|
+
const scripts = Array.from(new Set(vtxos.map((v) => v.script)));
|
|
385
|
+
const contracts = await this.config.contractRepository.getContracts({
|
|
386
|
+
script: scripts,
|
|
387
|
+
});
|
|
388
|
+
const scriptToContract = new Map(contracts.map((c) => [c.script, c]));
|
|
389
|
+
const owned = vtxos.filter((v) => scriptToContract.has(v.script));
|
|
390
|
+
if (owned.length === 0)
|
|
391
|
+
return;
|
|
392
|
+
const annotated = await this.annotateVtxos(owned);
|
|
393
|
+
const byAddress = new Map();
|
|
394
|
+
for (const vtxo of annotated) {
|
|
395
|
+
const contract = scriptToContract.get(vtxo.script);
|
|
396
|
+
if (!contract)
|
|
397
|
+
continue;
|
|
398
|
+
const address = contract.address;
|
|
399
|
+
const arr = byAddress.get(address) ?? [];
|
|
400
|
+
arr.push(vtxo);
|
|
401
|
+
byAddress.set(address, arr);
|
|
402
|
+
}
|
|
403
|
+
for (const [address, addressVtxos] of byAddress) {
|
|
404
|
+
const contract = contracts.find((c) => c.address === address);
|
|
405
|
+
if (contract) {
|
|
406
|
+
await saveVtxosForContract(this.config.walletRepository, contract, addressVtxos);
|
|
407
|
+
}
|
|
408
|
+
else {
|
|
409
|
+
await this.config.walletRepository.saveVtxos(address, addressVtxos);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
362
413
|
/**
|
|
363
414
|
* Check if currently watching.
|
|
364
415
|
*/
|
|
@@ -399,9 +450,9 @@ export class ContractManager {
|
|
|
399
450
|
this.emitEvent(event);
|
|
400
451
|
}
|
|
401
452
|
async getVtxosForContracts(contracts) {
|
|
402
|
-
const res = await Promise.all(contracts.map((
|
|
453
|
+
const res = await Promise.all(contracts.map((contract) => getVtxosForContract(this.config.walletRepository, contract).then((vtxos) => vtxos.map((vtxo) => ({
|
|
403
454
|
...vtxo,
|
|
404
|
-
contractScript: script,
|
|
455
|
+
contractScript: contract.script,
|
|
405
456
|
})))));
|
|
406
457
|
return res.flat();
|
|
407
458
|
}
|
|
@@ -467,7 +518,14 @@ export class ContractManager {
|
|
|
467
518
|
});
|
|
468
519
|
}
|
|
469
520
|
for (const [addr, contractVtxos] of byContract) {
|
|
470
|
-
|
|
521
|
+
// The bucket is keyed by contract address, so the script filter
|
|
522
|
+
// here is the same as the contract's. Skip wrong-script rows
|
|
523
|
+
// rather than crash the reconcile loop.
|
|
524
|
+
const contract = contracts.find((c) => c.address === addr);
|
|
525
|
+
const filtered = warnAndFilterVtxosForScript(contractVtxos, contract.script, "ContractManager.reconcilePendingFrontier");
|
|
526
|
+
if (filtered.length === 0)
|
|
527
|
+
continue;
|
|
528
|
+
await saveVtxosForContract(this.config.walletRepository, contract, filtered);
|
|
471
529
|
}
|
|
472
530
|
}
|
|
473
531
|
async fetchContractVxosFromIndexer(contracts, pageSize, syncWindow) {
|
|
@@ -477,7 +535,10 @@ export class ContractManager {
|
|
|
477
535
|
result.set(contractScript, vtxos);
|
|
478
536
|
const contract = contracts.find((c) => c.script === contractScript);
|
|
479
537
|
if (contract) {
|
|
480
|
-
|
|
538
|
+
const filtered = warnAndFilterVtxosForScript(vtxos, contract.script, "ContractManager.fetchContractVxosFromIndexer");
|
|
539
|
+
if (filtered.length === 0)
|
|
540
|
+
continue;
|
|
541
|
+
await saveVtxosForContract(this.config.walletRepository, contract, filtered);
|
|
481
542
|
}
|
|
482
543
|
}
|
|
483
544
|
return result;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { extendVirtualCoinForContract } from '../wallet/utils.js';
|
|
2
2
|
import { isEventSourceError } from '../providers/utils.js';
|
|
3
|
+
import { getVtxosForContract } from './vtxoOwnership.js';
|
|
3
4
|
/**
|
|
4
5
|
* Watches multiple contracts for virtual output state changes with resilient connection handling.
|
|
5
6
|
*
|
|
@@ -87,7 +88,10 @@ export class ContractWatcher {
|
|
|
87
88
|
*/
|
|
88
89
|
async seedLastKnownVtxos(state) {
|
|
89
90
|
try {
|
|
90
|
-
|
|
91
|
+
// Apply the same script gate used by getContractVtxos so a legacy
|
|
92
|
+
// wrong-script row in the address bucket can't seed the baseline
|
|
93
|
+
// and then look "spent" on the first poll.
|
|
94
|
+
const cached = await getVtxosForContract(this.config.walletRepository, state.contract);
|
|
91
95
|
for (const vtxo of cached) {
|
|
92
96
|
if (vtxo.isSpent)
|
|
93
97
|
continue;
|
|
@@ -166,8 +170,10 @@ export class ContractWatcher {
|
|
|
166
170
|
return true;
|
|
167
171
|
})
|
|
168
172
|
.map(async (state) => {
|
|
169
|
-
// Use contract address as cache key
|
|
170
|
-
|
|
173
|
+
// Use contract address as cache key. Legacy address buckets
|
|
174
|
+
// can contain rows from other contracts; gate by script before
|
|
175
|
+
// converting so a wrong-script row never reaches the watcher.
|
|
176
|
+
const cached = await getVtxosForContract(repo, state.contract);
|
|
171
177
|
if (cached.length > 0) {
|
|
172
178
|
// Convert to ContractVtxo with contractScript
|
|
173
179
|
const contractVtxos = cached.map((v) => ({
|