diff --git a/.changeset/bridge-origin-balance.md b/.changeset/bridge-origin-balance.md new file mode 100644 index 00000000000..9d429bea86d --- /dev/null +++ b/.changeset/bridge-origin-balance.md @@ -0,0 +1,5 @@ +--- +"thirdweb": patch +--- + +Bridge widgets check the payer's balance against the quoted amount before confirming, and common token errors are decoded without a contract ABI diff --git a/packages/thirdweb/src/react/web/ui/Bridge/payment-details/PaymentDetails.test.tsx b/packages/thirdweb/src/react/web/ui/Bridge/payment-details/PaymentDetails.test.tsx new file mode 100644 index 00000000000..376e4af7288 --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/Bridge/payment-details/PaymentDetails.test.tsx @@ -0,0 +1,136 @@ +// @vitest-environment happy-dom +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { TEST_CLIENT } from "~test/test-clients.js"; +import type { Wallet } from "../../../../../wallets/interfaces/wallet.js"; +import { CustomThemeProvider } from "../../../../core/design-system/CustomThemeProvider.js"; +import type { BridgePrepareResult } from "../../../../core/hooks/useBridgePrepare.js"; +import type { PaymentMethod } from "../types.js"; +import { PaymentDetails } from "./PaymentDetails.js"; + +const { balanceQuery } = vi.hoisted(() => ({ + balanceQuery: { data: undefined as { value: bigint } | undefined }, +})); + +vi.mock("../common/token-balance.js", () => ({ + useTokenBalance: () => balanceQuery, +})); + +vi.mock("../../../../core/hooks/others/useChainQuery.js", () => ({ + useChainsQuery: () => [], +})); + +vi.mock("./PaymentOverview.js", () => ({ + PaymentOverview: () => null, +})); + +const USDC_BASE = { + address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + chainId: 8453, + decimals: 6, + iconUri: "", + name: "USD Coin", + prices: { USD: 1 }, + symbol: "USDC", +} as const; + +const USDC_ARBITRUM = { + ...USDC_BASE, + address: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + chainId: 42161, +} as const; + +const PAYER = "0x0000000000000000000000000000000000000001"; + +const quote = { + destinationAmount: 5_000_000_000n, + estimatedExecutionTimeMs: 60_000, + intent: { + amount: 5_000_000_000n, + destinationChainId: USDC_ARBITRUM.chainId, + destinationTokenAddress: USDC_ARBITRUM.address, + originChainId: USDC_BASE.chainId, + originTokenAddress: USDC_BASE.address, + receiver: PAYER, + sender: PAYER, + }, + originAmount: 5_015_524_386n, + steps: [ + { + destinationAmount: 5_000_000_000n, + destinationToken: USDC_ARBITRUM, + estimatedExecutionTimeMs: 60_000, + originAmount: 5_015_524_386n, + originToken: USDC_BASE, + transactions: [], + }, + ], + type: "buy", +} as unknown as BridgePrepareResult; + +const paymentMethod = (balance: bigint): PaymentMethod => ({ + action: "buy", + balance, + hasEnoughBalance: true, + originToken: USDC_BASE, + payerWallet: { + getAccount: () => ({ address: PAYER }), + } as unknown as Wallet, + type: "wallet", +}); + +const renderDetails = (method: PaymentMethod, onConfirm = vi.fn()) => { + render( + + + , + ); + return onConfirm; +}; + +beforeEach(() => { + balanceQuery.data = undefined; +}); + +describe("PaymentDetails", () => { + it("disables confirm when the wallet balance is below the origin amount", () => { + balanceQuery.data = { value: 5_003_934_909n }; + const onConfirm = renderDetails(paymentMethod(5_003_934_909n)); + + const button = screen.getByRole("button", { + name: "Insufficient Balance", + }); + expect(button.disabled).toBe(true); + fireEvent.click(button); + expect(onConfirm).not.toHaveBeenCalled(); + }); + + it("confirms when the wallet balance covers the origin amount", () => { + balanceQuery.data = { value: 5_015_524_386n }; + const onConfirm = renderDetails(paymentMethod(5_003_934_909n)); + + fireEvent.click(screen.getByRole("button", { name: "Confirm Payment" })); + expect(onConfirm).toHaveBeenCalledOnce(); + }); + + it("uses the payment method balance until the wallet balance loads", () => { + renderDetails(paymentMethod(5_003_934_909n)); + + expect( + screen.getByRole("button", { + name: "Insufficient Balance", + }).disabled, + ).toBe(true); + }); +}); diff --git a/packages/thirdweb/src/react/web/ui/Bridge/payment-details/PaymentDetails.tsx b/packages/thirdweb/src/react/web/ui/Bridge/payment-details/PaymentDetails.tsx index c4fe8073b51..965d9737df7 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/payment-details/PaymentDetails.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/payment-details/PaymentDetails.tsx @@ -19,6 +19,7 @@ import { Container, ModalHeader } from "../../components/basic.js"; import { Button } from "../../components/buttons.js"; import { Spacer } from "../../components/Spacer.js"; import { Text } from "../../components/text.js"; +import { useTokenBalance } from "../common/token-balance.js"; import type { ModeInfo, PaymentMethod } from "../types.js"; import { PaymentOverview } from "./PaymentOverview.js"; @@ -84,6 +85,21 @@ export function PaymentDetails({ } }; + const walletPaymentMethod = + paymentMethod.type === "wallet" ? paymentMethod : undefined; + const originBalanceQuery = useTokenBalance({ + chainId: walletPaymentMethod?.originToken.chainId, + client, + tokenAddress: walletPaymentMethod?.originToken.address, + walletAddress: walletPaymentMethod?.payerWallet.getAccount()?.address, + }); + const originBalance = + originBalanceQuery.data?.value ?? walletPaymentMethod?.balance; + const hasInsufficientBalance = + preparedQuote.type !== "onramp" && + originBalance !== undefined && + originBalance < preparedQuote.originAmount; + const chainsQuery = useChainsQuery( preparedQuote.steps.flatMap((s) => [ defineChain(s.originToken.chainId), @@ -410,12 +426,18 @@ export function PaymentDetails({ {/* Action Buttons */} diff --git a/packages/thirdweb/src/transaction/extract-error.test.ts b/packages/thirdweb/src/transaction/extract-error.test.ts new file mode 100644 index 00000000000..f06d38f60d8 --- /dev/null +++ b/packages/thirdweb/src/transaction/extract-error.test.ts @@ -0,0 +1,41 @@ +import { encodeErrorResult, parseAbi } from "viem"; +import { describe, expect, it } from "vitest"; +import { extractErrorResult } from "./extract-error.js"; + +describe("extractErrorResult", () => { + it("decodes common token errors without a contract", async () => { + await expect( + extractErrorResult({ error: { data: "0x7939f424" } }), + ).resolves.toBe("TransferFromFailed"); + }); + + it("decodes errors with arguments without a contract", async () => { + const data = encodeErrorResult({ + abi: parseAbi([ + "error ERC20InsufficientBalance(address sender, uint256 balance, uint256 needed)", + ]), + args: ["0x0000000000000000000000000000000000000001", 1n, 2n], + errorName: "ERC20InsufficientBalance", + }); + await expect(extractErrorResult({ error: { data } })).resolves.toBe( + "ERC20InsufficientBalance - 0x0000000000000000000000000000000000000001,1,2", + ); + }); + + it("decodes revert strings without a contract", async () => { + const data = encodeErrorResult({ + abi: parseAbi(["error Error(string)"]), + args: ["not allowed"], + errorName: "Error", + }); + await expect(extractErrorResult({ error: { data } })).resolves.toBe( + "Error - not allowed", + ); + }); + + it("returns the raw data for unknown errors", async () => { + await expect( + extractErrorResult({ error: { data: "0x12345678" } }), + ).resolves.toBe("Execution Reverted: 0x12345678"); + }); +}); diff --git a/packages/thirdweb/src/transaction/extract-error.ts b/packages/thirdweb/src/transaction/extract-error.ts index 90377e84131..7ca008c2683 100644 --- a/packages/thirdweb/src/transaction/extract-error.ts +++ b/packages/thirdweb/src/transaction/extract-error.ts @@ -1,4 +1,4 @@ -import type { Abi } from "abitype"; +import { type Abi, parseAbi } from "abitype"; import { decodeErrorResult, type Hex, stringify } from "viem"; import { isInsufficientFundsError } from "../analytics/track/helpers.js"; import { trackInsufficientFundsError } from "../analytics/track/transaction.js"; @@ -7,6 +7,15 @@ import type { ThirdwebContract } from "../contract/contract.js"; import { isHex } from "../utils/encoding/hex.js"; import { IS_DEV } from "../utils/process.js"; +const COMMON_ERRORS_ABI = parseAbi([ + "error TransferFromFailed()", + "error TransferFailed()", + "error ApproveFailed()", + "error ETHTransferFailed()", + "error ERC20InsufficientBalance(address sender, uint256 balance, uint256 needed)", + "error ERC20InsufficientAllowance(address spender, uint256 allowance, uint256 needed)", +]); + /** * @internal */ @@ -53,11 +62,15 @@ export async function extractErrorResult(args: { if (contract && !abi) { abi = await resolveContractAbi(contract).catch(() => undefined); } - const parsedError = decodeErrorResult({ - abi, - data: errorObj.data, - }); - return `${parsedError.errorName}${parsedError.args ? ` - ${parsedError.args}` : ""}`; + try { + const parsedError = decodeErrorResult({ + abi: [...(abi ?? []), ...COMMON_ERRORS_ABI] as Abi, + data: errorObj.data, + }); + return `${parsedError.errorName}${parsedError.args ? ` - ${parsedError.args}` : ""}`; + } catch { + return `Execution Reverted: ${errorObj.data}`; + } } } }