Skip to content
Merged
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
36 changes: 25 additions & 11 deletions packages/thirdweb/src/react/core/hooks/usePaymentMethods.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useQuery } from "@tanstack/react-query";
import type { Quote } from "../../../bridge/index.js";
import { ApiError } from "../../../bridge/types/Errors.js";
import type { Token, TokenWithPrices } from "../../../bridge/types/Token.js";
import type { TokenWithPrices } from "../../../bridge/types/Token.js";
import type { ThirdwebClient } from "../../../client/client.js";
import { getThirdwebBaseUrl } from "../../../utils/domains.js";
import { getClientFetch } from "../../../utils/fetch.js";
Expand Down Expand Up @@ -29,7 +29,7 @@
* ```
*/
export function usePaymentMethods(options: {
destinationToken: Token;
destinationToken: TokenWithPrices;
destinationAmount: string;
client: ThirdwebClient;
payerWallet?: Wallet;
Expand Down Expand Up @@ -65,6 +65,8 @@
"amount",
toUnits(destinationAmount, destinationToken.decimals).toString(),
);
// dont include quotes to speed up the query
url.searchParams.set("includeQuotes", "false");

Check warning on line 69 in packages/thirdweb/src/react/core/hooks/usePaymentMethods.ts

View check run for this annotation

Codecov / codecov/patch

packages/thirdweb/src/react/core/hooks/usePaymentMethods.ts#L69

Added line #L69 was not covered by tests

const clientFetch = getClientFetch(client);
const response = await clientFetch(url.toString());
Expand All @@ -80,8 +82,9 @@

const {
data: allValidOriginTokens,
}: { data: { quote: Quote; balance: string; token: TokenWithPrices }[] } =
await response.json();
}: {

Check warning on line 85 in packages/thirdweb/src/react/core/hooks/usePaymentMethods.ts

View check run for this annotation

Codecov / codecov/patch

packages/thirdweb/src/react/core/hooks/usePaymentMethods.ts#L85

Added line #L85 was not covered by tests
data: { quote?: Quote; balance: string; token: TokenWithPrices }[];
} = await response.json();

Check warning on line 87 in packages/thirdweb/src/react/core/hooks/usePaymentMethods.ts

View check run for this annotation

Codecov / codecov/patch

packages/thirdweb/src/react/core/hooks/usePaymentMethods.ts#L87

Added line #L87 was not covered by tests

// Sort by enough balance to pay THEN gross balance
const validTokenQuotes = allValidOriginTokens.map((s) => ({
Expand All @@ -92,7 +95,7 @@
quote: s.quote,
}));

const sufficientBalanceQuotes = validTokenQuotes
const sortedValidTokenQuotes = validTokenQuotes

Check warning on line 98 in packages/thirdweb/src/react/core/hooks/usePaymentMethods.ts

View check run for this annotation

Codecov / codecov/patch

packages/thirdweb/src/react/core/hooks/usePaymentMethods.ts#L98

Added line #L98 was not covered by tests
.filter((s) => !!s.originToken.prices.USD)
.sort((a, b) => {
return (
Expand All @@ -114,18 +117,29 @@
)
: [];
const finalQuotes = supportedTokens
? sufficientBalanceQuotes.filter((q) =>
? sortedValidTokenQuotes.filter((q) =>

Check warning on line 120 in packages/thirdweb/src/react/core/hooks/usePaymentMethods.ts

View check run for this annotation

Codecov / codecov/patch

packages/thirdweb/src/react/core/hooks/usePaymentMethods.ts#L120

Added line #L120 was not covered by tests
tokensToInclude.find(
(t) =>
t.chainId === q.originToken.chainId &&
t.address.toLowerCase() === q.originToken.address.toLowerCase(),
),
)
: sufficientBalanceQuotes;
return finalQuotes.map((x) => ({
...x,
action: "buy",
}));
: sortedValidTokenQuotes;

Check warning on line 127 in packages/thirdweb/src/react/core/hooks/usePaymentMethods.ts

View check run for this annotation

Codecov / codecov/patch

packages/thirdweb/src/react/core/hooks/usePaymentMethods.ts#L127

Added line #L127 was not covered by tests

const requiredUsdValue =
(destinationToken.prices?.["USD"] ?? 0) * Number(destinationAmount);

Check warning on line 130 in packages/thirdweb/src/react/core/hooks/usePaymentMethods.ts

View check run for this annotation

Codecov / codecov/patch

packages/thirdweb/src/react/core/hooks/usePaymentMethods.ts#L129-L130

Added lines #L129 - L130 were not covered by tests

return finalQuotes.map((x) => {
const tokenUsdValue =
(x.originToken.prices?.["USD"] ?? 0) *
Number(toTokens(x.balance, x.originToken.decimals));
const hasEnoughBalance = tokenUsdValue >= requiredUsdValue;
Comment on lines +129 to +136
Copy link

Choose a reason for hiding this comment

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

Suggested change
const requiredUsdValue =
(destinationToken.prices?.["USD"] ?? 0) * Number(destinationAmount);
return finalQuotes.map((x) => {
const tokenUsdValue =
(x.originToken.prices?.["USD"] ?? 0) *
Number(toTokens(x.balance, x.originToken.decimals));
const hasEnoughBalance = tokenUsdValue >= requiredUsdValue;
return finalQuotes.map((x) => {
let hasEnoughBalance = false;
// Priority 1: Use quote's originAmount if available (most accurate)
if (x.quote) {
hasEnoughBalance = x.balance >= x.quote.originAmount;
} else {
// Priority 2: Fall back to USD comparison only if destination price is available
const destinationUsdPrice = destinationToken.prices?.["USD"];
if (destinationUsdPrice && destinationUsdPrice > 0) {
const requiredUsdValue =
destinationUsdPrice * Number(destinationAmount);
const tokenUsdPrice = x.originToken.prices?.["USD"] || 0;
const tokenUsdValue =
tokenUsdPrice * Number(toTokens(x.balance, x.originToken.decimals));
hasEnoughBalance = tokenUsdValue >= requiredUsdValue;
}
// Priority 3: If destination price is missing, default to false (fail-safe)
}

The balance check calculation is fundamentally flawed: it compares USD values between tokens without accounting for fees, slippage, and price impact. Additionally, if the destination token's USD price is missing or zero, the check incorrectly marks all tokens as having sufficient balance.

View Details

Analysis

Balance check calculation doesn't account for fees and fails with missing destination token price

What fails: The usePaymentMethods hook's hasEnoughBalance calculation (lines 129-136) incorrectly marks tokens as having sufficient balance when destination token price is missing, and ignores fees/slippage when calculating required amount.

How to reproduce:

  1. Payment method with destination token that has no USD price (prices["USD"] = undefined)
  2. Call usePaymentMethods with any token with non-zero balance and price
  3. Result: hasEnoughBalance = true even though destination amount is unknown

What happens:

  • When destinationToken.prices?.["USD"] is undefined, requiredUsdValue = 0
  • Any token with positive balance gets marked as sufficient (hasEnoughBalance = true)
  • Button is enabled allowing user to attempt swap that will fail

Expected behavior: When destination token price is missing, assume insufficient balance (fail-safe). Additionally, when quote data is available, use quote.originAmount (which includes fees from the API's calculation) instead of naive USD comparison.

Why this matters: Users select payment methods that appear to have enough balance but fail at transaction time, resulting in poor UX. Per Bridge Quote documentation, originAmount is "the necessary originAmount to receive the desired destinationAmount" and includes all fees.

Solution implemented:

  1. Use quote.originAmount when available (most accurate, accounts for all fees)
  2. Fall back to USD comparison only if destination price exists and is > 0
  3. Default to hasEnoughBalance = false if destination price is missing (fail-safe)

return {
...x,
action: "buy",
hasEnoughBalance,
};
});

Check warning on line 142 in packages/thirdweb/src/react/core/hooks/usePaymentMethods.ts

View check run for this annotation

Codecov / codecov/patch

packages/thirdweb/src/react/core/hooks/usePaymentMethods.ts#L132-L142

Added lines #L132 - L142 were not covered by tests
},
queryKey: [
"payment-methods",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { trackPayEvent } from "../../../../../analytics/track/pay.js";
import type { Token } from "../../../../../bridge/types/Token.js";
import type { TokenWithPrices } from "../../../../../bridge/types/Token.js";
import { defineChain } from "../../../../../chains/utils.js";
import type { ThirdwebClient } from "../../../../../client/client.js";
import type { SupportedFiatCurrency } from "../../../../../pay/convert/type.js";
Expand All @@ -26,7 +26,7 @@ type PaymentSelectionProps = {
/**
* The destination token to bridge to
*/
destinationToken: Token;
destinationToken: TokenWithPrices;

/**
* The destination amount to bridge
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,7 @@
}: PaymentMethodTokenRowProps) {
const theme = useCustomTheme();

const displayOriginAmount = paymentMethod.quote.originAmount;
const hasEnoughBalance = displayOriginAmount
? paymentMethod.balance >= displayOriginAmount
: false;
const hasEnoughBalance = paymentMethod.hasEnoughBalance;

Check warning on line 50 in packages/thirdweb/src/react/web/ui/Bridge/payment-selection/TokenSelection.tsx

View check run for this annotation

Codecov / codecov/patch

packages/thirdweb/src/react/web/ui/Bridge/payment-selection/TokenSelection.tsx#L50

Added line #L50 was not covered by tests
const currencyPrice = paymentMethod.originToken.prices[currency || "USD"];

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,7 @@ function SwapWidgetContent(
balance: screen.sellTokenBalance,
originToken: screen.sellToken,
action: screen.mode,
hasEnoughBalance: true,
}}
preparedQuote={screen.preparedQuote}
currency={props.currency}
Expand Down
3 changes: 2 additions & 1 deletion packages/thirdweb/src/react/web/ui/Bridge/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ export type PaymentMethod =
payerWallet: Wallet;
originToken: TokenWithPrices;
balance: bigint;
quote: Quote;
quote?: Quote;
hasEnoughBalance: boolean;
}
| {
type: "fiat";
Expand Down
Loading