diff --git a/backend/coins/btc/account.go b/backend/coins/btc/account.go index 8efc6cc346..704e9fe9de 100644 --- a/backend/coins/btc/account.go +++ b/backend/coins/btc/account.go @@ -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( diff --git a/backend/coins/btc/handlers/handlers.go b/backend/coins/btc/handlers/handlers.go index 9302a4d5b8..74983b36e2 100644 --- a/backend/coins/btc/handlers/handlers.go +++ b/backend/coins/btc/handlers/handlers.go @@ -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") @@ -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 { @@ -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 { @@ -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) { @@ -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 } diff --git a/backend/coins/btc/transaction.go b/backend/coins/btc/transaction.go index a2c7147d7f..9c2587652e 100644 --- a/backend/coins/btc/transaction.go +++ b/backend/coins/btc/transaction.go @@ -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 +} diff --git a/backend/coins/btc/transaction_test.go b/backend/coins/btc/transaction_test.go index d1e23ee593..69a51c2116 100644 --- a/backend/coins/btc/transaction_test.go +++ b/backend/coins/btc/transaction_test.go @@ -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) { @@ -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) @@ -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) } @@ -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) +} diff --git a/backend/handlers/handlers.go b/backend/handlers/handlers.go index 3b45215231..a15fe89aa6 100644 --- a/backend/handlers/handlers.go +++ b/backend/handlers/handlers.go @@ -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) @@ -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 { @@ -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()} diff --git a/backend/swap.go b/backend/swap.go index 3a45129e15..252949d991 100644 --- a/backend/swap.go +++ b/backend/swap.go @@ -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 @@ -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 } @@ -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") @@ -546,7 +548,7 @@ func swapSignTxInput( Amount: amount, UseHighestFee: true, SendAll: "no", - SelectedUTXOS: []string{}, + SelectedUTXOS: selectedUTXOs, PaymentRequest: frontendPaymentRequest(paymentRequest, destinationDerivation), }, nil } diff --git a/backend/swap_test.go b/backend/swap_test.go index 5119543dcc..9218d2942a 100644 --- a/backend/swap_test.go +++ b/backend/swap_test.go @@ -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) @@ -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) diff --git a/frontends/web/src/api/account.ts b/frontends/web/src/api/account.ts index cd1b909998..e12bccb037 100644 --- a/frontends/web/src/api/account.ts +++ b/frontends/web/src/api/account.ts @@ -349,6 +349,11 @@ export type TTxInput = { } ); +export type TSelectedUTXO = { + outPoint: string; + address: string; +}; + export type TTxProposalErrorCode = | 'accountNotSynced' | 'feeTooLow' @@ -361,6 +366,7 @@ export type TTxProposalResult = { amount: TAmountWithConversions; fee: TAmountWithConversions; recipientDisplayAddress: string; + selectedUTXOs?: TSelectedUTXO[]; success: true; total: TAmountWithConversions; } | { @@ -437,6 +443,21 @@ export const getUTXOs = (code: AccountCode): Promise => { return apiGet(`account/${code}/utxos`); }; +type TUTXOsAmount = { + success: true; + amount: TAmountWithConversions; +} | { + success: false; + errorMessage: string; +}; + +export const getUTXOsAmount = ( + code: AccountCode, + selectedUTXOs: string[], +): Promise => { + return apiPost(`account/${code}/utxos/amount`, { selectedUTXOS: selectedUTXOs }); +}; + type TSecureOutput = { hasSecureOutput: boolean; optional: boolean; diff --git a/frontends/web/src/api/swap.ts b/frontends/web/src/api/swap.ts index 5d25a655fa..0f6cca3ca8 100644 --- a/frontends/web/src/api/swap.ts +++ b/frontends/web/src/api/swap.ts @@ -77,6 +77,7 @@ export const getSwapQuote = ( export type TSwapSignRequest = { buyAccountCode: AccountCode; routeId: string; + selectedUTXOs?: string[]; sellAccountCode: AccountCode; sellAmount: string; }; diff --git a/frontends/web/src/locales/en/app.json b/frontends/web/src/locales/en/app.json index 655c4f8e3e..ab31dcd3c8 100644 --- a/frontends/web/src/locales/en/app.json +++ b/frontends/web/src/locales/en/app.json @@ -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", diff --git a/frontends/web/src/routes/account/send/components/confirm/confirm.module.css b/frontends/web/src/routes/account/send/components/confirm/confirm.module.css index 6520b9fdca..f87c70959b 100644 --- a/frontends/web/src/routes/account/send/components/confirm/confirm.module.css +++ b/frontends/web/src/routes/account/send/components/confirm/confirm.module.css @@ -16,17 +16,6 @@ color: var(--color-secondary); } -.confirmItem ul { - list-style-type: square; - margin: 0; - margin-top: var(--space-quarter); - padding-left: var(--space-half); -} - -.addressGroup { - margin-top: var(--space-quarter); -} - .unit { color: var(--color-secondary); font-size: var(--size-default); diff --git a/frontends/web/src/routes/account/send/components/confirm/confirm.tsx b/frontends/web/src/routes/account/send/components/confirm/confirm.tsx index 775ed1c06e..7b783ebfb2 100644 --- a/frontends/web/src/routes/account/send/components/confirm/confirm.tsx +++ b/frontends/web/src/routes/account/send/components/confirm/confirm.tsx @@ -11,23 +11,9 @@ import { Message } from '@/components/message/message'; import { PointToBitBox02 } from '@/components/icon'; import { FiatValue } from '@/components/amount/fiat-value'; import { AmountWithUnit } from '@/components/amount/amount-with-unit'; +import { SelectedUTXOs } from './selected-utxos'; import style from './confirm.module.css'; -type TUTXOsByAddress = { - [address: string]: string[]; -}; - -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; -}; - type TransactionDetails = { selectedReceiverAccountNumber?: number; selectedReceiverAccountName?: string; @@ -155,25 +141,7 @@ export const ConfirmSend = ({ {/* Selected UTXOs grouped by address */} { hasSelectedUTXOs && ( - - {t('send.confirm.selected-coins')} - -
- { Object.entries(groupUTXOsByAddress(selectedUTXOs)).map(([address, outpoints]) => ( -
-
- {address} -
-
    - {outpoints.map((outpoint) => ( -
  • - {outpoint} -
  • - ))} -
-
- )) } -
+
)} diff --git a/frontends/web/src/routes/account/send/components/confirm/selected-utxos.module.css b/frontends/web/src/routes/account/send/components/confirm/selected-utxos.module.css new file mode 100644 index 0000000000..604edcbd65 --- /dev/null +++ b/frontends/web/src/routes/account/send/components/confirm/selected-utxos.module.css @@ -0,0 +1,24 @@ +.address { + color: var(--color-secondary); + font-family: var(--font-family-monospace); + overflow-wrap: anywhere; +} + +.addressGroup { + margin-top: var(--space-quarter); +} + +.label { + color: var(--color-secondary); +} + +.selectedUTXOs ul { + list-style-type: square; + margin: 0; + margin-top: var(--space-quarter); + padding-left: var(--space-half); +} + +.value { + overflow-wrap: anywhere; +} diff --git a/frontends/web/src/routes/account/send/components/confirm/selected-utxos.tsx b/frontends/web/src/routes/account/send/components/confirm/selected-utxos.tsx new file mode 100644 index 0000000000..75eae8867a --- /dev/null +++ b/frontends/web/src/routes/account/send/components/confirm/selected-utxos.tsx @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { useTranslation } from 'react-i18next'; +import type { TSelectedUTXOs } from '../../utxos'; +import style from './selected-utxos.module.css'; + +type TUTXOsByAddress = { + [address: string]: string[]; +}; + +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; +}; + +type TProps = { + selectedUTXOs: TSelectedUTXOs; + title?: string; +}; + +export const SelectedUTXOs = ({ + selectedUTXOs, + title, +}: TProps) => { + const { t } = useTranslation(); + const groupedUTXOs = Object.entries(groupUTXOsByAddress(selectedUTXOs)); + + if (groupedUTXOs.length === 0) { + return null; + } + + return ( +
+ + {title ?? t('send.confirm.selected-coins')} + +
+ {groupedUTXOs.map(([address, outpoints]) => ( +
+
+ {address || t('generic.unknown')} +
+
    + {outpoints.map((outpoint) => ( +
  • + {outpoint} +
  • + ))} +
+
+ ))} +
+
+ ); +}; diff --git a/frontends/web/src/routes/market/swap/components/swap-confirm.tsx b/frontends/web/src/routes/market/swap/components/swap-confirm.tsx index 7c79eb0757..263f02ff6e 100644 --- a/frontends/web/src/routes/market/swap/components/swap-confirm.tsx +++ b/frontends/web/src/routes/market/swap/components/swap-confirm.tsx @@ -10,19 +10,25 @@ import { PointToBitBox02 } from '@/components/icon'; import { Column, Grid } from '@/components/layout'; import { FiatValue } from '@/components/amount/fiat-value'; import style from './swap-confirm.module.css'; +import { SelectedUTXOs } from '@/routes/account/send/components/confirm/selected-utxos'; +import type { TSelectedUTXOs } from '@/routes/account/send/utxos'; type TProps = { isConfirming: boolean; expectedOutput: TAmountWithConversions; feeAmount: TAmountWithConversions; + selectedUTXOs?: TSelectedUTXOs; sellAmount: TAmountWithConversions; + utxoSelectionMode?: 'automatic' | 'manual'; }; export const ConfirmSwap = ({ isConfirming, expectedOutput, feeAmount, + selectedUTXOs, sellAmount, + utxoSelectionMode, }: TProps) => { const { t } = useTranslation(); const isSmall = useMediaQuery('(max-width: 320px)'); @@ -73,6 +79,14 @@ export const ConfirmSwap = ({ enableRotateUnit /> + {utxoSelectionMode && selectedUTXOs && Object.keys(selectedUTXOs).length > 0 && ( + + + + )} {t('generic.receiveWithoutCoinCode')} diff --git a/frontends/web/src/routes/market/swap/swap.module.css b/frontends/web/src/routes/market/swap/swap.module.css index 35f43bff0d..b7c8061ec7 100644 --- a/frontends/web/src/routes/market/swap/swap.module.css +++ b/frontends/web/src/routes/market/swap/swap.module.css @@ -1,5 +1,6 @@ .row { display: flex; + gap: var(--space-half); justify-content: space-between; margin-bottom: var(--space-quarter); } @@ -29,6 +30,11 @@ margin-bottom: var(--space-half); } +.selectedCoinsCheckbox { + margin-bottom: var(--space-half); + margin-top: var(--space-quarter); +} + .flipContainer { text-align: center; } @@ -74,4 +80,4 @@ .unit { font-size: var(--size-smaller); margin-left: .4ch; -} \ No newline at end of file +} diff --git a/frontends/web/src/routes/market/swap/swap.test.tsx b/frontends/web/src/routes/market/swap/swap.test.tsx index e5a29ec87a..3fd366c2dc 100644 --- a/frontends/web/src/routes/market/swap/swap.test.tsx +++ b/frontends/web/src/routes/market/swap/swap.test.tsx @@ -40,6 +40,20 @@ vi.mock('./components/swap-confirm', () => ({ vi.mock('./components/swap-result', () => ({ SwapResult: () => null, })); +vi.mock('@/routes/account/send/coin-control', () => ({ + CoinControl: ({ + onSelectedUTXOsChange, + }: { + onSelectedUTXOsChange: (selectedUTXOs: Record) => void; + }) => ( + + ), +})); vi.mock('./components/input-with-account-selector', () => ({ InputWithAccountSelector: ({ id, @@ -70,6 +84,7 @@ vi.mock('@/api/account', async (importOriginal) => { return { ...actual, getBalance: vi.fn(), + getUTXOsAmount: vi.fn(), hasSwapPaymentRequest: vi.fn(), proposeTx: vi.fn(), sendTx: vi.fn(), @@ -195,6 +210,15 @@ describe('routes/market/swap', () => { }); vi.mocked(accountApi.getBalance).mockResolvedValue({ success: false }); + vi.mocked(accountApi.getUTXOsAmount).mockResolvedValue({ + success: true, + amount: { + amount: '0.5', + conversions: {}, + estimated: false, + unit: 'BTC', + }, + }); vi.mocked(coinsApi.parseExternalBtcAmount).mockResolvedValue({ success: false, amount: '' }); vi.mocked(swapApi.getSwapAccounts).mockResolvedValue({ success: true, @@ -434,4 +458,78 @@ describe('routes/market/swap', () => { expect(swapButton).toBeDisabled(); expect(screen.queryByText('THORChain + Mayachain')).not.toBeInTheDocument(); }); + + it('prefills sell amount from selected UTXOs when send selected coins is checked', async () => { + const user = userEvent.setup(); + + render( + + + + + , + ); + + await user.click(await screen.findByTestId('agree-swap-terms')); + await user.click(screen.getByRole('button', { name: 'Coin control' })); + + await waitFor(() => { + expect(accountApi.getUTXOsAmount).toHaveBeenCalledWith('btc-account', ['txid:0']); + }); + + await user.click(await screen.findByLabelText('Send selected coins')); + + expect(screen.getByTestId('swapSendAmount')).toHaveTextContent('0.5'); + await waitFor(() => { + expect(swapApi.getSwapQuote).toHaveBeenCalledWith({ + buyCoinCode: 'eth', + sellAccountCode: 'btc-account', + sellAmount: '0.5', + sellCoinCode: 'btc', + }); + }); + }); + + it('restores manual sell amount when send selected coins is unchecked', async () => { + const user = userEvent.setup(); + + render( + + + + + , + ); + + await user.click(await screen.findByTestId('agree-swap-terms')); + await user.type(await screen.findByLabelText('swapSendAmount'), '1.2'); + await user.click(screen.getByRole('button', { name: 'Coin control' })); + const sendSelectedCoins = await screen.findByLabelText('Send selected coins'); + + await user.click(sendSelectedCoins); + expect(screen.getByTestId('swapSendAmount')).toHaveTextContent('0.5'); + + await user.click(sendSelectedCoins); + expect(await screen.findByLabelText('swapSendAmount')).toHaveValue('1.2'); + }); }); diff --git a/frontends/web/src/routes/market/swap/swap.tsx b/frontends/web/src/routes/market/swap/swap.tsx index 7545630693..ba21136a2c 100644 --- a/frontends/web/src/routes/market/swap/swap.tsx +++ b/frontends/web/src/routes/market/swap/swap.tsx @@ -5,15 +5,17 @@ import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { getBalance, + getUTXOsAmount, hasSwapPaymentRequest, proposeTx, sendTx, - TBalance, type AccountCode, type TAccount, type TAmountWithConversions, type CoinUnit, type TSendTx, + type TSelectedUTXO, + type TBalance, } from '@/api/account'; import { convertToCurrency, parseExternalBtcAmount } from '@/api/coins'; import { @@ -33,13 +35,15 @@ import { Guide } from '@/components/guide/guide'; import { Entry } from '@/components/guide/entry'; import { alertUser } from '@/components/alert/Alert'; import { Message } from '@/components/message/message'; -import { Button, Label } from '@/components/forms'; +import { Button, Checkbox, Label } from '@/components/forms'; import { BackButton } from '@/components/backbutton/backbutton'; import { AmountWithUnit } from '@/components/amount/amount-with-unit'; import { ArrowSwap } from '@/components/icon'; import { SpinnerRingAnimated } from '@/components/spinner/SpinnerAnimation'; import { findAccount, getDisplayedCoinUnit, isBitcoinOnly } from '@/routes/account/utils'; import { useLoad } from '@/hooks/api'; +import { CoinControl } from '@/routes/account/send/coin-control'; +import type { TSelectedUTXOs } from '@/routes/account/send/utxos'; import { InputWithAccountSelector } from './components/input-with-account-selector'; import { SwapServiceSelector } from './components/swap-service-selector'; import { ConfirmSwap } from './components/swap-confirm'; @@ -86,6 +90,12 @@ const getSwapDisplayAmount = async ( }; }; +type TUTXOSelectionMode = 'automatic' | 'manual'; + +const selectedUTXOMap = (selectedUTXOs: TSelectedUTXO[] | undefined): TSelectedUTXOs => ( + Object.fromEntries((selectedUTXOs ?? []).map(utxo => [utxo.outPoint, utxo.address])) +); + export const Swap = ({ accounts, }: Props) => { @@ -108,6 +118,9 @@ export const Swap = ({ const [sellAccountCode, setSellAccountCode] = useState(); const [sellAmount, setSellAmount] = useState(''); const [maxSellAmount, setMaxSellAmount] = useState(); + const [selectedSwapUTXOs, setSelectedSwapUTXOs] = useState({}); + const [selectedSwapUTXOsAmount, setSelectedSwapUTXOsAmount] = useState(''); + const [sellSelectedUTXOs, setSellSelectedUTXOs] = useState(false); // Receive const [buyAccountCode, setBuyAccountCode] = useState(); @@ -119,7 +132,9 @@ export const Swap = ({ const [confirmDetails, setConfirmDetails] = useState<{ expectedOutput: TAmountWithConversions; feeAmount: TAmountWithConversions; + selectedUTXOs: TSelectedUTXOs; sellAmount: TAmountWithConversions; + utxoSelectionMode?: TUTXOSelectionMode; }>(); const [result, setResult] = useState(); const [canFlip, setCanFlip] = useState(false); @@ -134,6 +149,7 @@ export const Swap = ({ const [isConfirmInFlight, setIsConfirmInFlight] = useState(false); // Prevents double-submit before the button disabled state has re-rendered. const confirmInFlightRef = useRef(false); + const manualSellAmountRef = useRef(''); const sellAccount = useMemo( () => sellAccountCode && sellAccounts @@ -141,6 +157,10 @@ export const Swap = ({ : undefined, [sellAccounts, sellAccountCode], ); + const fullSellAccount = useMemo( + () => sellAccountCode ? findAccount(accounts, sellAccountCode) : undefined, + [accounts, sellAccountCode], + ); const buyAccount = useMemo( () => buyAccountCode && buyAccounts ? findAccount(buyAccounts, buyAccountCode) @@ -161,6 +181,7 @@ export const Swap = ({ const config = useLoad(getConfig); const { agreedTerms, setAgreedTerms } = useVendorTerms(!!config?.frontend?.skipSwapkitDisclaimer); + const selectedSwapUTXOCount = Object.keys(selectedSwapUTXOs).length; const isSameCoinAccount = ( candidate: TSwapAccount, @@ -231,12 +252,30 @@ export const Swap = ({ const handleFlipAccounts = () => { if (buyAccountCode && sellAccountCode) { clearQuoteState(); + manualSellAmountRef.current = expectedOutput; setSellAccountCode(buyAccountCode); setSellAmount(expectedOutput); setBuyAccountCode(sellAccountCode); } }; + const handleSelectedUTXOsChange = useCallback((selectedUTXOs: TSelectedUTXOs) => { + setSelectedSwapUTXOs(selectedUTXOs); + setSelectedSwapUTXOsAmount(''); + if (sellSelectedUTXOs) { + setSellAmount(''); + } + if (Object.keys(selectedUTXOs).length === 0) { + setSellSelectedUTXOs(false); + setSellAmount(manualSellAmountRef.current); + } + }, [sellSelectedUTXOs]); + + const handleSellAmountChange = (amount: string) => { + manualSellAmountRef.current = amount; + setSellAmount(amount); + }; + // update max swappable amount (total coins of the account) useEffect(() => { if (sellAccountCode) { @@ -244,6 +283,51 @@ export const Swap = ({ } }, [sellAccountCode]); + useEffect(() => { + setSelectedSwapUTXOs({}); + setSelectedSwapUTXOsAmount(''); + setSellSelectedUTXOs(false); + manualSellAmountRef.current = ''; + }, [sellAccountCode]); + + useEffect(() => { + let canceled = false; + const outpoints = Object.keys(selectedSwapUTXOs); + if (!sellAccountCode || outpoints.length === 0) { + setSelectedSwapUTXOsAmount(''); + setSellSelectedUTXOs(false); + return; + } + getUTXOsAmount(sellAccountCode, outpoints) + .then(response => { + if (canceled) { + return; + } + if (!response.success) { + setSelectedSwapUTXOsAmount(''); + setSellSelectedUTXOs(false); + return; + } + setSelectedSwapUTXOsAmount(response.amount.amount); + }) + .catch(() => { + if (canceled) { + return; + } + setSelectedSwapUTXOsAmount(''); + setSellSelectedUTXOs(false); + }); + return () => { + canceled = true; + }; + }, [selectedSwapUTXOs, sellAccountCode]); + + useEffect(() => { + if (sellSelectedUTXOs) { + setSellAmount(selectedSwapUTXOsAmount); + } + }, [selectedSwapUTXOsAmount, sellSelectedUTXOs]); + // fetch swap quotes whenever the selected pair or sell amount changes. useEffect(() => { let isCancelled = false; @@ -428,6 +512,7 @@ export const Swap = ({ const response = await signSwap({ buyAccountCode, routeId: selectedRouteId, + selectedUTXOs: sellSelectedUTXOs ? Object.keys(selectedSwapUTXOs) : [], sellAccountCode, sellAmount, }); @@ -463,6 +548,7 @@ export const Swap = ({ fiatConversions.filter(entry => entry !== undefined), ); + const proposedSelectedUTXOs = selectedUTXOMap(proposal.selectedUTXOs); setConfirmDetails({ expectedOutput: { amount: expectedOutput, @@ -471,7 +557,11 @@ export const Swap = ({ estimated: false, }, feeAmount: proposal.fee, + selectedUTXOs: proposedSelectedUTXOs, sellAmount: proposal.amount, + utxoSelectionMode: Object.keys(proposedSelectedUTXOs).length === 0 + ? undefined + : sellSelectedUTXOs ? 'manual' : 'automatic', }); setIsConfirming(true); @@ -595,6 +685,13 @@ export const Swap = ({ amount={maxSellAmount.available} /> )} + {fullSellAccount && ( + + )} {!sellAccounts || !sellAccountCode ? ( @@ -606,11 +703,32 @@ export const Swap = ({ isAccountDisabled={account => isSameCoinAccount(account, buyAccount)} onChangeAccountCode={setSellAccountCode} value={sellAmount} - onChangeValue={setSellAmount} + onChangeValue={sellSelectedUTXOs ? undefined : handleSellAmountChange} placeholder="0" placeholderFiat={placeholderFiat} + readOnlyAmount={sellSelectedUTXOs} /> )} + {selectedSwapUTXOCount > 0 && ( +
+ { + const checked = event.target.checked; + setSellSelectedUTXOs(checked); + if (checked) { + manualSellAmountRef.current = sellAmount; + setSellAmount(selectedSwapUTXOsAmount); + } else { + setSellAmount(manualSellAmountRef.current); + } + }} + label={t('swap.utxoSelection.sendSelectedCoins')} + /> +
+ )}