Skip to content
Draft
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: 22 additions & 0 deletions backend/coins/btc/account.go
Original file line number Diff line number Diff line change
Expand Up @@ -1031,6 +1031,28 @@ func (account *Account) SpendableOutputs() ([]*SpendableOutput, error) {
return account.makeSpendableOutputs(utxos), nil
}

// SelectedUTXOsAmount returns the total value of the selected spendable outputs.
func (account *Account) SelectedUTXOsAmount(
selectedUTXOs map[wire.OutPoint]struct{},
) (coin.Amount, error) {
if !account.Synced() {
return coin.Amount{}, accounts.ErrSyncInProgress
}
utxos, err := account.transactions.SpendableOutputs()
if err != nil {
return coin.Amount{}, err
}
amount := btcutil.Amount(0)
for outPoint := range selectedUTXOs {
output, ok := utxos[outPoint]
if !ok {
return coin.Amount{}, errp.Newf("Selected UTXO %s is not spendable", outPoint.String())
}
amount += btcutil.Amount(output.TxOut.Value)
}
return coin.NewAmountFromInt64(int64(amount)), nil
}

// ReusedAddressesForOutputs returns the subset of the provided outputs whose addresses are reused
// across all indexed wallet outputs.
func (account *Account) ReusedAddressesForOutputs(
Expand Down
63 changes: 56 additions & 7 deletions backend/coins/btc/handlers/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ func NewHandlers(
handleFunc("/export", handlers.ensureAccountInitialized(handlers.postExportTransactions)).Methods("POST")
handleFunc("/info", handlers.ensureAccountInitialized(handlers.getAccountInfo)).Methods("GET")
handleFunc("/utxos", handlers.ensureAccountInitialized(handlers.getUTXOs)).Methods("GET")
handleFunc("/utxos/amount", handlers.ensureAccountInitialized(handlers.postUTXOsAmount)).Methods("POST")
handleFunc("/balance", handlers.ensureAccountInitialized(handlers.getAccountBalance)).Methods("GET")
handleFunc("/sendtx", handlers.ensureAccountInitialized(handlers.postAccountSendTx)).Methods("POST")
handleFunc("/fee-targets", handlers.ensureAccountInitialized(handlers.getAccountFeeTargets)).Methods("GET")
Expand Down Expand Up @@ -375,6 +376,50 @@ func (handlers *Handlers) getUTXOs(*http.Request) (interface{}, error) {
return result, nil
}

func parseSelectedUTXOs(selectedUTXOs []string) (map[wire.OutPoint]struct{}, error) {
result := map[wire.OutPoint]struct{}{}
for _, outPointString := range selectedUTXOs {
outPoint, err := util.ParseOutPoint([]byte(outPointString))
if err != nil {
return nil, err
}
result[*outPoint] = struct{}{}
}
return result, nil
}

func (handlers *Handlers) postUTXOsAmount(r *http.Request) (interface{}, error) {
accountConfig := handlers.account.Config()
type response struct {
Success bool `json:"success"`
ErrorMessage string `json:"errorMessage,omitempty"`
Amount *coin.FormattedAmountWithConversions `json:"amount,omitempty"`
}
var request struct {
SelectedUTXOS []string `json:"selectedUTXOS"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
return response{Success: false, ErrorMessage: "Request body is required and must be a valid JSON object."}, nil
}
selectedUTXOs, err := parseSelectedUTXOs(request.SelectedUTXOS)
if err != nil {
return response{Success: false, ErrorMessage: err.Error()}, nil
}
account, ok := handlers.account.(*btc.Account)
if !ok {
return response{Success: false, ErrorMessage: "Interface must be of type btc.Account"}, nil
}
amount, err := account.SelectedUTXOsAmount(selectedUTXOs)
if err != nil {
return response{Success: false, ErrorMessage: err.Error()}, nil
}
formattedAmount := amount.FormatWithConversions(handlers.account.Coin(), false, accountConfig.RateUpdater)
return response{
Success: true,
Amount: &formattedAmount,
}, nil
}

func (handlers *Handlers) getAccountBalance(*http.Request) (interface{}, error) {
accountConfig := handlers.account.Config()
type balance struct {
Expand Down Expand Up @@ -438,13 +483,9 @@ func (input *sendTxInput) UnmarshalJSON(jsonBytes []byte) error {
} else {
input.Amount = coin.NewSendAmount(jsonBody.Amount)
}
input.SelectedUTXOs = map[wire.OutPoint]struct{}{}
for _, outPointString := range jsonBody.SelectedUTXOS {
outPoint, err := util.ParseOutPoint([]byte(outPointString))
if err != nil {
return err
}
input.SelectedUTXOs[*outPoint] = struct{}{}
input.SelectedUTXOs, err = parseSelectedUTXOs(jsonBody.SelectedUTXOS)
if err != nil {
return err
}
input.Note = jsonBody.Note
if jsonBody.PaymentRequest != nil {
Expand Down Expand Up @@ -503,6 +544,7 @@ type txProposalResponse struct {
Fee *coin.FormattedAmountWithConversions `json:"fee,omitempty"`
Total *coin.FormattedAmountWithConversions `json:"total,omitempty"`
RecipientDisplayAddress string `json:"recipientDisplayAddress,omitempty"`
SelectedUTXOs []btc.SelectedUTXO `json:"selectedUTXOs,omitempty"`
}

func txProposalError(err error) (interface{}, error) {
Expand All @@ -525,12 +567,19 @@ func (handlers *Handlers) postAccountTxProposal(r *http.Request) (interface{}, e
amountResponse := outputAmount.FormatWithConversions(handlers.account.Coin(), false, accountConfig.RateUpdater)
feeResponse := fee.FormatWithConversions(handlers.account.Coin(), true, accountConfig.RateUpdater)
totalResponse := total.FormatWithConversions(handlers.account.Coin(), false, accountConfig.RateUpdater)
var selectedUTXOs []btc.SelectedUTXO
if account, ok := handlers.account.(interface {
ActiveTxProposalSelectedUTXOs() []btc.SelectedUTXO
}); ok {
selectedUTXOs = account.ActiveTxProposalSelectedUTXOs()
}
return txProposalResponse{
Success: true,
Amount: &amountResponse,
Fee: &feeResponse,
Total: &totalResponse,
RecipientDisplayAddress: formatAddressForDisplay(handlers.account, input.RecipientAddress),
SelectedUTXOs: selectedUTXOs,
}, nil
}

Expand Down
28 changes: 28 additions & 0 deletions backend/coins/btc/transaction.go
Original file line number Diff line number Diff line change
Expand Up @@ -277,3 +277,31 @@ func (account *Account) TxProposal(
coin.NewAmountFromInt64(int64(txProposal.Fee)),
coin.NewAmountFromInt64(int64(txProposal.Total())), nil
}

// SelectedUTXO describes an input selected for the active transaction proposal.
type SelectedUTXO struct {
OutPoint string `json:"outPoint"`
Address string `json:"address"`
}

// ActiveTxProposalSelectedUTXOs returns the selected inputs of the active transaction proposal.
func (account *Account) ActiveTxProposalSelectedUTXOs() []SelectedUTXO {
defer account.activeTxProposalLock.RLock()()
txProposal := account.activeTxProposal
if txProposal == nil {
return nil
}
selectedUTXOs := make([]SelectedUTXO, 0, len(txProposal.Psbt.UnsignedTx.TxIn))
for _, txIn := range txProposal.Psbt.UnsignedTx.TxIn {
utxo := txProposal.PreviousOutputs[txIn.PreviousOutPoint]
address := ""
if utxo.Address != nil {
address = utxo.Address.EncodeForHumans()
}
selectedUTXOs = append(selectedUTXOs, SelectedUTXO{
OutPoint: txIn.PreviousOutPoint.String(),
Address: address,
})
}
return selectedUTXOs
}
39 changes: 35 additions & 4 deletions backend/coins/btc/transaction_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,9 @@ func mustKeypath(t *testing.T, keypath string) signing.AbsoluteKeypath {
return kp
}

func testAccount(t *testing.T, config *config.Account) *Account {
func testAccount(t *testing.T) *Account {
t.Helper()
account := mockAccount(t, config)
account := mockAccount(t, nil)
account.coin.TstSetMakeBlockchain(func() blockchain.Interface {
return &blockchainMocks.BlockchainMock{
MockRelayFee: func() (btcutil.Amount, error) {
Expand Down Expand Up @@ -137,7 +137,7 @@ func TestGetFeePerKb(t *testing.T) {
wantErr: errp.New("Fee could not be estimated"),
},
}
account := testAccount(t, nil)
account := testAccount(t)
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
gotAmount, err := account.getFeePerKb(tc.args)
Expand Down Expand Up @@ -371,7 +371,7 @@ func TestTxProposal(t *testing.T) {

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
account := testAccount(t, nil)
account := testAccount(t)
if tc.satoshi {
account.coin.SetFormatUnit(coin.BtcUnitSats)
}
Expand All @@ -388,3 +388,34 @@ func TestTxProposal(t *testing.T) {
})
}
}

func TestActiveTxProposalSelectedUTXOs(t *testing.T) {
account := testAccount(t)
selectedOutPoint := *wire.NewOutPoint(&chainhash.Hash{}, 1)
_, _, _, err := account.TxProposal(&accounts.TxProposalArgs{
RecipientAddress: "myY3Bbvj5mjwqqvubtu5Hfy2nuCeBfvNXL",
Amount: coin.NewSendAmount("0.0001"),
FeeTargetCode: accounts.FeeTargetCodeCustom,
CustomFee: "10",
SelectedUTXOs: map[wire.OutPoint]struct{}{
selectedOutPoint: {},
},
})
require.NoError(t, err)

selectedUTXOs := account.ActiveTxProposalSelectedUTXOs()
require.Len(t, selectedUTXOs, 1)
require.Equal(t, selectedOutPoint.String(), selectedUTXOs[0].OutPoint)
require.NotEmpty(t, selectedUTXOs[0].Address)
}

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)
}
Comment on lines +412 to +421
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.

4 changes: 3 additions & 1 deletion backend/handlers/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ type Backend interface {
Coin(coinpkg.Code) (coinpkg.Coin, error)
Testing() bool
Accounts() backend.AccountsList
PrepareSwap(buyAccountCode, sellAccountCode accountsTypes.Code, routeID, sellAmount string) (*backend.SwapPreparation, error)
PrepareSwap(buyAccountCode, sellAccountCode accountsTypes.Code, routeID, sellAmount string, selectedUTXOs []string) (*backend.SwapPreparation, error)
SwapAccounts() (backend.SwapAccounts, error)
SwapStatus() backend.SwapStatus
AccountsByKeystore() (backend.KeystoresAccountsListMap, error)
Expand Down Expand Up @@ -1855,6 +1855,7 @@ func (handlers *Handlers) postSwapSign(r *http.Request) interface{} {
RouteID string `json:"routeId"`
SellAccountCode accountsTypes.Code `json:"sellAccountCode"`
SellAmount string `json:"sellAmount"`
SelectedUTXOs []string `json:"selectedUTXOs"`
}

if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
Expand All @@ -1877,6 +1878,7 @@ func (handlers *Handlers) postSwapSign(r *http.Request) interface{} {
request.SellAccountCode,
request.RouteID,
request.SellAmount,
request.SelectedUTXOs,
)
if err != nil {
return result{Success: false, ErrorMessage: err.Error()}
Expand Down
6 changes: 4 additions & 2 deletions backend/swap.go
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,7 @@ func (backend *Backend) accountHasNonZeroBalance(accountCode accountsTypes.Code)
func (backend *Backend) PrepareSwap(
buyAccountCode, sellAccountCode accountsTypes.Code,
routeID, sellAmount string,
selectedUTXOs []string,
) (*SwapPreparation, error) {
if err := backend.activateSwapBuyAccount(buyAccountCode); err != nil {
return nil, err
Expand Down Expand Up @@ -374,7 +375,7 @@ func (backend *Backend) PrepareSwap(
if !slip24HasCoinPurchase(paymentRequest) {
return nil, errp.New("Missing coinPurchase payment request memo")
}
txInput, err := swapSignTxInput(paymentRequest, sellAccount.Coin(), destinationDerivation)
txInput, err := swapSignTxInput(paymentRequest, sellAccount.Coin(), destinationDerivation, selectedUTXOs)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -529,6 +530,7 @@ func swapSignTxInput(
paymentRequest *paymentrequest.Slip24,
sellCoin coinpkg.Coin,
destinationDerivation *paymentrequest.Slip24AddressDerivation,
selectedUTXOs []string,
) (SwapSignTxInput, error) {
if paymentRequest == nil {
return SwapSignTxInput{}, errp.New("Missing payment request")
Expand All @@ -546,7 +548,7 @@ func swapSignTxInput(
Amount: amount,
UseHighestFee: true,
SendAll: "no",
SelectedUTXOS: []string{},
SelectedUTXOS: selectedUTXOs,
PaymentRequest: frontendPaymentRequest(paymentRequest, destinationDerivation),
}, nil
}
Expand Down
5 changes: 3 additions & 2 deletions backend/swap_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -359,10 +359,11 @@ func TestSwapSignTxInputUsesSignedOutput(t *testing.T) {
Eth: &paymentrequest.Slip24EthAddressDerivation{
Keypath: []uint32{2147483692, 2147483708, 2147483648, 0, 0},
},
})
}, []string{"txid:1"})
require.NoError(t, err)
require.Equal(t, "1GqULdYGDRfF3w85yGmEq8LTWecpKn8JMJ", txInput.Address)
require.Equal(t, "100000000", txInput.Amount)
require.Equal(t, []string{"txid:1"}, txInput.SelectedUTXOS)
require.NotNil(t, txInput.PaymentRequest)
require.NotNil(t, txInput.PaymentRequest.Memos[0].CoinPurchase)
require.NotNil(t, txInput.PaymentRequest.Memos[0].CoinPurchase.AddressDerivation)
Expand Down Expand Up @@ -411,7 +412,7 @@ func TestSwapSignTxInputUsesBTCDestinationDerivation(t *testing.T) {
Keypath: []uint32{2147483697, 2147483648, 2147483648, 0, 5},
ScriptType: "p2wpkh",
},
})
}, nil)
require.NoError(t, err)
require.NotNil(t, txInput.PaymentRequest)
require.NotNil(t, txInput.PaymentRequest.Memos[0].CoinPurchase)
Expand Down
21 changes: 21 additions & 0 deletions frontends/web/src/api/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,11 @@ export type TTxInput = {
}
);

export type TSelectedUTXO = {
outPoint: string;
address: string;
};
Comment on lines +352 to +355
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).


export type TTxProposalErrorCode =
| 'accountNotSynced'
| 'feeTooLow'
Expand All @@ -361,6 +366,7 @@ export type TTxProposalResult = {
amount: TAmountWithConversions;
fee: TAmountWithConversions;
recipientDisplayAddress: string;
selectedUTXOs?: TSelectedUTXO[];
success: true;
total: TAmountWithConversions;
} | {
Expand Down Expand Up @@ -437,6 +443,21 @@ export const getUTXOs = (code: AccountCode): Promise<TUTXO[]> => {
return apiGet(`account/${code}/utxos`);
};

type TUTXOsAmount = {
success: true;
amount: TAmountWithConversions;
} | {
success: false;
errorMessage: string;
};

export const getUTXOsAmount = (
code: AccountCode,
selectedUTXOs: string[],
): Promise<TUTXOsAmount> => {
return apiPost(`account/${code}/utxos/amount`, { selectedUTXOS: selectedUTXOs });
};

type TSecureOutput = {
hasSecureOutput: boolean;
optional: boolean;
Expand Down
1 change: 1 addition & 0 deletions frontends/web/src/api/swap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ export const getSwapQuote = (
export type TSwapSignRequest = {
buyAccountCode: AccountCode;
routeId: string;
selectedUTXOs?: string[];
sellAccountCode: AccountCode;
sellAmount: string;
};
Expand Down
19 changes: 18 additions & 1 deletion frontends/web/src/locales/en/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -2121,7 +2121,24 @@
"recommended": "Recommended",
"route": "Swap route",
"routesPlaceholder": "Routes will appear here after entering an amount",
"unexpectedError": "Some unexpected error occurred."
"unexpectedError": "Some unexpected error occurred.",
"utxoSelection": {
"automatic": {
"description": "BitBoxApp selects UTXOs automatically. The selected inputs are shown before you confirm the swap.",
"disabledDescription": "BitBoxApp selects UTXOs automatically. Manual selection is available when coin control is enabled.",
"select": "Select UTXOs",
"selectedTitle": "Automatically selected UTXOs",
"title": "Automatic UTXO selection"
},
"manual": {
"change": "Change UTXOs",
"clear": "Use automatic selection",
"description": "{{count}} UTXO selected. Only selected UTXOs are available to fund the swap and network fee.",
"selectedTitle": "Manually selected UTXOs",
"title": "Manual UTXO selection"
},
"sendSelectedCoins": "Send selected coins"
}
},
"transactions": {
"errorLoadTransactions": "There was an error loading the transactions",
Expand Down
Loading