diff --git a/backend/swap.go b/backend/swap.go index 3a45129e15..d923fe46b6 100644 --- a/backend/swap.go +++ b/backend/swap.go @@ -371,8 +371,12 @@ func (backend *Backend) PrepareSwap( if len(paymentRequest.Outputs) != 1 { return nil, errp.New("Missing or multiple payment request output unsupported") } - if !slip24HasCoinPurchase(paymentRequest) { - return nil, errp.New("Missing coinPurchase payment request memo") + if err := validateSwapExpectedBuyAmount( + paymentRequest, + swapResponse.ExpectedBuyAmount, + buyAccount.Coin(), + ); err != nil { + return nil, err } txInput, err := swapSignTxInput(paymentRequest, sellAccount.Coin(), destinationDerivation) if err != nil { @@ -471,16 +475,48 @@ func (backend *Backend) appendERC20SwapAccounts( return sellAccounts, buyAccounts } -func slip24HasCoinPurchase(paymentRequest *paymentrequest.Slip24) bool { +func validateSwapExpectedBuyAmount( + paymentRequest *paymentrequest.Slip24, + expectedBuyAmount string, + buyCoin coinpkg.Coin, +) error { if paymentRequest == nil { - return false + return errp.New("Missing payment request") } + var coinPurchase *paymentrequest.Slip24CoinPurchase for _, memo := range paymentRequest.Memos { - if memo.CoinPurchase != nil { - return true + if memo.Type != "coinPurchase" { + continue } + if memo.CoinPurchase == nil { + return errp.New("Missing coinPurchase payment request memo payload") + } + if coinPurchase != nil { + return errp.New("Multiple coinPurchase payment request memos unsupported") + } + coinPurchase = memo.CoinPurchase } - return false + if coinPurchase == nil { + return errp.New("Missing coinPurchase payment request memo") + } + + signedAmountParts := strings.Fields(coinPurchase.Amount) + if len(signedAmountParts) != 2 || signedAmountParts[1] != buyCoin.Unit(false) { + return errp.New("Invalid coinPurchase payment request amount") + } + unitFactor := coinpkg.DecimalsExp(buyCoin, false) + signedAmount, err := coinpkg.NewAmountFromString(signedAmountParts[0], unitFactor) + if err != nil || signedAmount.BigInt().Sign() <= 0 { + return errp.New("Invalid coinPurchase payment request amount") + } + expectedAmount, err := coinpkg.NewAmountFromString(strings.TrimSpace(expectedBuyAmount), unitFactor) + if err != nil || expectedAmount.BigInt().Sign() <= 0 { + return errp.New("Invalid expected buy amount") + } + if signedAmount.BigInt().Cmp(expectedAmount.BigInt()) != 0 { + return errp.New("Expected buy amount does not match signed payment request") + } + return nil } func frontendPaymentRequest( diff --git a/backend/swap_test.go b/backend/swap_test.go index cf8e88b295..37b3c232d0 100644 --- a/backend/swap_test.go +++ b/backend/swap_test.go @@ -322,6 +322,84 @@ func TestValidateSwapAccountSupportedRejectsTestnetAccounts(t *testing.T) { } } +func TestValidateSwapExpectedBuyAmount(t *testing.T) { + buyCoin := &coinMocks.CoinMock{ + DecimalsFunc: func(bool) uint { return 6 }, + UnitFunc: func(bool) string { return "USDC" }, + } + paymentRequest := func(memos ...paymentrequest.Slip24Memo) *paymentrequest.Slip24 { + return &paymentrequest.Slip24{Memos: memos} + } + coinPurchaseMemo := func(amount string) paymentrequest.Slip24Memo { + return paymentrequest.Slip24Memo{ + Type: "coinPurchase", + CoinPurchase: &paymentrequest.Slip24CoinPurchase{ + Amount: amount, + }, + } + } + + testCases := []struct { + name string + expectedBuyAmount string + paymentRequest *paymentrequest.Slip24 + expectedError string + }{ + { + name: "missing payment request", + expectedBuyAmount: "1.23", + expectedError: "Missing payment request", + }, + { + name: "matching normalized decimal", + expectedBuyAmount: "1.230000", + paymentRequest: paymentRequest(coinPurchaseMemo("1.23 USDC")), + }, + { + name: "mismatching amount", + expectedBuyAmount: "1.23", + paymentRequest: paymentRequest(coinPurchaseMemo("1.24 USDC")), + expectedError: "Expected buy amount does not match signed payment request", + }, + { + name: "mismatching unit", + expectedBuyAmount: "1.23", + paymentRequest: paymentRequest(coinPurchaseMemo("1.23 USDT")), + expectedError: "Invalid coinPurchase payment request amount", + }, + { + name: "sub-unit precision", + expectedBuyAmount: "1.2300001", + paymentRequest: paymentRequest(coinPurchaseMemo("1.2300001 USDC")), + expectedError: "Invalid coinPurchase payment request amount", + }, + { + name: "missing semantic memo", + expectedBuyAmount: "1.23", + paymentRequest: paymentRequest(paymentrequest.Slip24Memo{ + Type: "text", + CoinPurchase: &paymentrequest.Slip24CoinPurchase{Amount: "1.23 USDC"}, + }), + expectedError: "Missing coinPurchase payment request memo", + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + err := validateSwapExpectedBuyAmount( + testCase.paymentRequest, + testCase.expectedBuyAmount, + buyCoin, + ) + if testCase.expectedError == "" { + require.NoError(t, err) + return + } + require.EqualError(t, err, testCase.expectedError) + }) + } +} + func TestSwapSignTxInputUsesSignedOutput(t *testing.T) { sellCoin := btc.NewCoin( coinpkg.CodeBTC, diff --git a/frontends/web/src/routes/market/swap/swap.test.tsx b/frontends/web/src/routes/market/swap/swap.test.tsx index 09b4ba5312..aae47f0fc0 100644 --- a/frontends/web/src/routes/market/swap/swap.test.tsx +++ b/frontends/web/src/routes/market/swap/swap.test.tsx @@ -41,7 +41,17 @@ vi.mock('@/components/spinner/SpinnerAnimation', () => ({ SpinnerRingAnimated: () => null, })); vi.mock('./components/swap-confirm', () => ({ - ConfirmSwap: () => null, + ConfirmSwap: ({ + expectedOutput, + }: { + expectedOutput: { amount: string; unit: string }; + }) => ( +