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
5 changes: 5 additions & 0 deletions .changeset/bridge-origin-balance.md
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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(
<CustomThemeProvider theme="dark">
<PaymentDetails
client={TEST_CLIENT}
confirmButtonLabel={undefined}
currency="USD"
metadata={{ description: undefined, title: undefined }}
modeInfo={{ mode: "fund_wallet" }}
onBack={vi.fn()}
onConfirm={onConfirm}
onError={vi.fn()}
paymentMethod={method}
preparedQuote={quote}
/>
</CustomThemeProvider>,
);
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<HTMLButtonElement>("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<HTMLButtonElement>("button", {
name: "Insufficient Balance",
}).disabled,
).toBe(true);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -410,12 +426,18 @@ export function PaymentDetails({
{/* Action Buttons */}
<Container flex="column" gap="sm">
<Button
disabled={hasInsufficientBalance}
fullWidth
onClick={handleConfirm}
variant="primary"
style={{ borderRadius: radius.full }}
style={{
borderRadius: radius.full,
opacity: hasInsufficientBalance ? 0.5 : 1,
}}
>
{confirmButtonLabel || "Confirm Payment"}
{hasInsufficientBalance
? "Insufficient Balance"
: confirmButtonLabel || "Confirm Payment"}
</Button>
</Container>
</Container>
Expand Down
41 changes: 41 additions & 0 deletions packages/thirdweb/src/transaction/extract-error.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
25 changes: 19 additions & 6 deletions packages/thirdweb/src/transaction/extract-error.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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
*/
Expand Down Expand Up @@ -53,11 +62,15 @@ export async function extractErrorResult<abi extends Abi>(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}`;
}
}
}
}
Expand Down
Loading