diff --git a/.changeset/weak-papayas-occur.md b/.changeset/weak-papayas-occur.md new file mode 100644 index 00000000000..a9669811389 --- /dev/null +++ b/.changeset/weak-papayas-occur.md @@ -0,0 +1,5 @@ +--- +"thirdweb": patch +--- + +Fix etherlink transfers when too little funds diff --git a/packages/thirdweb/src/bridge/Status.test.ts b/packages/thirdweb/src/bridge/Status.test.ts index 33fbe487ca6..6593c94c70a 100644 --- a/packages/thirdweb/src/bridge/Status.test.ts +++ b/packages/thirdweb/src/bridge/Status.test.ts @@ -44,7 +44,7 @@ describe.runIf(process.env.TW_SECRET_KEY)("Bridge.status", () => { it("should handle successful status with chain", async () => { const result = await status({ transactionHash: - "0x7bedc4693e899fe81a22dac11301e77a12a6e772834bba5b698baf3ebcf86f7a", + "0x06ac91479b3ea4c6507f9b7bff1f2d5f553253fa79af9a7db3755563b60f7dfb", chain: defineChain(8453), client: TEST_CLIENT, }); diff --git a/packages/thirdweb/src/react/core/hooks/useBridgeError.test.ts b/packages/thirdweb/src/react/core/hooks/useBridgeError.test.ts deleted file mode 100644 index b47fa3b8654..00000000000 --- a/packages/thirdweb/src/react/core/hooks/useBridgeError.test.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { ApiError } from "../../../bridge/types/Errors.js"; -import { useBridgeError } from "./useBridgeError.js"; - -describe("useBridgeError", () => { - it("should handle null error", () => { - const result = useBridgeError({ error: null }); - - expect(result).toEqual({ - mappedError: null, - isRetryable: false, - userMessage: "", - errorCode: null, - statusCode: null, - isClientError: false, - isServerError: false, - }); - }); - - it("should handle undefined error", () => { - const result = useBridgeError({ error: undefined }); - - expect(result).toEqual({ - mappedError: null, - isRetryable: false, - userMessage: "", - errorCode: null, - statusCode: null, - isClientError: false, - isServerError: false, - }); - }); - - it("should process ApiError correctly", () => { - const apiError = new ApiError({ - code: "INVALID_INPUT", - message: "Invalid parameters provided", - statusCode: 400, - }); - - const result = useBridgeError({ error: apiError }); - - expect(result.mappedError).toBeInstanceOf(ApiError); - expect(result.errorCode).toBe("INVALID_INPUT"); - expect(result.statusCode).toBe(400); - expect(result.isClientError).toBe(true); - expect(result.isServerError).toBe(false); - expect(result.isRetryable).toBe(false); // INVALID_INPUT is not retryable - expect(result.userMessage).toBe( - "Invalid input provided. Please check your parameters and try again.", - ); - }); - - it("should convert generic Error to ApiError", () => { - const genericError = new Error("Network connection failed"); - - const result = useBridgeError({ error: genericError }); - - expect(result.mappedError).toBeInstanceOf(ApiError); - expect(result.errorCode).toBe("UNKNOWN_ERROR"); - expect(result.statusCode).toBe(500); - expect(result.isClientError).toBe(false); - expect(result.isServerError).toBe(true); - expect(result.isRetryable).toBe(true); - expect(result.userMessage).toBe( - "An unexpected error occurred. Please try again.", - ); - }); - - it("should identify server errors correctly", () => { - const serverError = new ApiError({ - code: "INTERNAL_SERVER_ERROR", - message: "Server error", - statusCode: 500, - }); - - const result = useBridgeError({ error: serverError }); - - expect(result.statusCode).toBe(500); - expect(result.isClientError).toBe(false); - expect(result.isServerError).toBe(true); - expect(result.isRetryable).toBe(true); // INTERNAL_SERVER_ERROR is retryable - expect(result.userMessage).toBe( - "A temporary error occurred. Please try again in a moment.", - ); - }); - - it("should provide user-friendly messages for known error codes", () => { - // Test INVALID_INPUT - const invalidInputError = new ApiError({ - code: "INVALID_INPUT", - message: "Technical error message", - statusCode: 400, - }); - const invalidInputResult = useBridgeError({ error: invalidInputError }); - expect(invalidInputResult.userMessage).toBe( - "Invalid input provided. Please check your parameters and try again.", - ); - - // Test INTERNAL_SERVER_ERROR - const serverError = new ApiError({ - code: "INTERNAL_SERVER_ERROR", - message: "Technical error message", - statusCode: 500, - }); - const serverResult = useBridgeError({ error: serverError }); - expect(serverResult.userMessage).toBe( - "A temporary error occurred. Please try again in a moment.", - ); - }); - - it("should use original error message for unknown error codes", () => { - const unknownError = new ApiError({ - code: "UNKNOWN_ERROR", - message: "Custom error message", - statusCode: 418, - }); - - const result = useBridgeError({ error: unknownError }); - - expect(result.userMessage).toBe( - "An unexpected error occurred. Please try again.", - ); - expect(result.errorCode).toBe("UNKNOWN_ERROR"); - }); - - it("should detect client vs server errors correctly", () => { - // Client error (4xx) - const clientError = new ApiError({ - code: "INVALID_INPUT", - message: "Bad request", - statusCode: 400, - }); - - const clientResult = useBridgeError({ error: clientError }); - expect(clientResult.isClientError).toBe(true); - expect(clientResult.isServerError).toBe(false); - - // Server error (5xx) - const serverError = new ApiError({ - code: "INTERNAL_SERVER_ERROR", - message: "Internal error", - statusCode: 503, - }); - - const serverResult = useBridgeError({ error: serverError }); - expect(serverResult.isClientError).toBe(false); - expect(serverResult.isServerError).toBe(true); - - // No status code - const noStatusError = new ApiError({ - code: "UNKNOWN_ERROR", - message: "Unknown error", - statusCode: 500, - }); - - const noStatusResult = useBridgeError({ error: noStatusError }); - expect(noStatusResult.isClientError).toBe(false); - expect(noStatusResult.isServerError).toBe(true); // 500 is a server error - }); - - it("should handle Error without message", () => { - const errorWithoutMessage = new Error(); - - const result = useBridgeError({ error: errorWithoutMessage }); - - expect(result.userMessage).toBe( - "An unexpected error occurred. Please try again.", - ); - expect(result.errorCode).toBe("UNKNOWN_ERROR"); - }); -}); diff --git a/packages/thirdweb/src/react/core/hooks/usePaymentMethods.test.ts b/packages/thirdweb/src/react/core/hooks/usePaymentMethods.test.ts deleted file mode 100644 index 92a68f5da1e..00000000000 --- a/packages/thirdweb/src/react/core/hooks/usePaymentMethods.test.ts +++ /dev/null @@ -1,336 +0,0 @@ -/** - * @vitest-environment happy-dom - */ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { renderHook, waitFor } from "@testing-library/react"; -import { createElement } from "react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { routes } from "../../../bridge/Routes.js"; -import type { Token } from "../../../bridge/types/Token.js"; -import type { ThirdwebClient } from "../../../client/client.js"; -import { usePaymentMethods } from "./usePaymentMethods.js"; - -// Mock the routes API -vi.mock("../../../bridge/Routes.js", () => ({ - routes: vi.fn(), -})); - -const mockRoutes = vi.mocked(routes); - -// Mock data -const mockDestinationToken: Token = { - chainId: 1, - address: "0xA0b86a33E6441aA7A6fbEEc9bb27e5e8bc3b8eD7", - decimals: 6, - symbol: "USDC", - name: "USD Coin", - priceUsd: 1.0, -}; - -const mockClient = { - clientId: "test-client-id", -} as ThirdwebClient; - -const mockRouteData = [ - { - originToken: { - chainId: 1, - address: "0xA0b86a33E6441aA7A6fbEEc9bb27e5e8bc3b8eD7", - decimals: 18, - symbol: "ETH", - name: "Ethereum", - priceUsd: 2000, - }, - destinationToken: mockDestinationToken, - steps: [], - }, - { - originToken: { - chainId: 137, - address: "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", - decimals: 6, - symbol: "USDC", - name: "USD Coin", - priceUsd: 1.0, - }, - destinationToken: mockDestinationToken, - steps: [], - }, - { - originToken: { - chainId: 42161, - address: "0xFF970A61A04b1cA14834A43f5dE4533eBDDB5CC8", - decimals: 6, - symbol: "USDC", - name: "USD Coin", - priceUsd: 1.0, - }, - destinationToken: mockDestinationToken, - steps: [], - }, -]; - -// Test wrapper component -const createWrapper = () => { - const queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, - }); - - return ({ children }: { children: React.ReactNode }) => - createElement(QueryClientProvider, { client: queryClient }, children); -}; - -describe("usePaymentMethods", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("should require destinationToken and client parameters", () => { - const wrapper = createWrapper(); - - const { result } = renderHook( - () => - usePaymentMethods({ - destinationToken: mockDestinationToken, - destinationAmount: "1", - client: mockClient, - }), - { wrapper }, - ); - - expect(result.current).toBeDefined(); - expect(result.current.isLoading).toBe(true); - }); - - it("should fetch routes and transform data correctly", async () => { - mockRoutes.mockResolvedValueOnce(mockRouteData); - const wrapper = createWrapper(); - - const { result } = renderHook( - () => - usePaymentMethods({ - destinationToken: mockDestinationToken, - destinationAmount: "1", - client: mockClient, - }), - { wrapper }, - ); - - // Initially loading - expect(result.current.isLoading).toBe(true); - expect(result.current.data).toEqual([]); - - // Wait for query to resolve - await waitFor(() => { - expect(result.current.isSuccess).toBe(true); - }); - - // Should have transformed data - expect(result.current.data).toHaveLength(4); // 3 wallet methods + 1 fiat method - - const walletMethod = result.current.data[0]; - expect(walletMethod?.type).toBe("wallet"); - if (walletMethod?.type === "wallet") { - expect(walletMethod.originToken).toEqual(mockRouteData[0]?.originToken); - } - - const fiatMethod = result.current.data[3]; - expect(fiatMethod?.type).toBe("fiat"); - if (fiatMethod?.type === "fiat") { - expect(fiatMethod.currency).toBe("USD"); - } - }); - - it("should call routes API with correct parameters", async () => { - mockRoutes.mockResolvedValueOnce(mockRouteData); - const wrapper = createWrapper(); - - renderHook( - () => - usePaymentMethods({ - destinationToken: mockDestinationToken, - destinationAmount: "1", - client: mockClient, - }), - { wrapper }, - ); - - await waitFor(() => { - expect(mockRoutes).toHaveBeenCalledWith({ - client: mockClient, - destinationChainId: mockDestinationToken.chainId, - destinationTokenAddress: mockDestinationToken.address, - sortBy: "popularity", - limit: 50, - }); - }); - }); - - it("should handle empty routes data", async () => { - mockRoutes.mockResolvedValueOnce([]); - const wrapper = createWrapper(); - - const { result } = renderHook( - () => - usePaymentMethods({ - destinationToken: mockDestinationToken, - destinationAmount: "1", - client: mockClient, - }), - { wrapper }, - ); - - await waitFor(() => { - expect(result.current.isSuccess).toBe(true); - }); - - // Should only have fiat method when no routes - expect(result.current.data).toHaveLength(1); - expect(result.current.data[0]).toEqual({ - type: "fiat", - currency: "USD", - }); - }); - - it("should handle API errors gracefully", async () => { - const mockError = new Error("API Error"); - mockRoutes.mockRejectedValueOnce(mockError); - const wrapper = createWrapper(); - - const { result } = renderHook( - () => - usePaymentMethods({ - destinationToken: mockDestinationToken, - destinationAmount: "1", - client: mockClient, - }), - { wrapper }, - ); - - await waitFor(() => { - expect(result.current.isError).toBe(true); - }); - - expect(result.current.error).toBeTruthy(); - expect(result.current.data).toEqual([]); - }); - - it("should deduplicate origin tokens", async () => { - // Mock data with duplicate origin tokens - const firstRoute = mockRouteData[0]; - if (!firstRoute) { - throw new Error("Mock data is invalid"); - } - - const mockDataWithDuplicates = [ - ...mockRouteData, - { - originToken: firstRoute.originToken, // Duplicate ETH - destinationToken: mockDestinationToken, - steps: [], - }, - ]; - - mockRoutes.mockResolvedValueOnce(mockDataWithDuplicates); - const wrapper = createWrapper(); - - const { result } = renderHook( - () => - usePaymentMethods({ - destinationToken: mockDestinationToken, - destinationAmount: "1", - client: mockClient, - }), - { wrapper }, - ); - - await waitFor(() => { - expect(result.current.isSuccess).toBe(true); - }); - - // Should still have only 4 methods (3 unique wallet + 1 fiat) - expect(result.current.data).toHaveLength(4); - - // Check that ETH only appears once - const walletMethods = result.current.data.filter( - (m) => m.type === "wallet", - ); - const ethMethods = walletMethods.filter( - (m) => m.type === "wallet" && m.originToken?.symbol === "ETH", - ); - expect(ethMethods).toHaveLength(1); - }); - - it("should always include fiat payment option", async () => { - mockRoutes.mockResolvedValueOnce(mockRouteData); - const wrapper = createWrapper(); - - const { result } = renderHook( - () => - usePaymentMethods({ - destinationToken: mockDestinationToken, - destinationAmount: "1", - client: mockClient, - }), - { wrapper }, - ); - - await waitFor(() => { - expect(result.current.isSuccess).toBe(true); - }); - - const fiatMethods = result.current.data.filter((m) => m.type === "fiat"); - expect(fiatMethods).toHaveLength(1); - expect(fiatMethods[0]).toEqual({ - type: "fiat", - currency: "USD", - }); - }); - - it("should have correct query key for caching", async () => { - mockRoutes.mockResolvedValueOnce(mockRouteData); - const wrapper = createWrapper(); - - const { result } = renderHook( - () => - usePaymentMethods({ - destinationToken: mockDestinationToken, - destinationAmount: "1", - client: mockClient, - }), - { wrapper }, - ); - - // The hook should use a query key that includes chain ID and token address - await waitFor(() => { - expect(result.current.isSuccess).toBe(true); - }); - - expect(mockRoutes).toHaveBeenCalledTimes(1); - }); - - it("should provide refetch functionality", async () => { - mockRoutes.mockResolvedValueOnce(mockRouteData); - const wrapper = createWrapper(); - - const { result } = renderHook( - () => - usePaymentMethods({ - destinationToken: mockDestinationToken, - destinationAmount: "1", - client: mockClient, - }), - { wrapper }, - ); - - await waitFor(() => { - expect(result.current.isSuccess).toBe(true); - }); - - expect(typeof result.current.refetch).toBe("function"); - }); -}); diff --git a/packages/thirdweb/src/react/core/hooks/wallets/useSendToken.ts b/packages/thirdweb/src/react/core/hooks/wallets/useSendToken.ts index 84722dd6fe2..c1a8fcce78d 100644 --- a/packages/thirdweb/src/react/core/hooks/wallets/useSendToken.ts +++ b/packages/thirdweb/src/react/core/hooks/wallets/useSendToken.ts @@ -3,12 +3,14 @@ import type { ThirdwebClient } from "../../../../client/client.js"; import { getContract } from "../../../../contract/contract.js"; import { resolveAddress } from "../../../../extensions/ens/resolve-address.js"; import { transfer } from "../../../../extensions/erc20/write/transfer.js"; +import { estimateGas } from "../../../../transaction/actions/estimate-gas.js"; import { sendTransaction } from "../../../../transaction/actions/send-transaction.js"; import { waitForReceipt } from "../../../../transaction/actions/wait-for-tx-receipt.js"; import { prepareTransaction } from "../../../../transaction/prepare-transaction.js"; import { isAddress } from "../../../../utils/address.js"; import { isValidENSName } from "../../../../utils/ens/isValidENSName.js"; import { toWei } from "../../../../utils/units.js"; +import { getWalletBalance } from "../../../../wallets/utils/getWalletBalance.js"; import { invalidateWalletBalance } from "../../providers/invalidateWalletBalance.js"; import { useActiveWallet } from "./useActiveWallet.js"; @@ -85,13 +87,24 @@ export function useSendToken(client: ThirdwebClient) { to, value: toWei(amount), }); + const gasEstimate = await estimateGas({ + transaction: sendNativeTokenTx, + account, + }); + const balance = await getWalletBalance({ + address: account.address, + chain: activeChain, + client, + }); + if (toWei(amount) + gasEstimate > balance.value) { + throw new Error("Insufficient balance for transfer amount and gas"); + } - return sendTransaction({ + return await sendTransaction({ transaction: sendNativeTokenTx, account, }); } - // erc20 token transfer else { const contract = getContract({ @@ -106,7 +119,7 @@ export function useSendToken(client: ThirdwebClient) { to, }); - return sendTransaction({ + return await sendTransaction({ transaction: tx, account, }); @@ -121,6 +134,7 @@ export function useSendToken(client: ThirdwebClient) { transactionHash: data.transactionHash, client, chain: data.chain, + maxBlocksWaitTime: 10_000, }); } invalidateWalletBalance(queryClient); diff --git a/packages/thirdweb/src/react/core/machines/paymentMachine.test.ts b/packages/thirdweb/src/react/core/machines/paymentMachine.test.ts index f720770eaa6..b4f90695cdb 100644 --- a/packages/thirdweb/src/react/core/machines/paymentMachine.test.ts +++ b/packages/thirdweb/src/react/core/machines/paymentMachine.test.ts @@ -124,114 +124,6 @@ describe("PaymentMachine", () => { expect(state.context.adapters).toBe(adapters); }); - it("should transition through happy path with wallet payment method", () => { - const { result } = renderHook(() => - usePaymentMachine(adapters, "fund_wallet"), - ); - - // Confirm destination - act(() => { - const [, send] = result.current; - send({ - type: "DESTINATION_CONFIRMED", - destinationToken: testTokenForPayment, - destinationAmount: "100", - receiverAddress: "0xa3841994009B4fEabb01ebcC62062F9E56F701CD", - }); - }); - - let [state] = result.current; - expect(state.value).toBe("methodSelection"); - expect(state.context.destinationToken).toEqual(testTokenForPayment); - expect(state.context.destinationAmount).toBe("100"); - expect(state.context.receiverAddress).toBe( - "0xa3841994009B4fEabb01ebcC62062F9E56F701CD", - ); - - // Select wallet payment method - const walletPaymentMethod: PaymentMethod = { - type: "wallet", - originToken: testUSDCToken, - payerWallet: TEST_IN_APP_WALLET_A, - balance: 1000000000000000000n, - }; - - act(() => { - const [, send] = result.current; - send({ - type: "PAYMENT_METHOD_SELECTED", - paymentMethod: walletPaymentMethod, - }); - }); - - [state] = result.current; - expect(state.value).toBe("quote"); - expect(state.context.selectedPaymentMethod).toEqual(walletPaymentMethod); - - // Receive quote - act(() => { - const [, send] = result.current; - send({ - type: "QUOTE_RECEIVED", - preparedQuote: mockBuyQuote, - }); - }); - - [state] = result.current; - expect(state.value).toBe("preview"); - expect(state.context.preparedQuote).toBe(mockBuyQuote); - - // Confirm route - act(() => { - const [, send] = result.current; - send({ - type: "ROUTE_CONFIRMED", - }); - }); - - [state] = result.current; - expect(state.value).toBe("execute"); - expect(state.context.selectedPaymentMethod).toBe(walletPaymentMethod); - - // Complete execution - act(() => { - const [, send] = result.current; - send({ - type: "EXECUTION_COMPLETE", - completedStatuses: [ - { - type: "buy", - status: "COMPLETED", - paymentId: "test-payment-id", - originAmount: 1000000000000000000n, - destinationAmount: 100000000n, - originChainId: 1, - destinationChainId: 137, - originTokenAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", - destinationTokenAddress: - "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", - originToken: testETHToken, - destinationToken: testUSDCToken, - sender: "0xa3841994009B4fEabb01ebcC62062F9E56F701CD", - receiver: "0xa3841994009B4fEabb01ebcC62062F9E56F701CD", - transactions: [ - { - chainId: 1, - transactionHash: "0xtest123", - }, - ], - }, - ], - }); - }); - - [state] = result.current; - expect(state.value).toBe("success"); - expect(state.context.completedStatuses).toBeDefined(); - expect(state.context.completedStatuses).toHaveLength(1); - expect(state.context.completedStatuses?.[0]?.status).toBe("COMPLETED"); - }); - it("should handle errors and allow retry", () => { const { result } = renderHook(() => usePaymentMachine(adapters, "fund_wallet"), @@ -517,70 +409,6 @@ describe("PaymentMachine", () => { expect(state.value).toBe("init"); }); - it("should clear prepared quote when payment method changes", () => { - const { result } = renderHook(() => - usePaymentMachine(adapters, "fund_wallet"), - ); - - // Go to methodSelection - act(() => { - const [, send] = result.current; - send({ - type: "DESTINATION_CONFIRMED", - destinationToken: testTokenForPayment, - destinationAmount: "100", - receiverAddress: "0xa3841994009B4fEabb01ebcC62062F9E56F701CD", - }); - }); - - // Select first payment method and get quote - act(() => { - const [, send] = result.current; - send({ - type: "PAYMENT_METHOD_SELECTED", - paymentMethod: { - type: "fiat", - currency: "USD", - payerWallet: TEST_IN_APP_WALLET_A, - onramp: "stripe", - }, - }); - }); - - act(() => { - const [, send] = result.current; - send({ - type: "QUOTE_RECEIVED", - preparedQuote: mockBuyQuote, - }); - }); - - let [state] = result.current; - expect(state.context.preparedQuote).toBe(mockBuyQuote); - - // Go back and select different payment method - act(() => { - const [, send] = result.current; - send({ type: "BACK" }); - }); - - act(() => { - const [, send] = result.current; - send({ - type: "PAYMENT_METHOD_SELECTED", - paymentMethod: { - type: "wallet", - payerWallet: TEST_IN_APP_WALLET_A, - originToken: testUSDCToken, - balance: 1000000000000000000n, - }, - }); - }); - - [state] = result.current; - expect(state.context.preparedQuote).toBeUndefined(); // Should be cleared - }); - it("should handle post-buy-transaction state flow", () => { const { result } = renderHook(() => usePaymentMachine(adapters, "fund_wallet"), diff --git a/packages/thirdweb/src/react/web/adapters/adapters.test.ts b/packages/thirdweb/src/react/web/adapters/adapters.test.ts index 612fcf34bd1..05bcbf17f48 100644 --- a/packages/thirdweb/src/react/web/adapters/adapters.test.ts +++ b/packages/thirdweb/src/react/web/adapters/adapters.test.ts @@ -27,12 +27,4 @@ describe("WebWindowAdapter", () => { "noopener,noreferrer", ); }); - - it("should throw error when popup is blocked", async () => { - mockOpen.mockReturnValue(null); - - await expect(windowAdapter.open("https://example.com")).rejects.toThrow( - "Failed to open URL - popup may be blocked", - ); - }); }); diff --git a/packages/thirdweb/src/wallets/utils/getWalletBalance.ts b/packages/thirdweb/src/wallets/utils/getWalletBalance.ts index 24b1a25512e..632b9126c4d 100644 --- a/packages/thirdweb/src/wallets/utils/getWalletBalance.ts +++ b/packages/thirdweb/src/wallets/utils/getWalletBalance.ts @@ -41,7 +41,7 @@ export type GetWalletBalanceResult = GetBalanceResult; */ export async function getWalletBalance( options: GetWalletBalanceOptions, -): Promise { +): Promise { const { address, client, chain, tokenAddress } = options; // erc20 case if (tokenAddress) {