@arkade-os/sdk 0.4.23 → 0.4.24
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 +29 -4
- 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 +60 -0
- package/dist/cjs/index.js +3 -3
- 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 +59 -9
- package/dist/cjs/wallet/unroll.js +79 -67
- package/dist/cjs/wallet/wallet.js +78 -8
- package/dist/cjs/worker/expo/processors/contractPollProcessor.js +7 -2
- package/dist/esm/contracts/contractManager.js +29 -4
- 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 +53 -0
- package/dist/esm/index.js +1 -1
- 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 +59 -9
- package/dist/esm/wallet/unroll.js +78 -67
- package/dist/esm/wallet/wallet.js +76 -6
- package/dist/esm/worker/expo/processors/contractPollProcessor.js +7 -2
- package/dist/types/contracts/handlers/helpers.d.ts +0 -9
- package/dist/types/contracts/vtxoOwnership.d.ts +25 -0
- package/dist/types/index.d.ts +1 -1
- package/dist/types/script/tapscript.d.ts +4 -0
- package/dist/types/utils/timelock.d.ts +9 -0
- package/dist/types/wallet/unroll.d.ts +10 -0
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import * as bip68 from "bip68";
|
|
2
1
|
import { Script, ScriptNum, p2tr_ms } from "@scure/btc-signer";
|
|
3
2
|
import { hex } from "@scure/base";
|
|
3
|
+
import { sequenceToTimelock, timelockToSequence } from '../utils/timelock.js';
|
|
4
4
|
const MinimalScriptNum = ScriptNum(undefined, true);
|
|
5
5
|
export var TapscriptType;
|
|
6
6
|
(function (TapscriptType) {
|
|
@@ -234,9 +234,7 @@ export var CSVMultisigTapscript;
|
|
|
234
234
|
throw new Error(`Invalid pubkey length: expected 32, got ${pubkey.length}`);
|
|
235
235
|
}
|
|
236
236
|
}
|
|
237
|
-
const sequence = MinimalScriptNum.encode(BigInt(
|
|
238
|
-
? { blocks: Number(params.timelock.value) }
|
|
239
|
-
: { seconds: Number(params.timelock.value) })));
|
|
237
|
+
const sequence = MinimalScriptNum.encode(BigInt(timelockToSequence(params.timelock)));
|
|
240
238
|
const asm = [
|
|
241
239
|
sequence.length === 1 ? sequence[0] : sequence,
|
|
242
240
|
"CHECKSEQUENCEVERIFY",
|
|
@@ -259,17 +257,12 @@ export var CSVMultisigTapscript;
|
|
|
259
257
|
if (script.length === 0) {
|
|
260
258
|
throw new Error("Failed to decode: script is empty");
|
|
261
259
|
}
|
|
262
|
-
const
|
|
263
|
-
if (
|
|
264
|
-
throw
|
|
260
|
+
const isValid = isScriptValid(script);
|
|
261
|
+
if (isValid instanceof Error) {
|
|
262
|
+
throw isValid;
|
|
265
263
|
}
|
|
264
|
+
const asm = Script.decode(script);
|
|
266
265
|
const sequence = asm[0];
|
|
267
|
-
if (typeof sequence === "string") {
|
|
268
|
-
throw new Error("Invalid script: expected sequence number");
|
|
269
|
-
}
|
|
270
|
-
if (asm[1] !== "CHECKSEQUENCEVERIFY" || asm[2] !== "DROP") {
|
|
271
|
-
throw new Error("Invalid script: expected CHECKSEQUENCEVERIFY DROP");
|
|
272
|
-
}
|
|
273
266
|
const multisigScript = new Uint8Array(Script.encode(asm.slice(3)));
|
|
274
267
|
let multisig;
|
|
275
268
|
try {
|
|
@@ -285,10 +278,7 @@ export var CSVMultisigTapscript;
|
|
|
285
278
|
else {
|
|
286
279
|
sequenceNum = Number(MinimalScriptNum.decode(sequence));
|
|
287
280
|
}
|
|
288
|
-
const
|
|
289
|
-
const timelock = decodedTimelock.blocks !== undefined
|
|
290
|
-
? { type: "blocks", value: BigInt(decodedTimelock.blocks) }
|
|
291
|
-
: { type: "seconds", value: BigInt(decodedTimelock.seconds) };
|
|
281
|
+
const timelock = sequenceToTimelock(sequenceNum);
|
|
292
282
|
const reconstructed = encode({
|
|
293
283
|
timelock,
|
|
294
284
|
...multisig.params,
|
|
@@ -311,6 +301,21 @@ export var CSVMultisigTapscript;
|
|
|
311
301
|
return tapscript.type === TapscriptType.CSVMultisig;
|
|
312
302
|
}
|
|
313
303
|
CSVMultisigTapscript.is = is;
|
|
304
|
+
function isScriptValid(script) {
|
|
305
|
+
const asm = Script.decode(script);
|
|
306
|
+
if (asm.length < 3) {
|
|
307
|
+
return new Error(`Invalid script: too short (expected at least 3)`);
|
|
308
|
+
}
|
|
309
|
+
const sequence = asm[0];
|
|
310
|
+
if (typeof sequence === "string") {
|
|
311
|
+
return new Error("Invalid script: expected sequence number");
|
|
312
|
+
}
|
|
313
|
+
if (asm[1] !== "CHECKSEQUENCEVERIFY" || asm[2] !== "DROP") {
|
|
314
|
+
return new Error("Invalid script: expected CHECKSEQUENCEVERIFY DROP");
|
|
315
|
+
}
|
|
316
|
+
return true;
|
|
317
|
+
}
|
|
318
|
+
CSVMultisigTapscript.isScriptValid = isScriptValid;
|
|
314
319
|
})(CSVMultisigTapscript || (CSVMultisigTapscript = {}));
|
|
315
320
|
/**
|
|
316
321
|
* Combines a condition script with an exit closure. The resulting script requires
|
|
@@ -345,18 +350,14 @@ export var ConditionCSVMultisigTapscript;
|
|
|
345
350
|
if (script.length === 0) {
|
|
346
351
|
throw new Error("Failed to decode: script is empty");
|
|
347
352
|
}
|
|
348
|
-
const
|
|
349
|
-
if (
|
|
350
|
-
throw
|
|
351
|
-
}
|
|
352
|
-
let verifyIndex = -1;
|
|
353
|
-
for (let i = asm.length - 1; i >= 0; i--) {
|
|
354
|
-
if (asm[i] === "VERIFY") {
|
|
355
|
-
verifyIndex = i;
|
|
356
|
-
}
|
|
353
|
+
const isValid = isScriptValid(script);
|
|
354
|
+
if (isValid instanceof Error) {
|
|
355
|
+
throw isValid;
|
|
357
356
|
}
|
|
357
|
+
const asm = Script.decode(script);
|
|
358
|
+
let verifyIndex = getVerifyIndex(asm);
|
|
358
359
|
if (verifyIndex === -1) {
|
|
359
|
-
throw
|
|
360
|
+
throw Error("Invalid script: missing VERIFY operation");
|
|
360
361
|
}
|
|
361
362
|
const conditionScript = new Uint8Array(Script.encode(asm.slice(0, verifyIndex)));
|
|
362
363
|
const csvMultisigScript = new Uint8Array(Script.encode(asm.slice(verifyIndex + 1)));
|
|
@@ -389,6 +390,28 @@ export var ConditionCSVMultisigTapscript;
|
|
|
389
390
|
return tapscript.type === TapscriptType.ConditionCSVMultisig;
|
|
390
391
|
}
|
|
391
392
|
ConditionCSVMultisigTapscript.is = is;
|
|
393
|
+
function getVerifyIndex(asm) {
|
|
394
|
+
let verifyIndex = -1;
|
|
395
|
+
for (let i = asm.length - 1; i >= 0; i--) {
|
|
396
|
+
if (asm[i] === "VERIFY") {
|
|
397
|
+
verifyIndex = i;
|
|
398
|
+
return verifyIndex;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
return verifyIndex;
|
|
402
|
+
}
|
|
403
|
+
function isScriptValid(script) {
|
|
404
|
+
const asm = Script.decode(script);
|
|
405
|
+
if (asm.length < 1) {
|
|
406
|
+
return new Error(`Invalid script: too short (expected at least 1)`);
|
|
407
|
+
}
|
|
408
|
+
let verifyIndex = getVerifyIndex(asm);
|
|
409
|
+
if (verifyIndex === -1) {
|
|
410
|
+
return new Error("Invalid script: missing VERIFY operation");
|
|
411
|
+
}
|
|
412
|
+
return true;
|
|
413
|
+
}
|
|
414
|
+
ConditionCSVMultisigTapscript.isScriptValid = isScriptValid;
|
|
392
415
|
})(ConditionCSVMultisigTapscript || (ConditionCSVMultisigTapscript = {}));
|
|
393
416
|
/**
|
|
394
417
|
* Combines a condition script with a forfeit closure. The resulting script requires
|
|
@@ -423,18 +446,14 @@ export var ConditionMultisigTapscript;
|
|
|
423
446
|
if (script.length === 0) {
|
|
424
447
|
throw new Error("Failed to decode: script is empty");
|
|
425
448
|
}
|
|
426
|
-
const
|
|
427
|
-
if (
|
|
428
|
-
throw
|
|
429
|
-
}
|
|
430
|
-
let verifyIndex = -1;
|
|
431
|
-
for (let i = asm.length - 1; i >= 0; i--) {
|
|
432
|
-
if (asm[i] === "VERIFY") {
|
|
433
|
-
verifyIndex = i;
|
|
434
|
-
}
|
|
449
|
+
const isValid = isScriptValid(script);
|
|
450
|
+
if (isValid instanceof Error) {
|
|
451
|
+
throw isValid;
|
|
435
452
|
}
|
|
453
|
+
const asm = Script.decode(script);
|
|
454
|
+
let verifyIndex = getVerifyIndex(asm);
|
|
436
455
|
if (verifyIndex === -1) {
|
|
437
|
-
throw
|
|
456
|
+
throw Error("Invalid script: missing VERIFY operation");
|
|
438
457
|
}
|
|
439
458
|
const conditionScript = new Uint8Array(Script.encode(asm.slice(0, verifyIndex)));
|
|
440
459
|
const multisigScript = new Uint8Array(Script.encode(asm.slice(verifyIndex + 1)));
|
|
@@ -467,6 +486,28 @@ export var ConditionMultisigTapscript;
|
|
|
467
486
|
return tapscript.type === TapscriptType.ConditionMultisig;
|
|
468
487
|
}
|
|
469
488
|
ConditionMultisigTapscript.is = is;
|
|
489
|
+
function getVerifyIndex(asm) {
|
|
490
|
+
let verifyIndex = -1;
|
|
491
|
+
for (let i = asm.length - 1; i >= 0; i--) {
|
|
492
|
+
if (asm[i] === "VERIFY") {
|
|
493
|
+
verifyIndex = i;
|
|
494
|
+
return verifyIndex;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
return verifyIndex;
|
|
498
|
+
}
|
|
499
|
+
function isScriptValid(script) {
|
|
500
|
+
const asm = Script.decode(script);
|
|
501
|
+
if (asm.length < 1) {
|
|
502
|
+
return new Error(`Invalid script: too short (expected at least 1)`);
|
|
503
|
+
}
|
|
504
|
+
let verifyIndex = getVerifyIndex(asm);
|
|
505
|
+
if (verifyIndex === -1) {
|
|
506
|
+
return new Error("Invalid script: missing VERIFY operation");
|
|
507
|
+
}
|
|
508
|
+
return true;
|
|
509
|
+
}
|
|
510
|
+
ConditionMultisigTapscript.isScriptValid = isScriptValid;
|
|
470
511
|
})(ConditionMultisigTapscript || (ConditionMultisigTapscript = {}));
|
|
471
512
|
/**
|
|
472
513
|
* Implements an absolute timelock (CLTV) script combined with a forfeit closure.
|
|
@@ -507,10 +548,11 @@ export var CLTVMultisigTapscript;
|
|
|
507
548
|
if (script.length === 0) {
|
|
508
549
|
throw new Error("Failed to decode: script is empty");
|
|
509
550
|
}
|
|
510
|
-
const
|
|
511
|
-
if (
|
|
512
|
-
throw
|
|
551
|
+
const isValid = isScriptValid(script);
|
|
552
|
+
if (isValid instanceof Error) {
|
|
553
|
+
throw isValid;
|
|
513
554
|
}
|
|
555
|
+
const asm = Script.decode(script);
|
|
514
556
|
const locktime = asm[0];
|
|
515
557
|
if (typeof locktime === "string") {
|
|
516
558
|
throw new Error("Invalid script: expected locktime number");
|
|
@@ -555,4 +597,19 @@ export var CLTVMultisigTapscript;
|
|
|
555
597
|
return tapscript.type === TapscriptType.CLTVMultisig;
|
|
556
598
|
}
|
|
557
599
|
CLTVMultisigTapscript.is = is;
|
|
600
|
+
function isScriptValid(script) {
|
|
601
|
+
const asm = Script.decode(script);
|
|
602
|
+
if (asm.length < 3) {
|
|
603
|
+
return new Error(`Invalid script: too short (expected at least 3)`);
|
|
604
|
+
}
|
|
605
|
+
const locktime = asm[0];
|
|
606
|
+
if (typeof locktime === "string") {
|
|
607
|
+
return new Error("Invalid script: expected locktime as number or bytes");
|
|
608
|
+
}
|
|
609
|
+
if (asm[1] !== "CHECKLOCKTIMEVERIFY" || asm[2] !== "DROP") {
|
|
610
|
+
return new Error("Invalid script: expected CHECKLOCKTIMEVERIFY DROP");
|
|
611
|
+
}
|
|
612
|
+
return true;
|
|
613
|
+
}
|
|
614
|
+
CLTVMultisigTapscript.isScriptValid = isScriptValid;
|
|
558
615
|
})(CLTVMultisigTapscript || (CLTVMultisigTapscript = {}));
|
|
@@ -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, 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");
|
|
@@ -581,11 +583,45 @@ export class WalletMessageHandler {
|
|
|
581
583
|
const { newVtxos, spentVtxos } = funds;
|
|
582
584
|
if (newVtxos.length + spentVtxos.length === 0)
|
|
583
585
|
return;
|
|
584
|
-
//
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
586
|
+
// Save virtual outputs using unified repository. The
|
|
587
|
+
// event may carry rows for several scripts (other
|
|
588
|
+
// contracts the wallet watches), so split by script and
|
|
589
|
+
// save each bucket under its own contract address rather
|
|
590
|
+
// than saving a mixed-script array under one address.
|
|
591
|
+
const byScript = new Map();
|
|
592
|
+
for (const v of [...newVtxos, ...spentVtxos]) {
|
|
593
|
+
if (!v.script) {
|
|
594
|
+
// Without a script we can't route the row to the
|
|
595
|
+
// right contract bucket; surface the drop instead
|
|
596
|
+
// of silently losing the VTXO.
|
|
597
|
+
console.warn(`WalletMessageHandler.notifyIncomingFunds: dropping VTXO without script ${v.txid}:${v.vout}`);
|
|
598
|
+
continue;
|
|
599
|
+
}
|
|
600
|
+
const arr = byScript.get(v.script) ?? [];
|
|
601
|
+
arr.push(v);
|
|
602
|
+
byScript.set(v.script, arr);
|
|
603
|
+
}
|
|
604
|
+
let walletScript;
|
|
605
|
+
try {
|
|
606
|
+
walletScript = scriptFromArkAddress(address);
|
|
607
|
+
}
|
|
608
|
+
catch {
|
|
609
|
+
walletScript = undefined;
|
|
610
|
+
}
|
|
611
|
+
const cm = await this.readonlyWallet.getContractManager();
|
|
612
|
+
const contracts = await cm.getContracts();
|
|
613
|
+
const addrByScript = new Map(contracts.map((c) => [c.script, c.address]));
|
|
614
|
+
for (const [script, vtxos] of byScript) {
|
|
615
|
+
const filtered = warnAndFilterVtxosForScript(vtxos, script, "WalletMessageHandler.notifyIncomingFunds");
|
|
616
|
+
if (filtered.length === 0)
|
|
617
|
+
continue;
|
|
618
|
+
const targetAddress = script === walletScript
|
|
619
|
+
? address
|
|
620
|
+
: addrByScript.get(script);
|
|
621
|
+
if (!targetAddress)
|
|
622
|
+
continue;
|
|
623
|
+
await this.walletRepository?.saveVtxos(targetAddress, filtered);
|
|
624
|
+
}
|
|
589
625
|
// notify all clients about the virtual output state update
|
|
590
626
|
this.scheduleForNextTick(() => this.tagged({
|
|
591
627
|
type: "VTXO_UPDATE",
|
|
@@ -797,17 +833,31 @@ export class WalletMessageHandler {
|
|
|
797
833
|
}
|
|
798
834
|
}
|
|
799
835
|
};
|
|
800
|
-
// Aggregate virtual outputs from all contract addresses
|
|
836
|
+
// Aggregate virtual outputs from all contract addresses. Address
|
|
837
|
+
// buckets may carry legacy duplicate rows from other contracts; gate
|
|
838
|
+
// each bucket by its owning contract script before deduplication so a
|
|
839
|
+
// wrong-script row never wins the txid:vout race.
|
|
801
840
|
const manager = await this.readonlyWallet.getContractManager();
|
|
802
841
|
const contracts = await manager.getContracts();
|
|
803
842
|
for (const contract of contracts) {
|
|
804
843
|
const vtxos = await this.walletRepository.getVtxos(contract.address);
|
|
805
|
-
addVtxos(vtxos);
|
|
844
|
+
addVtxos(filterVtxosForScript(vtxos, contract.script));
|
|
806
845
|
}
|
|
807
|
-
// Also check the wallet's primary address
|
|
846
|
+
// Also check the wallet's primary address. Decode it to its script
|
|
847
|
+
// and apply the same script gate. Failing to decode the wallet's own
|
|
848
|
+
// address is a structural bug — surfacing the error is safer than
|
|
849
|
+
// silently dropping the primary bucket and zeroing the user's
|
|
850
|
+
// visible balance.
|
|
808
851
|
const walletAddress = await this.readonlyWallet.getAddress();
|
|
852
|
+
let walletScript;
|
|
853
|
+
try {
|
|
854
|
+
walletScript = scriptFromArkAddress(walletAddress);
|
|
855
|
+
}
|
|
856
|
+
catch (e) {
|
|
857
|
+
throw new Error(`WalletMessageHandler.getVtxosFromRepo: failed to derive script from wallet address ${walletAddress}: ${e instanceof Error ? e.message : String(e)}`);
|
|
858
|
+
}
|
|
809
859
|
const walletVtxos = await this.walletRepository.getVtxos(walletAddress);
|
|
810
|
-
addVtxos(walletVtxos);
|
|
860
|
+
addVtxos(filterVtxosForScript(walletVtxos, walletScript));
|
|
811
861
|
return allVtxos;
|
|
812
862
|
}
|
|
813
863
|
/**
|
|
@@ -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 () => {
|
|
@@ -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 } 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,45 @@ 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
|
+
const byAddress = new Map();
|
|
1870
|
+
for (const [script, vtxos] of spentByScript) {
|
|
1871
|
+
// User-initiated send path: a wrong-script row here means the
|
|
1872
|
+
// wallet is about to record ownership against the wrong
|
|
1873
|
+
// contract — fail loudly rather than persist inconsistent state.
|
|
1874
|
+
validateVtxosForScript(vtxos, script, "Wallet.updateDbAfterOffchainTx");
|
|
1875
|
+
const targetAddr = addrByScript.get(script);
|
|
1876
|
+
if (!targetAddr) {
|
|
1877
|
+
throw new Error(`Wallet.updateDbAfterOffchainTx: no contract owns script ${script}`);
|
|
1878
|
+
}
|
|
1879
|
+
const bucket = byAddress.get(targetAddr) ?? [];
|
|
1880
|
+
bucket.push(...vtxos);
|
|
1881
|
+
byAddress.set(targetAddr, bucket);
|
|
1882
|
+
}
|
|
1883
|
+
// Change is always primary-script by construction.
|
|
1884
|
+
if (changeVtxo) {
|
|
1885
|
+
const bucket = byAddress.get(primaryAddr) ?? [];
|
|
1886
|
+
bucket.push(changeVtxo);
|
|
1887
|
+
byAddress.set(primaryAddr, bucket);
|
|
1888
|
+
}
|
|
1889
|
+
for (const [addr, vtxos] of byAddress) {
|
|
1890
|
+
await this.walletRepository.saveVtxos(addr, vtxos);
|
|
1891
|
+
}
|
|
1892
|
+
await this.walletRepository.saveTransactions(primaryAddr, [
|
|
1855
1893
|
{
|
|
1856
1894
|
key: {
|
|
1857
1895
|
boardingTxid: "",
|
|
@@ -1867,12 +1905,12 @@ export class Wallet extends ReadonlyWallet {
|
|
|
1867
1905
|
}
|
|
1868
1906
|
catch (e) {
|
|
1869
1907
|
console.warn("error saving offchain tx to repository", e);
|
|
1908
|
+
throw e;
|
|
1870
1909
|
}
|
|
1871
1910
|
}
|
|
1872
1911
|
// mark virtual outputs as spent/settled, remove boarding inputs
|
|
1873
1912
|
async updateDbAfterSettle(inputs, commitmentTxid) {
|
|
1874
1913
|
try {
|
|
1875
|
-
const addr = this.arkAddress.encode();
|
|
1876
1914
|
const boardingAddress = await this.getBoardingAddress();
|
|
1877
1915
|
const spentVtxos = [];
|
|
1878
1916
|
const inputArkTxIds = new Set();
|
|
@@ -1905,7 +1943,38 @@ export class Wallet extends ReadonlyWallet {
|
|
|
1905
1943
|
}
|
|
1906
1944
|
}
|
|
1907
1945
|
if (spentVtxos.length > 0) {
|
|
1908
|
-
|
|
1946
|
+
// Route settled rows to their owning contract bucket. In a
|
|
1947
|
+
// multi-contract settle the inputs may belong to several
|
|
1948
|
+
// contracts; the wallet's primary contract is registered with
|
|
1949
|
+
// the manager at boot, so its address is in `addrByScript`
|
|
1950
|
+
// alongside the rest.
|
|
1951
|
+
const contracts = await cm.getContracts();
|
|
1952
|
+
const addrByScript = new Map(contracts.map((c) => [c.script, c.address]));
|
|
1953
|
+
const byAddress = new Map();
|
|
1954
|
+
const byScript = new Map();
|
|
1955
|
+
for (const v of spentVtxos) {
|
|
1956
|
+
if (!v.script) {
|
|
1957
|
+
throw new Error(`Wallet.updateDbAfterSettle: spent VTXO ${v.txid}:${v.vout} has no script`);
|
|
1958
|
+
}
|
|
1959
|
+
const arr = byScript.get(v.script) ?? [];
|
|
1960
|
+
arr.push(v);
|
|
1961
|
+
byScript.set(v.script, arr);
|
|
1962
|
+
}
|
|
1963
|
+
for (const [script, vtxos] of byScript) {
|
|
1964
|
+
// User-initiated settle path: refuse to record a settle
|
|
1965
|
+
// against the wrong script.
|
|
1966
|
+
validateVtxosForScript(vtxos, script, "Wallet.updateDbAfterSettle");
|
|
1967
|
+
const targetAddr = addrByScript.get(script);
|
|
1968
|
+
if (!targetAddr) {
|
|
1969
|
+
throw new Error(`Wallet.updateDbAfterSettle: no contract owns script ${script}`);
|
|
1970
|
+
}
|
|
1971
|
+
const bucket = byAddress.get(targetAddr) ?? [];
|
|
1972
|
+
bucket.push(...vtxos);
|
|
1973
|
+
byAddress.set(targetAddr, bucket);
|
|
1974
|
+
}
|
|
1975
|
+
for (const [bucketAddr, vtxos] of byAddress) {
|
|
1976
|
+
await this.walletRepository.saveVtxos(bucketAddr, vtxos);
|
|
1977
|
+
}
|
|
1909
1978
|
}
|
|
1910
1979
|
if (boardingUtxoToRemove.size > 0) {
|
|
1911
1980
|
const currentUtxos = await this.walletRepository.getUtxos(boardingAddress);
|
|
@@ -1919,6 +1988,7 @@ export class Wallet extends ReadonlyWallet {
|
|
|
1919
1988
|
}
|
|
1920
1989
|
catch (e) {
|
|
1921
1990
|
console.warn("error updating repository after settle", e);
|
|
1991
|
+
throw e;
|
|
1922
1992
|
}
|
|
1923
1993
|
}
|
|
1924
1994
|
}
|