Repository: BoltzExchange/boltz-web-app
Pull request #864: feat: Refund all UTXOs in one transaction
Author: login: kiocos
Created: 2025-03-25T11:47:18Z | Merged: 2025-03-27T16:46:02Z

Closes #855 

Depends on #828


Linked issue #855: Refund all UTXOs in one transaction
To be done after #828

https://github.com/BoltzExchange/boltz-web-app/blob/2060d80ef1f8e012affa70132fdc5f5db5a77f1a/src/components/RefundButton.tsx#L170
Iterating over them and refunding individually cannot be shiped
Linked issue comments: user: login: kilrau
created_at: 2025-03-17T15:38:47Z
body: Please reply so I can assign you @kiocosta 
author_association: MEMBER
user: login: kiocos
created_at: 2025-03-17T15:43:57Z
body: > Please reply so I can assign you [@kiocosta](https://github.com/kiocosta)

There you go!

author_association: MEMBER

Diff:
diff --git a/src/components/RefundButton.tsx b/src/components/RefundButton.tsx
index 8f4448fee..f5d32df0a 100644
--- a/src/components/RefundButton.tsx
+++ b/src/components/RefundButton.tsx
@@ -161,18 +161,14 @@ export const RefundBtc = (props: {
         setRefundRunning(true);
 
         try {
-            let refundedSwap: SomeSwap;
-
-            for (const tx of refundableUTXOs()) {
-                refundedSwap = await refund(
-                    props.deriveKeyFn || deriveKey,
-                    props.swap(),
-                    refundAddress(),
-                    tx,
-                    true,
-                    externalBroadcast(),
-                );
-            }
+            const refundedSwap: SomeSwap = await refund(
+                props.deriveKeyFn || deriveKey,
+                props.swap(),
+                refundAddress(),
+                refundableUTXOs(),
+                true,
+                externalBroadcast(),
+            );
 
             // save refundTx into swaps json and set it to the current swap
             // only if the swap exist in localstorage, else it is a refund json
diff --git a/src/utils/refund.ts b/src/utils/refund.ts
index 98bbcac8a..67778da0e 100644
--- a/src/utils/refund.ts
+++ b/src/utils/refund.ts
@@ -38,12 +38,12 @@ import { createMusig, hashForWitnessV1, tweakMusig } from "./taproot/musig";
 
 const refundTaproot = async <T extends TransactionInterface>(
     swap: SubmarineSwap | ChainSwap,
-    lockupTx: TransactionInterface,
+    lockupTxs: TransactionInterface[],
     privateKey: ECPairInterface,
     decodedAddress: DecodedAddress,
     feePerVbyte: number,
-    timeoutBlockHeight: number,
     cooperative: boolean = true,
+    timeoutBlockHeight?: number,
     // Keep the error of the cooperative refund to show a nice error reason in case the
     // uncooperative transaction is not ready to be broadcast yet
     cooperativeError?: string,
@@ -65,13 +65,12 @@ const refundTaproot = async <T extends TransactionInterface>(
 
     const swapTree = SwapTreeSerializer.deserializeSwapTree(lockupTree);
     const boltzPublicKey = Buffer.from(theirPublicKey, "hex");
-    const musig = await createMusig(privateKey, boltzPublicKey);
+    let musig = await createMusig(privateKey, boltzPublicKey);
     const tweakedKey = tweakMusig(swap.assetSend, musig, swapTree.tree);
 
-    const swapOutput = detectSwap(tweakedKey, lockupTx);
-
-    const details = [
-        {
+    const details = lockupTxs.map((lockupTx) => {
+        const swapOutput = detectSwap(tweakedKey, lockupTx);
+        return {
             ...swapOutput,
             cooperative,
             swapTree,
@@ -80,8 +79,8 @@ const refundTaproot = async <T extends TransactionInterface>(
             txHash: lockupTx.getHash(),
             blindingPrivateKey: parseBlindingKey(swap, true),
             internalKey: musig.getAggregatedPublicKey(),
-        },
-    ] as (RefundDetails & { blindingPrivateKey: Buffer })[];
+        } as RefundDetails & { blindingPrivateKey: Buffer };
+    });
 
     const constructRefundTransaction = getConstructRefundTransaction(
         swap.assetSend,
@@ -105,28 +104,32 @@ const refundTaproot = async <T extends TransactionInterface>(
     }
 
     try {
-        const boltzSig = await getPartialRefundSignature(
-            swap.id,
-            swap.type,
-            Buffer.from(musig.getPublicNonce()),
-            refundTx,
-            0,
-        );
-        musig.aggregateNonces([[boltzPublicKey, boltzSig.pubNonce]]);
-        musig.initializeSession(
-            hashForWitnessV1(
-                swap.assetSend,
-                getNetwork(swap.assetSend),
-                details,
+        for (const [index, input] of refundTx.ins.entries()) {
+            // Create new musig instance to initialize a new session
+            musig = await createMusig(privateKey, boltzPublicKey);
+            const boltzSig = await getPartialRefundSignature(
+                swap.id,
+                swap.type,
+                Buffer.from(musig.getPublicNonce()),
                 refundTx,
-                0,
-            ),
-        );
-        musig.signPartial();
-        musig.addPartial(boltzPublicKey, boltzSig.signature);
-
-        refundTx.ins[0].witness = [musig.aggregatePartials()];
+                index,
+            );
+            musig.aggregateNonces([[boltzPublicKey, boltzSig.pubNonce]]);
+            tweakMusig(swap.assetSend, musig, swapTree.tree);
+            musig.initializeSession(
+                hashForWitnessV1(
+                    swap.assetSend,
+                    getNetwork(swap.assetSend),
+                    details,
+                    refundTx,
+                    index,
+                ),
+            );
+            musig.signPartial();
+            musig.addPartial(boltzPublicKey, boltzSig.signature);
 
+            input.witness = [musig.aggregatePartials()];
+        }
         return {
             transaction: refundTx as T,
         };
@@ -144,12 +147,12 @@ const refundTaproot = async <T extends TransactionInterface>(
         try {
             return await refundTaproot(
                 swap,
-                lockupTx,
+                lockupTxs,
                 privateKey,
                 decodedAddress,
                 feePerVbyte,
-                timeoutBlockHeight,
                 false,
+                timeoutBlockHeight,
                 formatError(errorMsg),
             );
         } catch (uncoopError) {
@@ -194,7 +197,7 @@ export const refund = async <T extends SubmarineSwap | ChainSwap>(
     deriveKey: deriveKeyFn,
     swap: T,
     refundAddress: string,
-    transactionToRefund: { hex: string; timeoutBlockHeight?: number },
+    transactionsToRefund: { hex: string; timeoutBlockHeight?: number }[],
     cooperative: boolean,
     externalBroadcast: boolean,
 ): Promise<T> => {
@@ -204,9 +207,15 @@ export const refund = async <T extends SubmarineSwap | ChainSwap>(
 
     const feePerVbyte = (await getFeeEstimations())[swap.assetSend];
 
-    const lockupTransaction = getTransaction(swap.assetSend).fromHex(
-        transactionToRefund.hex,
+    const transactions = transactionsToRefund.map((transactionToRefund) =>
+        getTransaction(swap.assetSend).fromHex(transactionToRefund.hex),
     );
+
+    // We only have timeoutBlockHeight for the lockup transaction
+    const timeoutBlockHeight = transactionsToRefund.find(
+        (tx) => typeof tx.timeoutBlockHeight === "number",
+    )?.timeoutBlockHeight;
+
     const privateKey = parsePrivateKey(
         deriveKey,
         swap.refundPrivateKeyIndex,
@@ -218,12 +227,12 @@ export const refund = async <T extends SubmarineSwap | ChainSwap>(
     if (swap.version === OutputType.Taproot) {
         refundTransaction = await refundTaproot(
             swap,
-            lockupTransaction,
+            transactions,
             privateKey,
             output,
             feePerVbyte,
-            transactionToRefund.timeoutBlockHeight,
             cooperative,
+            timeoutBlockHeight,
         );
     } else {
         // Initialize the secp256k1-zkp library for blinding
@@ -233,8 +242,17 @@ export const refund = async <T extends SubmarineSwap | ChainSwap>(
             "hex",
         );
         log.debug("redeemScript", redeemScript);
-        const swapOutput = detectSwap(redeemScript, lockupTransaction);
-        log.debug("swapOutput", swapOutput);
+        const details = transactions.map((lockupTx) => {
+            const swapOutput = detectSwap(redeemScript, lockupTx);
+            log.debug("swapOutput", swapOutput);
+            return {
+                ...swapOutput,
+                txHash: lockupTx.getHash(),
+                redeemScript: redeemScript,
+                keys: privateKey,
+                blindingPrivateKey: parseBlindingKey(swap, true),
+            } as RefundDetails & LiquidRefundDetails;
+        });
 
         const constructRefundTransaction = getConstructRefundTransaction(
             swap.assetSend,
@@ -242,17 +260,9 @@ export const refund = async <T extends SubmarineSwap | ChainSwap>(
         );
         refundTransaction = {
             transaction: constructRefundTransaction(
-                [
-                    {
-                        ...swapOutput,
-                        txHash: lockupTransaction.getHash(),
-                        redeemScript: redeemScript,
-                        keys: privateKey,
-                        blindingPrivateKey: parseBlindingKey(swap, true),
-                    } as RefundDetails & LiquidRefundDetails,
-                ],
+                details,
                 output.script,
-                transactionToRefund.timeoutBlockHeight,
+                timeoutBlockHeight,
                 feePerVbyte,
                 true,
                 swap.assetSend === LBTC


Reviews:
user: login: kilrau
body: Imported a mainnet backup with a history of old swaps and sent multiple UTXOs to an old swap's lockup address.

Refunding fails with
Fails with `logs.ts:67 Cannot read properties of undefined (reading 'timeoutBlockHeight')`.
![image](https://github.com/user-attachments/assets/d3927bc1-3690-4e4b-9352-00c7a3aff1f0)

just out of interest I ran the same backup with PR 282 and here refund fails with `invalid mnemonic type: undefined`
![image](https://github.com/user-attachments/assets/a5727b30-d52d-4682-8172-e8205e9b30f1)
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-03-26T21:21:44Z
user: login: kilrau
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-27T11:37:43Z
user: login: kilrau
body: tACK

~Note: refunding two UTXO in lockup in two separate transactions which is perfectly fine:~
![image](https://github.com/user-attachments/assets/c168cb5c-b2bf-44fd-be56-0cdbf31b9eab)


~Please rename PR and commit "in one transaction" is misleading.~

Edit: edge case that we are fine with
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-27T11:40:20Z
user: login: kiocos
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-27T12:20:13Z
user: login: michael1011
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-27T16:45:50Z

Review comments:
diff_hunk: @@ -105,28 +104,32 @@ const refundTaproot = async <T extends TransactionInterface>(
     }
 
     try {
-        const boltzSig = await getPartialRefundSignature(
-            swap.id,
-            swap.type,
-            Buffer.from(musig.getPublicNonce()),
-            refundTx,
-            0,
-        );
-        musig.aggregateNonces([[boltzPublicKey, boltzSig.pubNonce]]);
-        musig.initializeSession(
-            hashForWitnessV1(
-                swap.assetSend,
-                getNetwork(swap.assetSend),
-                details,
+        for (const [index, input] of refundTx.ins.entries()) {
path: src/utils/refund.ts
user: login: kilrau
body: So you are looping over the refundable inputs and create refund txes individually
created_at: 2025-03-27T11:37:43Z
author_association: MEMBER
diff_hunk: @@ -105,28 +104,32 @@ const refundTaproot = async <T extends TransactionInterface>(
     }
 
     try {
-        const boltzSig = await getPartialRefundSignature(
-            swap.id,
-            swap.type,
-            Buffer.from(musig.getPublicNonce()),
-            refundTx,
-            0,
-        );
-        musig.aggregateNonces([[boltzPublicKey, boltzSig.pubNonce]]);
-        musig.initializeSession(
-            hashForWitnessV1(
-                swap.assetSend,
-                getNetwork(swap.assetSend),
-                details,
+        for (const [index, input] of refundTx.ins.entries()) {
path: src/utils/refund.ts
user: login: kiocos
body: @kilrau no, this is just to sign the multiple inputs of a single transaction. I only return one tx after this:
```
        return {
            transaction: refundTx as T,
        };
```
created_at: 2025-03-27T12:20:13Z
author_association: MEMBER

PR comments:
user: login: vercel[bot]
created_at: 2025-03-25T11:47:21Z
body: @kiocosta is attempting to deploy a commit to the **boltz** Team on [Vercel](https://vercel.com).

A member of the Team first needs to [authorize it](https://vercel.com/git/authorize?team=boltz&type=github&job=%7B%22headInfo%22%3A%7B%22sha%22%3A%22ab31167b75855fdeeec550ba4de418bfb89943ee%22%7D%2C%22id%22%3A%22QmUKngxVys9ieZoNXCVsoThnSzt5S9ck1gmMhaEVpvjUNT%22%2C%22org%22%3A%22BoltzExchange%22%2C%22prId%22%3A864%2C%22repo%22%3A%22boltz-web-app%22%7D).


author_association: NONE
user: login: kiocos
created_at: 2025-03-26T22:36:10Z
body: > Refunding fails with Fails with `logs.ts:67 Cannot read properties of undefined (reading 'timeoutBlockHeight')`. ![image]

I see the problem, pushed the fix. Will make sure to include a similar test scenario on #856

> just out of interest I ran the same backup with PR 282 and here refund fails with `invalid mnemonic type: undefined`

You mean #828, right?
I have the impression it's unrelated to #828, but will try to reproduce nonetheless. 
If you use the same swap on this PR I'm guessing you will have the same issue you just had on #828.
author_association: MEMBER
user: login: kilrau
created_at: 2025-03-26T22:43:09Z
body: Yes, 828 - mistyped. Same swap, ignore it.
author_association: MEMBER
user: login: kiocos
created_at: 2025-03-27T00:22:51Z
body: @kilrau 
>just out of interest I ran the same backup with PR 282 and here refund fails with invalid mnemonic type: undefined

Tested this and I believe the problem is related to the private key. Its bytes don't match with the original and working one. 

On `refund.ts`:
```
const musig = await createMusig(privateKey, boltzPublicKey);
const tweakedKey = tweakMusig(swap.assetSend, musig, swapTree.tree);

const swapOutput = detectSwap(tweakedKey, lockupTx);
```
`swapOutput` is undefined. I believe it's because we're using a different private key.

This issue also happens on `main`: created an invoice, paid it with incorrect amount and tried to refund it via the backup file and it also failed.
author_association: MEMBER
user: login: vercel[bot]
created_at: 2025-03-27T16:40:02Z
body: [vc]: #aObCalXGnLSAzFHBsOwR8zrbEb5DZAVklTXqx359IIk=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJ3ZWJhcHAtbWFpbm5ldCIsInJvb3REaXJlY3RvcnkiOm51bGwsImluc3BlY3RvclVybCI6Imh0dHBzOi8vdmVyY2VsLmNvbS9ib2x0ei93ZWJhcHAtbWFpbm5ldC9FQ3o1OWNxTUY2bllaNGhyTU5CNkF1WEZ5dXNuIiwicHJldmlld1VybCI6IndlYmFwcC1tYWlubmV0LWdpdC1mb3JrLWtpb2Nvc3RhLXJlZnVuZC1zaW5nbGUtdHgtYm9sdHoudmVyY2VsLmFwcCIsIm5leHRDb21taXRTdGF0dXMiOiJERVBMT1lFRCJ9LHsibmFtZSI6IndlYmFwcCIsInJvb3REaXJlY3RvcnkiOm51bGwsImluc3BlY3RvclVybCI6Imh0dHBzOi8vdmVyY2VsLmNvbS9ib2x0ei93ZWJhcHAvNExWZlZ2ejN2VzU2aFJpYkNock1zaTRSbmpQRSIsInByZXZpZXdVcmwiOiJ3ZWJhcHAtZ2l0LWZvcmsta2lvY29zdGEtcmVmdW5kLXNpbmdsZS10eC1ib2x0ei52ZXJjZWwuYXBwIiwibmV4dENvbW1pdFN0YXR1cyI6IkRFUExPWUVEIn1dfQ==
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Updated (UTC) |
| :--- | :----- | :------ | :------ |
| **webapp** | ✅ Ready ([Inspect](https://vercel.com/boltz/webapp/4LVfVvz3vW56hRibChrMsi4RnjPE)) | [Visit Preview](https://webapp-git-fork-kiocosta-refund-single-tx-boltz.vercel.app) | Mar 27, 2025 4:41pm |
| **webapp-mainnet** | ✅ Ready ([Inspect](https://vercel.com/boltz/webapp-mainnet/ECz59cqMF6nYZ4hrMNB6AuXFyusn)) | [Visit Preview](https://webapp-mainnet-git-fork-kiocosta-refund-single-tx-boltz.vercel.app) | Mar 27, 2025 4:41pm |



author_association: NONE