Skip to content

[WIP] swap: enable UTXO selection.#4140

Draft
bznein wants to merge 1 commit into
BitBoxSwiss:masterfrom
bznein:swapUTXO
Draft

[WIP] swap: enable UTXO selection.#4140
bznein wants to merge 1 commit into
BitBoxSwiss:masterfrom
bznein:swapUTXO

Conversation

@bznein
Copy link
Copy Markdown
Collaborator

@bznein bznein commented May 12, 2026

Note, this is an entirely vibe-coded feature just because I had extra usage available and wanted to see how much work it would take to implement the feature. It is NOT ready for review, I haven't even looked at the code.

Before asking for reviews, here is a check list of the most common things you might need to consider:

  • updating the Changelog
  • writing unit tests
  • checking if your changes affect other coins or tokens in unintended ways
  • testing on multiple environments (Qt, Android, ...)
  • having an AI review your changes

@bznein
Copy link
Copy Markdown
Collaborator Author

bznein commented May 13, 2026

@CodeRabbit review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 13, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 13, 2026

Review Change Stack

📝 Walkthrough

Walkthrough

This PR implements UTXO (Unspent Transaction Output) selection functionality across the application. The backend adds methods to compute selected UTXO amounts, track selected inputs in active transaction proposals, and expose this information through a new /utxos/amount API endpoint. The swap flow is extended to accept and forward selected UTXOs to the backend. On the frontend, a new reusable SelectedUTXOs component displays selected outputs grouped by address. The swap flow gains manual UTXO selection via a coin control interface, with a checkbox to toggle between automatic and manual modes. When manual selection is active, the component computes the total amount of selected UTXOs, prefills the sell amount, makes the amount field read-only, and forwards the selection through confirmation. Styling and translations support the new UI controls for both send and swap flows.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/coins/btc/transaction_test.go`:
- Around line 412-421: Add a test case that exercises the SelectedUTXOsAmount
guard for non-spendable selections: create a test (e.g.,
TestSelectedUTXOsAmount_NonSpendableOutpoint) using testAccount() and build a
selected outpoint (via wire.NewOutPoint) that is not spendable by the account,
call account.SelectedUTXOsAmount with that outpoint in the map, and assert the
call returns the expected failure (an error) rather than a valid amount (do not
assert coin.NewAmountFromInt64); reuse the existing test setup patterns from
TestSelectedUTXOsAmount to locate the account and outpoint creation.

In `@frontends/web/src/api/account.ts`:
- Around line 352-355: TSelectedUTXO currently requires address but backend can
omit it; update the type definition for TSelectedUTXO so address is optional
(e.g., change to address?: string) and then update any call sites or
destructuring that assume address is always present to handle undefined (look
for usages of TSelectedUTXO, selectedUtxos, and any code that reads .address to
add fallbacks or null checks).

In `@frontends/web/src/routes/account/send/components/confirm/selected-utxos.tsx`:
- Around line 11-20: The grouping function groupUTXOsByAddress should normalize
stringified null/undefined addresses so they don't become literal keys; inside
the loop that iterates over Object.entries(selectedUTXOs) (variables outpoint
and address), coerce address to a normalized key (e.g. treat address === 'null'
|| address === 'undefined' || address == null as ''), then use that normalized
key when creating/looking up utxosByAddress and when pushing outpoint; this
ensures the existing downstream `||` checks for empty strings still work and
users see the translated "unknown" instead of the literal "null"/"undefined".

In `@frontends/web/src/routes/market/swap/swap.tsx`:
- Around line 306-319: The lookup failure currently clears selected UTXO state
via setSelectedSwapUTXOsAmount('') and setSellSelectedUTXOs(false) but does not
restore the user's manual sell amount (sellAmount), which can unexpectedly blank
the quote; before initiating the UTXO amount lookup capture the current
sellAmount into a ref (e.g., prevSellAmountRef) or temp variable, and in both
the !response.success branch and the .catch branch restore that saved value by
calling setSellAmount(prevSellAmountRef.current) (or equivalent) while still
clearing selected UTXO state with setSelectedSwapUTXOsAmount('') and
setSellSelectedUTXOs(false).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: bf161d53-2e9c-41cd-a819-eb74932864b5

📥 Commits

Reviewing files that changed from the base of the PR and between a019889 and ef80a3b.

📒 Files selected for processing (18)
  • backend/coins/btc/account.go
  • backend/coins/btc/handlers/handlers.go
  • backend/coins/btc/transaction.go
  • backend/coins/btc/transaction_test.go
  • backend/handlers/handlers.go
  • backend/swap.go
  • backend/swap_test.go
  • frontends/web/src/api/account.ts
  • frontends/web/src/api/swap.ts
  • frontends/web/src/locales/en/app.json
  • frontends/web/src/routes/account/send/components/confirm/confirm.module.css
  • frontends/web/src/routes/account/send/components/confirm/confirm.tsx
  • frontends/web/src/routes/account/send/components/confirm/selected-utxos.module.css
  • frontends/web/src/routes/account/send/components/confirm/selected-utxos.tsx
  • frontends/web/src/routes/market/swap/components/swap-confirm.tsx
  • frontends/web/src/routes/market/swap/swap.module.css
  • frontends/web/src/routes/market/swap/swap.test.tsx
  • frontends/web/src/routes/market/swap/swap.tsx
💤 Files with no reviewable changes (1)
  • frontends/web/src/routes/account/send/components/confirm/confirm.module.css

Comment on lines +412 to +421
func TestSelectedUTXOsAmount(t *testing.T) {
account := testAccount(t)
selectedOutPoint := *wire.NewOutPoint(&chainhash.Hash{}, 1)

amount, err := account.SelectedUTXOsAmount(map[wire.OutPoint]struct{}{
selectedOutPoint: {},
})
require.NoError(t, err)
require.Equal(t, coin.NewAmountFromInt64(1000000), amount)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Add a non-spendable outpoint test case for SelectedUTXOsAmount.

This test currently validates only the success path; the method’s key guard (selected outpoint not spendable) is untested.

Suggested test addition
 func TestSelectedUTXOsAmount(t *testing.T) {
 	account := testAccount(t)
 	selectedOutPoint := *wire.NewOutPoint(&chainhash.Hash{}, 1)

 	amount, err := account.SelectedUTXOsAmount(map[wire.OutPoint]struct{}{
 		selectedOutPoint: {},
 	})
 	require.NoError(t, err)
 	require.Equal(t, coin.NewAmountFromInt64(1000000), amount)
+
+	_, err = account.SelectedUTXOsAmount(map[wire.OutPoint]struct{}{
+		*wire.NewOutPoint(&chainhash.Hash{}, 999): {},
+	})
+	require.ErrorContains(t, err, "is not spendable")
 }
📝 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
func TestSelectedUTXOsAmount(t *testing.T) {
account := testAccount(t)
selectedOutPoint := *wire.NewOutPoint(&chainhash.Hash{}, 1)
amount, err := account.SelectedUTXOsAmount(map[wire.OutPoint]struct{}{
selectedOutPoint: {},
})
require.NoError(t, err)
require.Equal(t, coin.NewAmountFromInt64(1000000), amount)
}
func TestSelectedUTXOsAmount(t *testing.T) {
account := testAccount(t)
selectedOutPoint := *wire.NewOutPoint(&chainhash.Hash{}, 1)
amount, err := account.SelectedUTXOsAmount(map[wire.OutPoint]struct{}{
selectedOutPoint: {},
})
require.NoError(t, err)
require.Equal(t, coin.NewAmountFromInt64(1000000), amount)
_, err = account.SelectedUTXOsAmount(map[wire.OutPoint]struct{}{
*wire.NewOutPoint(&chainhash.Hash{}, 999): {},
})
require.ErrorContains(t, err, "is not spendable")
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/coins/btc/transaction_test.go` around lines 412 - 421, Add a test
case that exercises the SelectedUTXOsAmount guard for non-spendable selections:
create a test (e.g., TestSelectedUTXOsAmount_NonSpendableOutpoint) using
testAccount() and build a selected outpoint (via wire.NewOutPoint) that is not
spendable by the account, call account.SelectedUTXOsAmount with that outpoint in
the map, and assert the call returns the expected failure (an error) rather than
a valid amount (do not assert coin.NewAmountFromInt64); reuse the existing test
setup patterns from TestSelectedUTXOsAmount to locate the account and outpoint
creation.

Comment on lines +352 to +355
export type TSelectedUTXO = {
outPoint: string;
address: string;
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Make address optional in TSelectedUTXO to match backend payload.

Line 354 requires address, but selected UTXO address can be missing for some inputs. Keeping it required hides a real undefined case and weakens downstream fallback handling.

💡 Suggested fix
 export type TSelectedUTXO = {
   outPoint: string;
-  address: string;
+  address?: string;
 };
📝 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
export type TSelectedUTXO = {
outPoint: string;
address: string;
};
export type TSelectedUTXO = {
outPoint: string;
address?: string;
};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontends/web/src/api/account.ts` around lines 352 - 355, TSelectedUTXO
currently requires address but backend can omit it; update the type definition
for TSelectedUTXO so address is optional (e.g., change to address?: string) and
then update any call sites or destructuring that assume address is always
present to handle undefined (look for usages of TSelectedUTXO, selectedUtxos,
and any code that reads .address to add fallbacks or null checks).

Comment on lines +11 to +20
const groupUTXOsByAddress = (selectedUTXOs: TSelectedUTXOs): TUTXOsByAddress => {
const utxosByAddress: TUTXOsByAddress = {};
for (const [outpoint, address] of Object.entries(selectedUTXOs)) {
if (!utxosByAddress[address]) {
utxosByAddress[address] = [];
}
utxosByAddress[address].push(outpoint);
}
return utxosByAddress;
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Consider handling stringified null/undefined addresses explicitly.

When an address value is null or undefined, Object.entries() coerces them to the string keys "null" or "undefined". Later, these strings are truthy and won't trigger the fallback logic at lines 45 and 47, so users would see the literal text "null" or "undefined" instead of the translated "unknown".

🛡️ Suggested fix to handle stringified nullish values
 const groupUTXOsByAddress = (selectedUTXOs: TSelectedUTXOs): TUTXOsByAddress => {
   const utxosByAddress: TUTXOsByAddress = {};
   for (const [outpoint, address] of Object.entries(selectedUTXOs)) {
-    if (!utxosByAddress[address]) {
+    const key = (address && address !== 'undefined' && address !== 'null') ? address : '';
+    if (!utxosByAddress[key]) {
-      utxosByAddress[address] = [];
+      utxosByAddress[key] = [];
     }
-    utxosByAddress[address].push(outpoint);
+    utxosByAddress[key].push(outpoint);
   }
   return utxosByAddress;
 };

This normalizes all nullish or stringified-nullish addresses to an empty string, which the existing || checks will handle correctly.

📝 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
const groupUTXOsByAddress = (selectedUTXOs: TSelectedUTXOs): TUTXOsByAddress => {
const utxosByAddress: TUTXOsByAddress = {};
for (const [outpoint, address] of Object.entries(selectedUTXOs)) {
if (!utxosByAddress[address]) {
utxosByAddress[address] = [];
}
utxosByAddress[address].push(outpoint);
}
return utxosByAddress;
};
const groupUTXOsByAddress = (selectedUTXOs: TSelectedUTXOs): TUTXOsByAddress => {
const utxosByAddress: TUTXOsByAddress = {};
for (const [outpoint, address] of Object.entries(selectedUTXOs)) {
const key = (address && address !== 'undefined' && address !== 'null') ? address : '';
if (!utxosByAddress[key]) {
utxosByAddress[key] = [];
}
utxosByAddress[key].push(outpoint);
}
return utxosByAddress;
};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontends/web/src/routes/account/send/components/confirm/selected-utxos.tsx`
around lines 11 - 20, The grouping function groupUTXOsByAddress should normalize
stringified null/undefined addresses so they don't become literal keys; inside
the loop that iterates over Object.entries(selectedUTXOs) (variables outpoint
and address), coerce address to a normalized key (e.g. treat address === 'null'
|| address === 'undefined' || address == null as ''), then use that normalized
key when creating/looking up utxosByAddress and when pushing outpoint; this
ensures the existing downstream `||` checks for empty strings still work and
users see the translated "unknown" instead of the literal "null"/"undefined".

Comment on lines +306 to +319
if (!response.success) {
setSelectedSwapUTXOsAmount('');
setSellSelectedUTXOs(false);
return;
}
setSelectedSwapUTXOsAmount(response.amount.amount);
})
.catch(() => {
if (canceled) {
return;
}
setSelectedSwapUTXOsAmount('');
setSellSelectedUTXOs(false);
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Restore manual sell amount when UTXO amount lookup fails.

Line 308 and Line 318 disable sellSelectedUTXOs, but the manual amount is not restored. This can leave sellAmount blank/stale and unexpectedly clear the quote flow.

💡 Suggested fix
        if (!response.success) {
          setSelectedSwapUTXOsAmount('');
+         setSellAmount(manualSellAmountRef.current);
          setSellSelectedUTXOs(false);
          return;
        }
...
      .catch(() => {
        if (canceled) {
          return;
        }
        setSelectedSwapUTXOsAmount('');
+       setSellAmount(manualSellAmountRef.current);
        setSellSelectedUTXOs(false);
      });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontends/web/src/routes/market/swap/swap.tsx` around lines 306 - 319, The
lookup failure currently clears selected UTXO state via
setSelectedSwapUTXOsAmount('') and setSellSelectedUTXOs(false) but does not
restore the user's manual sell amount (sellAmount), which can unexpectedly blank
the quote; before initiating the UTXO amount lookup capture the current
sellAmount into a ref (e.g., prevSellAmountRef) or temp variable, and in both
the !response.success branch and the .catch branch restore that saved value by
calling setSellAmount(prevSellAmountRef.current) (or equivalent) while still
clearing selected UTXO state with setSelectedSwapUTXOsAmount('') and
setSellSelectedUTXOs(false).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant