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}`;
+ }
}
}
}