Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ for await (const step of session) {
console.log(`Waiting for transaction ${step.txid} to be confirmed`);
break;
case Unroll.StepType.UNROLL:
console.log(`Broadcasting transaction ${step.tx.id}`);
console.log(`Transaction ${step.tx.id} unrolled`);
break;
case Unroll.StepType.DONE:
console.log(`Unrolling complete for VTXO ${step.vtxoTxid}`);
Expand All @@ -225,6 +225,26 @@ The unrolling process works by:
- Waiting for confirmations between steps
- Using P2A (Pay-to-Anchor) transactions to pay for fees

Optionally, you can use `session.next()` to control the broadcasting process manually.

```typescript
const step = await session.next();
switch (step.type) {
case Unroll.StepType.WAIT:
await step.do(); // wait for the transaction to be confirmed
break;
case Unroll.StepType.UNROLL:
const [parent, child] = step.pkg;
console.log(`Parent: ${parent}`)
console.log(`Child: ${child}`)
await step.do(); // broadcast the 1C1P package
break;
case Unroll.StepType.DONE:
console.log(`Unrolling complete for VTXO ${step.vtxoTxid}`);
break;
}
```

#### Step 2: Completing the Exit

Once VTXOs are fully unrolled and the unilateral exit timelock has expired, you can complete the exit:
Expand Down
181 changes: 99 additions & 82 deletions src/wallet/unroll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export namespace Unroll {
*/
export type UnrollStep = {
tx: Transaction;
pkg: [parent: string, child: string];
};

/**
Expand Down Expand Up @@ -187,10 +188,12 @@ export namespace Unroll {
tx.finalize();
}

const pkg = await this.bumper.bumpP2A(tx);
return {
type: StepType.UNROLL,
tx,
do: doUnroll(this.bumper, this.explorer, tx),
pkg,
do: doUnroll(this.explorer, pkg),
};
}

Expand Down Expand Up @@ -227,111 +230,125 @@ export namespace Unroll {
vtxoTxids: string[],
outputAddress: string
): Promise<string> {
const chainTip = await wallet.onchainProvider.getChainTip();

let vtxos = await wallet.getVtxos({ withUnrolled: true });
vtxos = vtxos.filter((vtxo) => vtxoTxids.includes(vtxo.txid));

if (vtxos.length === 0) {
throw new Error("No vtxos to complete unroll");
}

const inputs: TransactionInputUpdate[] = [];
let totalAmount = 0n;
const txWeightEstimator = TxWeightEstimator.create();
for (const vtxo of vtxos) {
if (!vtxo.isUnrolled) {
throw new Error(
`Vtxo ${vtxo.txid}:${vtxo.vout} is not fully unrolled, use unroll first`
);
}
const signedTx = await prepareUnrollTransaction(
wallet,
vtxoTxids,
outputAddress
);
await wallet.onchainProvider.broadcastTransaction(signedTx.hex);
return signedTx.id;
}
}

const txStatus = await wallet.onchainProvider.getTxStatus(
vtxo.txid
);
if (!txStatus.confirmed) {
throw new Error(`tx ${vtxo.txid} is not confirmed`);
}
/**
* Prepares the transaction that spends the CSV path to complete unrolling a VTXO.
* @param wallet the wallet owning the VTXO(s)
* @param vtxoTxIds the txids of the VTXO(s) to complete unroll
* @param outputAddress the address to send the unrolled funds to
* @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
* @returns the transaction spending the unrolled funds
*/
export async function prepareUnrollTransaction(
wallet: Wallet,
vtxoTxIds: string[],
outputAddress: string
): Promise<Transaction> {
const chainTip = await wallet.onchainProvider.getChainTip();

let vtxos = await wallet.getVtxos({ withUnrolled: true });
vtxos = vtxos.filter((vtxo) => vtxoTxIds.includes(vtxo.txid));

if (vtxos.length === 0) {
throw new Error("No vtxos to complete unroll");
}

const exit = availableExitPath(
{ height: txStatus.blockHeight, time: txStatus.blockTime },
chainTip,
vtxo
const inputs: TransactionInputUpdate[] = [];
let totalAmount = 0n;
const txWeightEstimator = TxWeightEstimator.create();
for (const vtxo of vtxos) {
if (!vtxo.isUnrolled) {
throw new Error(
`Vtxo ${vtxo.txid}:${vtxo.vout} is not fully unrolled, use unroll first`
);
if (!exit) {
throw new Error(
`no available exit path found for vtxo ${vtxo.txid}:${vtxo.vout}`
);
}
}

const spendingLeaf = VtxoScript.decode(vtxo.tapTree).findLeaf(
hex.encode(exit.script)
);
if (!spendingLeaf) {
throw new Error(
`spending leaf not found for vtxo ${vtxo.txid}:${vtxo.vout}`
);
}
const txStatus = await wallet.onchainProvider.getTxStatus(vtxo.txid);
if (!txStatus.confirmed) {
throw new Error(`tx ${vtxo.txid} is not confirmed`);
}

totalAmount += BigInt(vtxo.value);
inputs.push({
txid: vtxo.txid,
index: vtxo.vout,
tapLeafScript: [spendingLeaf],
sequence: 0xffffffff - 1,
witnessUtxo: {
amount: BigInt(vtxo.value),
script: VtxoScript.decode(vtxo.tapTree).pkScript,
},
sighashType: SigHash.DEFAULT,
});
txWeightEstimator.addTapscriptInput(
64,
spendingLeaf[1].length,
TaprootControlBlock.encode(spendingLeaf[0]).length
const exit = availableExitPath(
{ height: txStatus.blockHeight, time: txStatus.blockTime },
chainTip,
vtxo
);
if (!exit) {
throw new Error(
`no available exit path found for vtxo ${vtxo.txid}:${vtxo.vout}`
);
}

const tx = new Transaction({ version: 2 });
for (const input of inputs) {
tx.addInput(input);
const spendingLeaf = VtxoScript.decode(vtxo.tapTree).findLeaf(
hex.encode(exit.script)
);
if (!spendingLeaf) {
throw new Error(
`spending leaf not found for vtxo ${vtxo.txid}:${vtxo.vout}`
);
}

txWeightEstimator.addP2TROutput();
totalAmount += BigInt(vtxo.value);
inputs.push({
txid: vtxo.txid,
index: vtxo.vout,
tapLeafScript: [spendingLeaf],
sequence: 0xffffffff - 1,
witnessUtxo: {
amount: BigInt(vtxo.value),
script: VtxoScript.decode(vtxo.tapTree).pkScript,
},
sighashType: SigHash.DEFAULT,
});
txWeightEstimator.addTapscriptInput(
64,
spendingLeaf[1].length,
TaprootControlBlock.encode(spendingLeaf[0]).length
);
}

let feeRate = await wallet.onchainProvider.getFeeRate();
if (!feeRate || feeRate < Wallet.MIN_FEE_RATE) {
feeRate = Wallet.MIN_FEE_RATE;
}
const feeAmount = txWeightEstimator.vsize().fee(BigInt(feeRate));
if (feeAmount > totalAmount) {
throw new Error("fee amount is greater than the total amount");
}
const tx = new Transaction({ version: 2 });
for (const input of inputs) {
tx.addInput(input);
}

tx.addOutputAddress(outputAddress, totalAmount - feeAmount);
txWeightEstimator.addP2TROutput();

const signedTx = await wallet.identity.sign(tx);
signedTx.finalize();
let feeRate = await wallet.onchainProvider.getFeeRate();
if (!feeRate || feeRate < Wallet.MIN_FEE_RATE) {
feeRate = Wallet.MIN_FEE_RATE;
}
const feeAmount = txWeightEstimator.vsize().fee(BigInt(feeRate));
Comment on lines +326 to +330
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for getFeeRate definition and return type
rg -n -B2 -A10 'getFeeRate\(' --type ts

Repository: arkade-os/ts-sdk

Length of output: 5641


🏁 Script executed:

#!/bin/bash
# Check BigInt usage with feeRate
rg -n 'BigInt.*feeRate|BigInt.*fee' --type ts

Repository: arkade-os/ts-sdk

Length of output: 154


🏁 Script executed:

#!/bin/bash
# Check the type of feeRate in context
rg -n 'feeRate.*=' --type ts | head -20

Repository: arkade-os/ts-sdk

Length of output: 754


🏁 Script executed:

#!/bin/bash
# Look for Wallet.MIN_FEE_RATE definition and its type
rg -n 'MIN_FEE_RATE' --type ts -B2 -A2

Repository: arkade-os/ts-sdk

Length of output: 1668


Convert fractional feeRate to integer before passing to BigInt().

Line 330 converts feeRate directly to BigInt, but getFeeRate() returns a number type that can be fractional. JavaScript's BigInt() constructor throws a TypeError on non-integer values (e.g., BigInt(1.5) fails). This will crash at runtime if feeRate is fractional.

Other code in the repository handles this correctly using Math.ceil() (see onchain.ts lines 113 and 180).

Proposed fix
-    const feeAmount = txWeightEstimator.vsize().fee(BigInt(feeRate));
+    const feeAmount = txWeightEstimator.vsize().fee(BigInt(Math.ceil(feeRate)));
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let feeRate = await wallet.onchainProvider.getFeeRate();
if (!feeRate || feeRate < Wallet.MIN_FEE_RATE) {
feeRate = Wallet.MIN_FEE_RATE;
}
const feeAmount = txWeightEstimator.vsize().fee(BigInt(feeRate));
let feeRate = await wallet.onchainProvider.getFeeRate();
if (!feeRate || feeRate < Wallet.MIN_FEE_RATE) {
feeRate = Wallet.MIN_FEE_RATE;
}
const feeAmount = txWeightEstimator.vsize().fee(BigInt(Math.ceil(feeRate)));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/wallet/unroll.ts` around lines 326 - 330, feeRate returned by
wallet.onchainProvider.getFeeRate() may be fractional and passing it directly
into BigInt(...) will throw; before calling
txWeightEstimator.vsize().fee(BigInt(...)) convert the effective feeRate to an
integer (use Math.ceil like other code paths) so both the value from
getFeeRate() and fallback Wallet.MIN_FEE_RATE are rounded up to an integer prior
to BigInt conversion; update the logic around feeRate and the call to
txWeightEstimator.vsize().fee to use Math.ceil(feeRate) (then BigInt) to avoid
runtime TypeError.

if (feeAmount > totalAmount) {
throw new Error("fee amount is greater than the total amount");
}

await wallet.onchainProvider.broadcastTransaction(signedTx.hex);
tx.addOutputAddress(outputAddress, totalAmount - feeAmount);

return signedTx.id;
}
const signedTx = await wallet.identity.sign(tx);
signedTx.finalize();
return signedTx;
}

function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

function doUnroll(
bumper: AnchorBumper,
onchainProvider: OnchainProvider,
tx: Transaction
pkg: Unroll.UnrollStep["pkg"]
): () => Promise<void> {
return async () => {
const [parent, child] = await bumper.bumpP2A(tx);
await onchainProvider.broadcastTransaction(parent, child);
};
return () =>
onchainProvider.broadcastTransaction(...pkg).then(() => undefined);
}

function doWait(
Expand Down