Skip to content
Closed
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
50 changes: 43 additions & 7 deletions backend/swap.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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(
Expand Down
78 changes: 78 additions & 0 deletions backend/swap_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
74 changes: 73 additions & 1 deletion frontends/web/src/routes/market/swap/swap.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}) => (
<div data-testid="confirm-swap-output">
{expectedOutput.amount}
{' '}
{expectedOutput.unit}
</div>
),
}));
vi.mock('./components/swap-result', () => ({
SwapResult: () => null,
Expand Down Expand Up @@ -266,6 +276,68 @@ describe('routes/market/swap', () => {
});
});

it('uses the final receive amount in the confirmation', async () => {
const user = userEvent.setup();
const amount = (value: string, unit: accountApi.CoinUnit): accountApi.TAmountWithConversions => ({
amount: value,
conversions: {},
estimated: false,
unit,
});

vi.mocked(accountApi.hasSwapPaymentRequest).mockResolvedValue({ success: true });
vi.mocked(swapApi.signSwap).mockResolvedValue({
success: true,
expectedBuyAmount: '1.25',
swapId: 'swap-id',
txInput: {
address: 'deposit-address',
amount: '1',
paymentRequest: null,
selectedUTXOs: [],
sendAll: 'no',
useHighestFee: true,
},
});
vi.mocked(accountApi.proposeTx).mockResolvedValue({
success: true,
amount: amount('1', 'BTC'),
fee: amount('0.0001', 'BTC'),
recipientDisplayAddress: 'deposit-address',
total: amount('1.0001', 'BTC'),
});
vi.mocked(accountApi.sendTx).mockResolvedValue({ success: true, txId: 'tx-id' });

render(
<BackButtonProvider>
<RatesContext.Provider
value={{
activeCurrencies: [],
addToActiveCurrencies: vi.fn(),
btcUnit: 'default',
defaultCurrency: 'USD',
removeFromActiveCurrencies: vi.fn(),
rotateBtcUnit: vi.fn(),
rotateDefaultCurrency: vi.fn(),
updateDefaultCurrency: vi.fn(),
}}>
<MemoryRouter>
<Swap accounts={[sellAccount, buyAccount]} />
</MemoryRouter>
</RatesContext.Provider>
</BackButtonProvider>
);

await user.click(await screen.findByTestId('agree-swap-terms'));
await user.type(await screen.findByLabelText('swapSendAmount'), '1');
await waitFor(() => expect(screen.getByRole('button', { name: 'Swap' })).toBeEnabled());
expect(await screen.findByTestId('swapGetAmount')).toHaveTextContent('1.23');

await user.click(screen.getByRole('button', { name: 'Swap' }));

expect(await screen.findByTestId('confirm-swap-output')).toHaveTextContent('1.25 ETH');
});

it('shows no-route quote errors with display units', async () => {
const user = userEvent.setup();

Expand Down
16 changes: 8 additions & 8 deletions frontends/web/src/routes/market/swap/swap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,6 @@ export const Swap = ({
// Receive
const [buyAccountCode, setBuyAccountCode] = useState<AccountCode | undefined>();
const [expectedOutput, setExpectedOutput] = useState<string>('');
const [expectedOutputUnit, setExpectedOutputUnit] = useState<CoinUnit | undefined>();

// Shows the fullscreen device-confirmation step after the tx proposal is ready.
const [isConfirming, setIsConfirming] = useState<boolean>(false);
Expand Down Expand Up @@ -210,7 +209,6 @@ export const Swap = ({
setRoutes([]);
setSelectedRouteId(undefined);
setExpectedOutput('');
setExpectedOutputUnit(undefined);
setQuoteErrorCode(undefined);
setRouteError(undefined);
}, []);
Expand Down Expand Up @@ -254,7 +252,6 @@ export const Swap = ({
setRoutes([]);
setSelectedRouteId(undefined);
setExpectedOutput('');
setExpectedOutputUnit(undefined);

if (
!sellCoinCode
Expand Down Expand Up @@ -367,7 +364,6 @@ export const Swap = ({
const updateExpectedOutput = async () => {
if (!selectedRoute || !buyAccount) {
setExpectedOutput('');
setExpectedOutputUnit(undefined);
return;
}

Expand All @@ -381,13 +377,11 @@ export const Swap = ({
return;
}
setExpectedOutput(displayAmount.amount);
setExpectedOutputUnit(displayAmount.unit);
};

updateExpectedOutput().catch(() => {
if (!canceled) {
setExpectedOutput(selectedRoute?.expectedBuyAmount || '');
setExpectedOutputUnit(buyAccount?.coinUnit);
}
});

Expand Down Expand Up @@ -446,6 +440,12 @@ export const Swap = ({
return;
}

const finalExpectedOutput = await getSwapDisplayAmount(
response.expectedBuyAmount,
buyAccount.coinCode,
buyAccount.coinUnit,
btcUnit,
);
let expectedOutputConversions: TAmountWithConversions['conversions'];
const fiatConversions = await Promise.all(
activeCurrencies.map(async fiatUnit => {
Expand All @@ -465,9 +465,9 @@ export const Swap = ({

setConfirmDetails({
expectedOutput: {
amount: expectedOutput,
amount: finalExpectedOutput.amount,
conversions: expectedOutputConversions,
unit: expectedOutputUnit || buyAccount.coinUnit,
unit: finalExpectedOutput.unit,
estimated: false,
},
feeAmount: proposal.fee,
Expand Down