@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
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import * as bip68 from "bip68";
|
|
2
|
+
/**
|
|
3
|
+
* Convert RelativeTimelock to BIP68 sequence number.
|
|
4
|
+
*/
|
|
5
|
+
export function timelockToSequence(timelock) {
|
|
6
|
+
return bip68.encode(timelock.type === "blocks"
|
|
7
|
+
? { blocks: Number(timelock.value) }
|
|
8
|
+
: { seconds: Number(timelock.value) });
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Convert BIP68 sequence number back to RelativeTimelock.
|
|
12
|
+
*/
|
|
13
|
+
export function sequenceToTimelock(sequence) {
|
|
14
|
+
const decoded = bip68.decode(sequence);
|
|
15
|
+
if ("blocks" in decoded && decoded.blocks !== undefined) {
|
|
16
|
+
return { type: "blocks", value: BigInt(decoded.blocks) };
|
|
17
|
+
}
|
|
18
|
+
if ("seconds" in decoded && decoded.seconds !== undefined) {
|
|
19
|
+
return { type: "seconds", value: BigInt(decoded.seconds) };
|
|
20
|
+
}
|
|
21
|
+
throw new Error(`Invalid BIP68 sequence: ${sequence}`);
|
|
22
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import * as bip68 from "bip68";
|
|
2
1
|
import { RawWitness, ScriptNum } from "@scure/btc-signer";
|
|
3
2
|
import { hex } from "@scure/base";
|
|
3
|
+
import { sequenceToTimelock } from './timelock.js';
|
|
4
4
|
/**
|
|
5
5
|
* ArkPsbtFieldKey are the available key names for the Arkade PSBT custom fields.
|
|
6
6
|
*/
|
|
@@ -146,11 +146,7 @@ export const VtxoTreeExpiry = {
|
|
|
146
146
|
const v = ScriptNum(6, true).decode(unknown[1]);
|
|
147
147
|
if (!v)
|
|
148
148
|
return null;
|
|
149
|
-
|
|
150
|
-
return {
|
|
151
|
-
type: blocks ? "blocks" : "seconds",
|
|
152
|
-
value: BigInt(blocks ?? seconds ?? 0),
|
|
153
|
-
};
|
|
149
|
+
return sequenceToTimelock(Number(v));
|
|
154
150
|
}),
|
|
155
151
|
};
|
|
156
152
|
const encodedPsbtFieldKey = Object.fromEntries(Object.values(ArkPsbtFieldKey).map((key) => [
|
|
@@ -2,6 +2,8 @@ import { RestIndexerProvider } from '../../providers/indexer.js';
|
|
|
2
2
|
import { isExpired, isRecoverable, isSpendable, isSubdust, } from '../index.js';
|
|
3
3
|
import { extendCoin } from '../utils.js';
|
|
4
4
|
import { buildTransactionHistory } from '../../utils/transactionHistory.js';
|
|
5
|
+
import { filterVtxosForScript, getVtxosForContract, saveVtxosForContract, warnAndFilterVtxosForScript, } from '../../contracts/vtxoOwnership.js';
|
|
6
|
+
import { scriptFromArkAddress } from '../../repositories/scriptFromAddress.js';
|
|
5
7
|
export class WalletNotInitializedError extends Error {
|
|
6
8
|
constructor() {
|
|
7
9
|
super("Wallet handler not initialized");
|
|
@@ -311,6 +313,16 @@ export class WalletMessageHandler {
|
|
|
311
313
|
type: "REFRESH_VTXOS_SUCCESS",
|
|
312
314
|
});
|
|
313
315
|
}
|
|
316
|
+
case "REFRESH_OUTPOINTS": {
|
|
317
|
+
const manager = await this.readonlyWallet.getContractManager();
|
|
318
|
+
const { outpoints } = message
|
|
319
|
+
.payload;
|
|
320
|
+
await manager.refreshOutpoints(outpoints);
|
|
321
|
+
return this.tagged({
|
|
322
|
+
id,
|
|
323
|
+
type: "REFRESH_OUTPOINTS_SUCCESS",
|
|
324
|
+
});
|
|
325
|
+
}
|
|
314
326
|
case "SEND": {
|
|
315
327
|
const { recipients } = message.payload;
|
|
316
328
|
const txid = await this.wallet.send(...recipients);
|
|
@@ -581,11 +593,47 @@ export class WalletMessageHandler {
|
|
|
581
593
|
const { newVtxos, spentVtxos } = funds;
|
|
582
594
|
if (newVtxos.length + spentVtxos.length === 0)
|
|
583
595
|
return;
|
|
584
|
-
//
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
596
|
+
// Save virtual outputs using unified repository. The
|
|
597
|
+
// event may carry rows for several scripts (other
|
|
598
|
+
// contracts the wallet watches), so split by script and
|
|
599
|
+
// save each bucket under its own contract address rather
|
|
600
|
+
// than saving a mixed-script array under one address.
|
|
601
|
+
const byScript = new Map();
|
|
602
|
+
for (const v of [...newVtxos, ...spentVtxos]) {
|
|
603
|
+
if (!v.script) {
|
|
604
|
+
// Without a script we can't route the row to the
|
|
605
|
+
// right contract bucket; surface the drop instead
|
|
606
|
+
// of silently losing the VTXO.
|
|
607
|
+
console.warn(`WalletMessageHandler.notifyIncomingFunds: dropping VTXO without script ${v.txid}:${v.vout}`);
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
const arr = byScript.get(v.script) ?? [];
|
|
611
|
+
arr.push(v);
|
|
612
|
+
byScript.set(v.script, arr);
|
|
613
|
+
}
|
|
614
|
+
let walletScript;
|
|
615
|
+
try {
|
|
616
|
+
walletScript = scriptFromArkAddress(address);
|
|
617
|
+
}
|
|
618
|
+
catch {
|
|
619
|
+
walletScript = undefined;
|
|
620
|
+
}
|
|
621
|
+
const cm = await this.readonlyWallet.getContractManager();
|
|
622
|
+
const contracts = await cm.getContracts();
|
|
623
|
+
const addrByScript = new Map(contracts.map((c) => [c.script, c.address]));
|
|
624
|
+
for (const [script, vtxos] of byScript) {
|
|
625
|
+
const filtered = warnAndFilterVtxosForScript(vtxos, script, "WalletMessageHandler.notifyIncomingFunds");
|
|
626
|
+
if (filtered.length === 0)
|
|
627
|
+
continue;
|
|
628
|
+
const targetAddress = script === walletScript
|
|
629
|
+
? address
|
|
630
|
+
: addrByScript.get(script);
|
|
631
|
+
if (!targetAddress)
|
|
632
|
+
continue;
|
|
633
|
+
if (this.walletRepository) {
|
|
634
|
+
await saveVtxosForContract(this.walletRepository, { script, address: targetAddress }, filtered);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
589
637
|
// notify all clients about the virtual output state update
|
|
590
638
|
this.scheduleForNextTick(() => this.tagged({
|
|
591
639
|
type: "VTXO_UPDATE",
|
|
@@ -797,17 +845,30 @@ export class WalletMessageHandler {
|
|
|
797
845
|
}
|
|
798
846
|
}
|
|
799
847
|
};
|
|
800
|
-
// Aggregate virtual outputs from all contract addresses
|
|
848
|
+
// Aggregate virtual outputs from all contract addresses. Address
|
|
849
|
+
// buckets may carry legacy duplicate rows from other contracts; gate
|
|
850
|
+
// each bucket by its owning contract script before deduplication so a
|
|
851
|
+
// wrong-script row never wins the txid:vout race.
|
|
801
852
|
const manager = await this.readonlyWallet.getContractManager();
|
|
802
853
|
const contracts = await manager.getContracts();
|
|
803
854
|
for (const contract of contracts) {
|
|
804
|
-
|
|
805
|
-
addVtxos(vtxos);
|
|
855
|
+
addVtxos(await getVtxosForContract(this.walletRepository, contract));
|
|
806
856
|
}
|
|
807
|
-
// Also check the wallet's primary address
|
|
857
|
+
// Also check the wallet's primary address. Decode it to its script
|
|
858
|
+
// and apply the same script gate. Failing to decode the wallet's own
|
|
859
|
+
// address is a structural bug — surfacing the error is safer than
|
|
860
|
+
// silently dropping the primary bucket and zeroing the user's
|
|
861
|
+
// visible balance.
|
|
808
862
|
const walletAddress = await this.readonlyWallet.getAddress();
|
|
863
|
+
let walletScript;
|
|
864
|
+
try {
|
|
865
|
+
walletScript = scriptFromArkAddress(walletAddress);
|
|
866
|
+
}
|
|
867
|
+
catch (e) {
|
|
868
|
+
throw new Error(`WalletMessageHandler.getVtxosFromRepo: failed to derive script from wallet address ${walletAddress}: ${e instanceof Error ? e.message : String(e)}`);
|
|
869
|
+
}
|
|
809
870
|
const walletVtxos = await this.walletRepository.getVtxos(walletAddress);
|
|
810
|
-
addVtxos(walletVtxos);
|
|
871
|
+
addVtxos(filterVtxosForScript(walletVtxos, walletScript));
|
|
811
872
|
return allVtxos;
|
|
812
873
|
}
|
|
813
874
|
/**
|
|
@@ -57,6 +57,7 @@ export const DEFAULT_MESSAGE_TIMEOUTS = {
|
|
|
57
57
|
UPDATE_CONTRACT: 30000,
|
|
58
58
|
DELETE_CONTRACT: 10000,
|
|
59
59
|
REFRESH_VTXOS: 30000,
|
|
60
|
+
REFRESH_OUTPOINTS: 30000,
|
|
60
61
|
};
|
|
61
62
|
const DEDUPABLE_REQUEST_TYPES = new Set([
|
|
62
63
|
"GET_ADDRESS",
|
|
@@ -821,6 +822,15 @@ export class ServiceWorkerReadonlyWallet {
|
|
|
821
822
|
};
|
|
822
823
|
await sendContractMessage(message);
|
|
823
824
|
},
|
|
825
|
+
async refreshOutpoints(outpoints) {
|
|
826
|
+
const message = {
|
|
827
|
+
type: "REFRESH_OUTPOINTS",
|
|
828
|
+
id: getRandomId(),
|
|
829
|
+
tag: messageTag,
|
|
830
|
+
payload: { outpoints },
|
|
831
|
+
};
|
|
832
|
+
await sendContractMessage(message);
|
|
833
|
+
},
|
|
824
834
|
async isWatching() {
|
|
825
835
|
const message = {
|
|
826
836
|
type: "IS_CONTRACT_MANAGER_WATCHING",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { base64, hex } from "@scure/base";
|
|
2
2
|
import { SigHash, TaprootControlBlock } from "@scure/btc-signer";
|
|
3
|
-
import { timelockToSequence } from '../
|
|
3
|
+
import { timelockToSequence } from '../utils/timelock.js';
|
|
4
4
|
import { ChainTxType } from '../providers/indexer.js';
|
|
5
5
|
import { VtxoScript } from '../script/base.js';
|
|
6
6
|
import { TxWeightEstimator } from '../utils/txSizeEstimator.js';
|
|
@@ -126,10 +126,12 @@ export var Unroll;
|
|
|
126
126
|
// finalize Arkade transaction
|
|
127
127
|
tx.finalize();
|
|
128
128
|
}
|
|
129
|
+
const pkg = await this.bumper.bumpP2A(tx);
|
|
129
130
|
return {
|
|
130
131
|
type: StepType.UNROLL,
|
|
131
132
|
tx,
|
|
132
|
-
|
|
133
|
+
pkg,
|
|
134
|
+
do: doUnroll(this.explorer, pkg),
|
|
133
135
|
};
|
|
134
136
|
}
|
|
135
137
|
/**
|
|
@@ -161,79 +163,88 @@ export var Unroll;
|
|
|
161
163
|
* @returns the txid of the transaction spending the unrolled funds
|
|
162
164
|
*/
|
|
163
165
|
async function completeUnroll(wallet, vtxoTxids, outputAddress) {
|
|
164
|
-
const
|
|
165
|
-
let vtxos = await wallet.getVtxos({ withUnrolled: true });
|
|
166
|
-
vtxos = vtxos.filter((vtxo) => vtxoTxids.includes(vtxo.txid));
|
|
167
|
-
if (vtxos.length === 0) {
|
|
168
|
-
throw new Error("No vtxos to complete unroll");
|
|
169
|
-
}
|
|
170
|
-
const inputs = [];
|
|
171
|
-
let totalAmount = 0n;
|
|
172
|
-
const txWeightEstimator = TxWeightEstimator.create();
|
|
173
|
-
for (const vtxo of vtxos) {
|
|
174
|
-
if (!vtxo.isUnrolled) {
|
|
175
|
-
throw new Error(`Vtxo ${vtxo.txid}:${vtxo.vout} is not fully unrolled, use unroll first`);
|
|
176
|
-
}
|
|
177
|
-
const txStatus = await wallet.onchainProvider.getTxStatus(vtxo.txid);
|
|
178
|
-
if (!txStatus.confirmed) {
|
|
179
|
-
throw new Error(`tx ${vtxo.txid} is not confirmed`);
|
|
180
|
-
}
|
|
181
|
-
const exit = availableExitPath({ height: txStatus.blockHeight, time: txStatus.blockTime }, chainTip, vtxo);
|
|
182
|
-
if (!exit) {
|
|
183
|
-
throw new Error(`no available exit path found for vtxo ${vtxo.txid}:${vtxo.vout}`);
|
|
184
|
-
}
|
|
185
|
-
const spendingLeaf = VtxoScript.decode(vtxo.tapTree).findLeaf(hex.encode(exit.script));
|
|
186
|
-
if (!spendingLeaf) {
|
|
187
|
-
throw new Error(`spending leaf not found for vtxo ${vtxo.txid}:${vtxo.vout}`);
|
|
188
|
-
}
|
|
189
|
-
totalAmount += BigInt(vtxo.value);
|
|
190
|
-
const sequence = timelockToSequence(exit.params.timelock);
|
|
191
|
-
inputs.push({
|
|
192
|
-
txid: vtxo.txid,
|
|
193
|
-
index: vtxo.vout,
|
|
194
|
-
tapLeafScript: [spendingLeaf],
|
|
195
|
-
sequence,
|
|
196
|
-
witnessUtxo: {
|
|
197
|
-
amount: BigInt(vtxo.value),
|
|
198
|
-
script: VtxoScript.decode(vtxo.tapTree).pkScript,
|
|
199
|
-
},
|
|
200
|
-
sighashType: SigHash.DEFAULT,
|
|
201
|
-
});
|
|
202
|
-
txWeightEstimator.addTapscriptInput(64, spendingLeaf[1].length, TaprootControlBlock.encode(spendingLeaf[0]).length);
|
|
203
|
-
}
|
|
204
|
-
const tx = new Transaction({ version: 2 });
|
|
205
|
-
for (const input of inputs) {
|
|
206
|
-
tx.addInput(input);
|
|
207
|
-
}
|
|
208
|
-
txWeightEstimator.addOutputAddress(outputAddress, wallet.network);
|
|
209
|
-
let feeRate = await wallet.onchainProvider.getFeeRate();
|
|
210
|
-
if (!feeRate || feeRate < Wallet.MIN_FEE_RATE) {
|
|
211
|
-
feeRate = Wallet.MIN_FEE_RATE;
|
|
212
|
-
}
|
|
213
|
-
const feeAmount = txWeightEstimator.vsize().fee(BigInt(feeRate));
|
|
214
|
-
if (feeAmount > totalAmount) {
|
|
215
|
-
throw new Error("fee amount is greater than the total amount");
|
|
216
|
-
}
|
|
217
|
-
const sendAmount = totalAmount - feeAmount;
|
|
218
|
-
if (sendAmount < BigInt(DUST_AMOUNT)) {
|
|
219
|
-
throw new Error("send amount is less than dust amount");
|
|
220
|
-
}
|
|
221
|
-
tx.addOutputAddress(outputAddress, sendAmount);
|
|
222
|
-
const signedTx = await wallet.identity.sign(tx);
|
|
223
|
-
signedTx.finalize();
|
|
166
|
+
const signedTx = await prepareUnrollTransaction(wallet, vtxoTxids, outputAddress);
|
|
224
167
|
await wallet.onchainProvider.broadcastTransaction(signedTx.hex);
|
|
225
168
|
return signedTx.id;
|
|
226
169
|
}
|
|
227
170
|
Unroll.completeUnroll = completeUnroll;
|
|
228
171
|
})(Unroll || (Unroll = {}));
|
|
172
|
+
/**
|
|
173
|
+
* Prepares the transaction that spends the CSV path to complete unrolling a VTXO.
|
|
174
|
+
* @param wallet the wallet owning the VTXO(s)
|
|
175
|
+
* @param vtxoTxIds the txids of the VTXO(s) to complete unroll
|
|
176
|
+
* @param outputAddress the address to send the unrolled funds to
|
|
177
|
+
* @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
|
|
178
|
+
* @returns the transaction spending the unrolled funds
|
|
179
|
+
*/
|
|
180
|
+
export async function prepareUnrollTransaction(wallet, vtxoTxIds, outputAddress) {
|
|
181
|
+
const chainTip = await wallet.onchainProvider.getChainTip();
|
|
182
|
+
let vtxos = await wallet.getVtxos({ withUnrolled: true });
|
|
183
|
+
vtxos = vtxos.filter((vtxo) => vtxoTxIds.includes(vtxo.txid));
|
|
184
|
+
if (vtxos.length === 0) {
|
|
185
|
+
throw new Error("No vtxos to complete unroll");
|
|
186
|
+
}
|
|
187
|
+
const inputs = [];
|
|
188
|
+
let totalAmount = 0n;
|
|
189
|
+
const txWeightEstimator = TxWeightEstimator.create();
|
|
190
|
+
for (const vtxo of vtxos) {
|
|
191
|
+
if (!vtxo.isUnrolled) {
|
|
192
|
+
throw new Error(`Vtxo ${vtxo.txid}:${vtxo.vout} is not fully unrolled, use unroll first`);
|
|
193
|
+
}
|
|
194
|
+
const txStatus = await wallet.onchainProvider.getTxStatus(vtxo.txid);
|
|
195
|
+
if (!txStatus.confirmed) {
|
|
196
|
+
throw new Error(`tx ${vtxo.txid} is not confirmed`);
|
|
197
|
+
}
|
|
198
|
+
const exit = availableExitPath({ height: txStatus.blockHeight, time: txStatus.blockTime }, chainTip, vtxo);
|
|
199
|
+
if (!exit) {
|
|
200
|
+
throw new Error(`no available exit path found for vtxo ${vtxo.txid}:${vtxo.vout}`);
|
|
201
|
+
}
|
|
202
|
+
const spendingLeaf = VtxoScript.decode(vtxo.tapTree).findLeaf(hex.encode(exit.script));
|
|
203
|
+
if (!spendingLeaf) {
|
|
204
|
+
throw new Error(`spending leaf not found for vtxo ${vtxo.txid}:${vtxo.vout}`);
|
|
205
|
+
}
|
|
206
|
+
totalAmount += BigInt(vtxo.value);
|
|
207
|
+
const sequence = timelockToSequence(exit.params.timelock);
|
|
208
|
+
inputs.push({
|
|
209
|
+
txid: vtxo.txid,
|
|
210
|
+
index: vtxo.vout,
|
|
211
|
+
tapLeafScript: [spendingLeaf],
|
|
212
|
+
sequence,
|
|
213
|
+
witnessUtxo: {
|
|
214
|
+
amount: BigInt(vtxo.value),
|
|
215
|
+
script: VtxoScript.decode(vtxo.tapTree).pkScript,
|
|
216
|
+
},
|
|
217
|
+
sighashType: SigHash.DEFAULT,
|
|
218
|
+
});
|
|
219
|
+
txWeightEstimator.addTapscriptInput(64, spendingLeaf[1].length, TaprootControlBlock.encode(spendingLeaf[0]).length);
|
|
220
|
+
}
|
|
221
|
+
const tx = new Transaction({ version: 2 });
|
|
222
|
+
for (const input of inputs) {
|
|
223
|
+
tx.addInput(input);
|
|
224
|
+
}
|
|
225
|
+
txWeightEstimator.addOutputAddress(outputAddress, wallet.network);
|
|
226
|
+
let feeRate = await wallet.onchainProvider.getFeeRate();
|
|
227
|
+
if (!feeRate || feeRate < Wallet.MIN_FEE_RATE) {
|
|
228
|
+
feeRate = Wallet.MIN_FEE_RATE;
|
|
229
|
+
}
|
|
230
|
+
const feeAmount = txWeightEstimator.vsize().fee(BigInt(feeRate));
|
|
231
|
+
if (feeAmount > totalAmount) {
|
|
232
|
+
throw new Error("fee amount is greater than the total amount");
|
|
233
|
+
}
|
|
234
|
+
const sendAmount = totalAmount - feeAmount;
|
|
235
|
+
if (sendAmount < BigInt(DUST_AMOUNT)) {
|
|
236
|
+
throw new Error("send amount is less than dust amount");
|
|
237
|
+
}
|
|
238
|
+
tx.addOutputAddress(outputAddress, sendAmount, wallet.network);
|
|
239
|
+
const signedTx = await wallet.identity.sign(tx);
|
|
240
|
+
signedTx.finalize();
|
|
241
|
+
return signedTx;
|
|
242
|
+
}
|
|
229
243
|
function sleep(ms) {
|
|
230
244
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
231
245
|
}
|
|
232
|
-
function doUnroll(
|
|
233
|
-
return
|
|
234
|
-
const [parent, child] = await bumper.bumpP2A(tx);
|
|
235
|
-
await onchainProvider.broadcastTransaction(parent, child);
|
|
236
|
-
};
|
|
246
|
+
function doUnroll(onchainProvider, pkg) {
|
|
247
|
+
return () => onchainProvider.broadcastTransaction(...pkg).then(() => undefined);
|
|
237
248
|
}
|
|
238
249
|
function doWait(onchainProvider, txid) {
|
|
239
250
|
return () => {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { isExpired, isRecoverable, isSpendable, isSubdust, } from './index.js';
|
|
2
|
+
import { maybeArkError } from '../providers/errors.js';
|
|
2
3
|
import { hasBoardingTxExpired } from '../utils/arkTransaction.js';
|
|
3
4
|
import { CSVMultisigTapscript } from '../script/tapscript.js';
|
|
4
5
|
import { hex } from "@scure/base";
|
|
@@ -432,10 +433,22 @@ export class VtxoManager {
|
|
|
432
433
|
try {
|
|
433
434
|
// Get all virtual outputs (including recoverable ones)
|
|
434
435
|
// Use default threshold to bypass settlementConfig gate (manual API should always work)
|
|
435
|
-
const
|
|
436
|
+
const threshold = this.settlementConfig !== false &&
|
|
436
437
|
this.settlementConfig?.vtxoThreshold !== undefined
|
|
437
438
|
? this.settlementConfig.vtxoThreshold * 1000
|
|
438
|
-
: DEFAULT_RENEWAL_CONFIG.thresholdMs
|
|
439
|
+
: DEFAULT_RENEWAL_CONFIG.thresholdMs;
|
|
440
|
+
let vtxos = await this.getExpiringVtxos(threshold);
|
|
441
|
+
if (vtxos.length === 0) {
|
|
442
|
+
throw new Error("No VTXOs available to renew");
|
|
443
|
+
}
|
|
444
|
+
// Pre-flight: validate the chosen inputs against the indexer's
|
|
445
|
+
// authoritative state before submitting. The cursor-derived
|
|
446
|
+
// delta sync filters by `created_at`, so a VTXO created
|
|
447
|
+
// before the cursor and spent recently can sit in the local
|
|
448
|
+
// cache forever; settling against it yields a guaranteed
|
|
449
|
+
// VTXO_ALREADY_SPENT 400. Refreshing the candidates here
|
|
450
|
+
// catches that BEFORE the network round-trip.
|
|
451
|
+
vtxos = await this.revalidateBeforeSettle(vtxos, threshold);
|
|
439
452
|
if (vtxos.length === 0) {
|
|
440
453
|
throw new Error("No VTXOs available to renew");
|
|
441
454
|
}
|
|
@@ -684,9 +697,11 @@ export class VtxoManager {
|
|
|
684
697
|
if (e.message.includes("VTXO_ALREADY_SPENT")) {
|
|
685
698
|
// Our local VTXO cache is stale vs. the
|
|
686
699
|
// server's authoritative view. Trigger a
|
|
687
|
-
// throttled refresh
|
|
688
|
-
//
|
|
689
|
-
|
|
700
|
+
// throttled, targeted refresh on the
|
|
701
|
+
// offending outpoint (if the server told
|
|
702
|
+
// us which one), then skip — the next
|
|
703
|
+
// cycle will see fresh data.
|
|
704
|
+
void this.maybeRefreshAfterVtxoSpent(this.extractSpentOutpoint(e));
|
|
690
705
|
return;
|
|
691
706
|
}
|
|
692
707
|
}
|
|
@@ -711,13 +726,20 @@ export class VtxoManager {
|
|
|
711
726
|
/**
|
|
712
727
|
* VTXO_ALREADY_SPENT means the server's authoritative view of VTXO state
|
|
713
728
|
* is ahead of ours — cross-instance race, pre-lock snapshot drift, or an
|
|
714
|
-
* SSE gap left stale data in the local cache. Silent-swallowing
|
|
715
|
-
* the same error on the next cycle because nothing
|
|
716
|
-
*
|
|
717
|
-
*
|
|
718
|
-
*
|
|
729
|
+
* SSE gap left stale data in the local cache. Silent-swallowing
|
|
730
|
+
* guarantees the same error on the next cycle because nothing
|
|
731
|
+
* reconciles the cache.
|
|
732
|
+
*
|
|
733
|
+
* The cursor-derived delta sync filters by `created_at`, so a VTXO that
|
|
734
|
+
* was created before the cursor but spent recently can never be
|
|
735
|
+
* reconciled by `refreshVtxos()`. Use `refreshOutpoints` for surgical
|
|
736
|
+
* recovery: query the indexer for the specific stale outpoint and
|
|
737
|
+
* upsert its authoritative state into the wallet repository.
|
|
738
|
+
*
|
|
739
|
+
* Throttled because the same VTXO can fire repeatedly before the
|
|
740
|
+
* upsert observably propagates through the renewal selector.
|
|
719
741
|
*/
|
|
720
|
-
maybeRefreshAfterVtxoSpent() {
|
|
742
|
+
maybeRefreshAfterVtxoSpent(spentOutpoint) {
|
|
721
743
|
if (this.vtxoSpentRefreshPromise) {
|
|
722
744
|
return this.vtxoSpentRefreshPromise;
|
|
723
745
|
}
|
|
@@ -730,7 +752,13 @@ export class VtxoManager {
|
|
|
730
752
|
this.vtxoSpentRefreshPromise = (async () => {
|
|
731
753
|
try {
|
|
732
754
|
const contractManager = await this.wallet.getContractManager();
|
|
733
|
-
|
|
755
|
+
if (spentOutpoint) {
|
|
756
|
+
await contractManager.refreshOutpoints([spentOutpoint]);
|
|
757
|
+
}
|
|
758
|
+
else {
|
|
759
|
+
// No outpoint metadata — fall back to the broader refresh.
|
|
760
|
+
await contractManager.refreshVtxos();
|
|
761
|
+
}
|
|
734
762
|
}
|
|
735
763
|
catch (e) {
|
|
736
764
|
console.error("Error refreshing VTXOs after VTXO_ALREADY_SPENT:", e);
|
|
@@ -741,6 +769,66 @@ export class VtxoManager {
|
|
|
741
769
|
})();
|
|
742
770
|
return this.vtxoSpentRefreshPromise;
|
|
743
771
|
}
|
|
772
|
+
/**
|
|
773
|
+
* Extract the offending VTXO outpoint from a `VTXO_ALREADY_SPENT` error,
|
|
774
|
+
* if the server attached one in `metadata.vtxo_outpoint`. Returns
|
|
775
|
+
* `undefined` when the error isn't a parsed ArkError, isn't this code,
|
|
776
|
+
* or doesn't carry the metadata.
|
|
777
|
+
*/
|
|
778
|
+
extractSpentOutpoint(error) {
|
|
779
|
+
const ark = maybeArkError(error);
|
|
780
|
+
if (!ark || ark.name !== "VTXO_ALREADY_SPENT")
|
|
781
|
+
return undefined;
|
|
782
|
+
const raw = ark.metadata?.vtxo_outpoint;
|
|
783
|
+
if (typeof raw !== "string")
|
|
784
|
+
return undefined;
|
|
785
|
+
const [txid, voutStr] = raw.split(":");
|
|
786
|
+
if (!txid || !voutStr)
|
|
787
|
+
return undefined;
|
|
788
|
+
const vout = Number(voutStr);
|
|
789
|
+
if (!Number.isInteger(vout) || vout < 0)
|
|
790
|
+
return undefined;
|
|
791
|
+
return { txid, vout };
|
|
792
|
+
}
|
|
793
|
+
/**
|
|
794
|
+
* Reconcile the chosen VTXOs with the indexer's authoritative state
|
|
795
|
+
* before submitting a settle intent. Pulls the canonical record for
|
|
796
|
+
* each candidate outpoint via {@link IContractManager.refreshOutpoints}
|
|
797
|
+
* (which upserts the result into the wallet repository), then
|
|
798
|
+
* re-selects through the standard expiring-vtxo filter so anything
|
|
799
|
+
* the refresh flagged as spent is dropped.
|
|
800
|
+
*
|
|
801
|
+
* Best-effort: a failed refresh just falls back to the original
|
|
802
|
+
* candidates and lets the post-submit `VTXO_ALREADY_SPENT` recovery
|
|
803
|
+
* handle whatever slipped through.
|
|
804
|
+
*/
|
|
805
|
+
async revalidateBeforeSettle(candidates, thresholdMs) {
|
|
806
|
+
if (candidates.length === 0)
|
|
807
|
+
return candidates;
|
|
808
|
+
try {
|
|
809
|
+
const cm = await this.wallet.getContractManager();
|
|
810
|
+
await cm.refreshOutpoints(candidates.map((v) => ({ txid: v.txid, vout: v.vout })));
|
|
811
|
+
}
|
|
812
|
+
catch (e) {
|
|
813
|
+
console.error("Error pre-validating VTXOs before settle:", e);
|
|
814
|
+
return candidates;
|
|
815
|
+
}
|
|
816
|
+
// Re-select from the now-fresh local cache. Anything previously
|
|
817
|
+
// selected but spent gets filtered out by the standard
|
|
818
|
+
// `isSpendable`/`isSpent` checks inside getVtxos / getExpiringVtxos.
|
|
819
|
+
try {
|
|
820
|
+
const refreshed = await this.getExpiringVtxos(thresholdMs);
|
|
821
|
+
const candidateKeys = new Set(candidates.map((v) => `${v.txid}:${v.vout}`));
|
|
822
|
+
// Restrict to vtxos that were also in the original candidate set
|
|
823
|
+
// — `getExpiringVtxos` may surface NEW vtxos and we don't want
|
|
824
|
+
// pre-flight to silently expand the input set.
|
|
825
|
+
return refreshed.filter((v) => candidateKeys.has(`${v.txid}:${v.vout}`));
|
|
826
|
+
}
|
|
827
|
+
catch (e) {
|
|
828
|
+
console.error("Error re-selecting VTXOs after pre-validate:", e);
|
|
829
|
+
return candidates;
|
|
830
|
+
}
|
|
831
|
+
}
|
|
744
832
|
/** Computes the next poll delay, applying exponential backoff on failures. */
|
|
745
833
|
getNextPollDelay() {
|
|
746
834
|
if (this.settlementConfig === false)
|
|
@@ -882,6 +970,13 @@ export class VtxoManager {
|
|
|
882
970
|
if (!this.renewalInProgress) {
|
|
883
971
|
try {
|
|
884
972
|
expiringVtxos = await this.getExpiringVtxos();
|
|
973
|
+
// Pre-flight validation: see comment in `renewVtxos`. The
|
|
974
|
+
// local cache may carry vtxos that the indexer already
|
|
975
|
+
// marks spent because the cursor-derived delta sync only
|
|
976
|
+
// catches `created_at`-recent updates, not status changes
|
|
977
|
+
// for older VTXOs.
|
|
978
|
+
expiringVtxos =
|
|
979
|
+
await this.revalidateBeforeSettle(expiringVtxos);
|
|
885
980
|
}
|
|
886
981
|
catch (e) {
|
|
887
982
|
// Non-fatal: fall back to boarding-only settle.
|
|
@@ -973,11 +1068,12 @@ export class VtxoManager {
|
|
|
973
1068
|
e.message.includes("VTXO_ALREADY_SPENT")) {
|
|
974
1069
|
// Local VTXO cache is stale vs. the server's
|
|
975
1070
|
// authoritative view — not a transient failure.
|
|
976
|
-
// Trigger a throttled refresh
|
|
977
|
-
//
|
|
978
|
-
//
|
|
1071
|
+
// Trigger a throttled, targeted refresh on the
|
|
1072
|
+
// offending outpoint and skip this cycle without
|
|
1073
|
+
// bumping the failure counter, so the next poll
|
|
1074
|
+
// can retry once the cache reconciles.
|
|
979
1075
|
staleCacheSkip = true;
|
|
980
|
-
void this.maybeRefreshAfterVtxoSpent();
|
|
1076
|
+
void this.maybeRefreshAfterVtxoSpent(this.extractSpentOutpoint(e));
|
|
981
1077
|
}
|
|
982
1078
|
else {
|
|
983
1079
|
throw e;
|
|
@@ -32,8 +32,9 @@ import { DelegatorManagerImpl, findDestinationOutputIndex, } from './delegator.j
|
|
|
32
32
|
import { IndexedDBContractRepository, IndexedDBWalletRepository, } from '../repositories/index.js';
|
|
33
33
|
import { ContractManager } from '../contracts/contractManager.js';
|
|
34
34
|
import { contractHandlers } from '../contracts/handlers/index.js';
|
|
35
|
-
import { timelockToSequence } from '../
|
|
35
|
+
import { timelockToSequence } from '../utils/timelock.js';
|
|
36
36
|
import { clearSyncCursor, updateWalletState } from '../utils/syncCursors.js';
|
|
37
|
+
import { validateVtxosForScript, saveVtxosForContract, } from '../contracts/vtxoOwnership.js';
|
|
37
38
|
export const getArkadeServerUrl = ({ arkServerUrl, }) => arkServerUrl || DEFAULT_ARKADE_SERVER_URL;
|
|
38
39
|
// Historical unilateral exit delay for mainnet (~7 days in seconds).
|
|
39
40
|
// Kept so existing wallets can still discover and spend VTXOs sent to the
|
|
@@ -1823,7 +1824,7 @@ export class Wallet extends ReadonlyWallet {
|
|
|
1823
1824
|
}
|
|
1824
1825
|
}
|
|
1825
1826
|
const createdAt = Date.now();
|
|
1826
|
-
const
|
|
1827
|
+
const primaryAddr = this.arkAddress.encode();
|
|
1827
1828
|
// Only save a change virtual output for preconfirmed coins (those with a batchExpiry).
|
|
1828
1829
|
// Inputs without a batchExpiry are already settled/unrolled and don't need tracking.
|
|
1829
1830
|
let changeVtxo;
|
|
@@ -1850,8 +1851,37 @@ export class Wallet extends ReadonlyWallet {
|
|
|
1850
1851
|
script: hex.encode(this.offchainTapscript.pkScript),
|
|
1851
1852
|
};
|
|
1852
1853
|
}
|
|
1853
|
-
|
|
1854
|
-
|
|
1854
|
+
// Route spent rows to their owning contract bucket. The wallet's
|
|
1855
|
+
// primary contract is registered with the manager at boot, so
|
|
1856
|
+
// `addrByScript` already includes it; in a multi-contract spend
|
|
1857
|
+
// each input may belong to a different contract.
|
|
1858
|
+
const contracts = await cm.getContracts();
|
|
1859
|
+
const addrByScript = new Map(contracts.map((c) => [c.script, c.address]));
|
|
1860
|
+
const spentByScript = new Map();
|
|
1861
|
+
for (const v of spentVtxos) {
|
|
1862
|
+
if (!v.script) {
|
|
1863
|
+
throw new Error(`Wallet.updateDbAfterOffchainTx: spent VTXO ${v.txid}:${v.vout} has no script`);
|
|
1864
|
+
}
|
|
1865
|
+
const arr = spentByScript.get(v.script) ?? [];
|
|
1866
|
+
arr.push(v);
|
|
1867
|
+
spentByScript.set(v.script, arr);
|
|
1868
|
+
}
|
|
1869
|
+
for (const [script, vtxos] of spentByScript) {
|
|
1870
|
+
// User-initiated send path: a wrong-script row here means the
|
|
1871
|
+
// wallet is about to record ownership against the wrong
|
|
1872
|
+
// contract — fail loudly rather than persist inconsistent state.
|
|
1873
|
+
validateVtxosForScript(vtxos, script, "Wallet.updateDbAfterOffchainTx");
|
|
1874
|
+
const targetAddr = addrByScript.get(script);
|
|
1875
|
+
if (!targetAddr) {
|
|
1876
|
+
throw new Error(`Wallet.updateDbAfterOffchainTx: no contract owns script ${script}`);
|
|
1877
|
+
}
|
|
1878
|
+
await saveVtxosForContract(this.walletRepository, { script, address: targetAddr }, vtxos);
|
|
1879
|
+
}
|
|
1880
|
+
// Change is always primary-script by construction.
|
|
1881
|
+
if (changeVtxo) {
|
|
1882
|
+
await saveVtxosForContract(this.walletRepository, { script: changeVtxo.script, address: primaryAddr }, [changeVtxo]);
|
|
1883
|
+
}
|
|
1884
|
+
await this.walletRepository.saveTransactions(primaryAddr, [
|
|
1855
1885
|
{
|
|
1856
1886
|
key: {
|
|
1857
1887
|
boardingTxid: "",
|
|
@@ -1867,12 +1897,12 @@ export class Wallet extends ReadonlyWallet {
|
|
|
1867
1897
|
}
|
|
1868
1898
|
catch (e) {
|
|
1869
1899
|
console.warn("error saving offchain tx to repository", e);
|
|
1900
|
+
throw e;
|
|
1870
1901
|
}
|
|
1871
1902
|
}
|
|
1872
1903
|
// mark virtual outputs as spent/settled, remove boarding inputs
|
|
1873
1904
|
async updateDbAfterSettle(inputs, commitmentTxid) {
|
|
1874
1905
|
try {
|
|
1875
|
-
const addr = this.arkAddress.encode();
|
|
1876
1906
|
const boardingAddress = await this.getBoardingAddress();
|
|
1877
1907
|
const spentVtxos = [];
|
|
1878
1908
|
const inputArkTxIds = new Set();
|
|
@@ -1905,7 +1935,32 @@ export class Wallet extends ReadonlyWallet {
|
|
|
1905
1935
|
}
|
|
1906
1936
|
}
|
|
1907
1937
|
if (spentVtxos.length > 0) {
|
|
1908
|
-
|
|
1938
|
+
// Route settled rows to their owning contract bucket. In a
|
|
1939
|
+
// multi-contract settle the inputs may belong to several
|
|
1940
|
+
// contracts; the wallet's primary contract is registered with
|
|
1941
|
+
// the manager at boot, so its address is in `addrByScript`
|
|
1942
|
+
// alongside the rest.
|
|
1943
|
+
const contracts = await cm.getContracts();
|
|
1944
|
+
const addrByScript = new Map(contracts.map((c) => [c.script, c.address]));
|
|
1945
|
+
const byScript = new Map();
|
|
1946
|
+
for (const v of spentVtxos) {
|
|
1947
|
+
if (!v.script) {
|
|
1948
|
+
throw new Error(`Wallet.updateDbAfterSettle: spent VTXO ${v.txid}:${v.vout} has no script`);
|
|
1949
|
+
}
|
|
1950
|
+
const arr = byScript.get(v.script) ?? [];
|
|
1951
|
+
arr.push(v);
|
|
1952
|
+
byScript.set(v.script, arr);
|
|
1953
|
+
}
|
|
1954
|
+
for (const [script, vtxos] of byScript) {
|
|
1955
|
+
// User-initiated settle path: refuse to record a settle
|
|
1956
|
+
// against the wrong script.
|
|
1957
|
+
validateVtxosForScript(vtxos, script, "Wallet.updateDbAfterSettle");
|
|
1958
|
+
const targetAddr = addrByScript.get(script);
|
|
1959
|
+
if (!targetAddr) {
|
|
1960
|
+
throw new Error(`Wallet.updateDbAfterSettle: no contract owns script ${script}`);
|
|
1961
|
+
}
|
|
1962
|
+
await saveVtxosForContract(this.walletRepository, { script, address: targetAddr }, vtxos);
|
|
1963
|
+
}
|
|
1909
1964
|
}
|
|
1910
1965
|
if (boardingUtxoToRemove.size > 0) {
|
|
1911
1966
|
const currentUtxos = await this.walletRepository.getUtxos(boardingAddress);
|
|
@@ -1919,6 +1974,7 @@ export class Wallet extends ReadonlyWallet {
|
|
|
1919
1974
|
}
|
|
1920
1975
|
catch (e) {
|
|
1921
1976
|
console.warn("error updating repository after settle", e);
|
|
1977
|
+
throw e;
|
|
1922
1978
|
}
|
|
1923
1979
|
}
|
|
1924
1980
|
}
|