diff --git a/.changeset/onramp-success-guard.md b/.changeset/onramp-success-guard.md new file mode 100644 index 00000000000..d1a7b32ab52 --- /dev/null +++ b/.changeset/onramp-success-guard.md @@ -0,0 +1,5 @@ +--- +"thirdweb": patch +--- + +Fixed Universal Bridge onramp checkout incorrectly reporting success when the onramp did not complete. A failed onramp now surfaces the error instead of a false success, and retrying a failed onramp prepares a fresh payment session rather than replaying the expired one (post-onramp transaction failures still retry in place, so completed onramps are never charged twice). diff --git a/packages/thirdweb/src/react/core/hooks/useStepExecutor.test.tsx b/packages/thirdweb/src/react/core/hooks/useStepExecutor.test.tsx new file mode 100644 index 00000000000..67f16b5ed84 --- /dev/null +++ b/packages/thirdweb/src/react/core/hooks/useStepExecutor.test.tsx @@ -0,0 +1,134 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { TEST_CLIENT } from "~test/test-clients.js"; +import type { WindowAdapter } from "../adapters/WindowAdapter.js"; +import type { BridgePrepareResult } from "./useBridgePrepare.js"; +import { useStepExecutor } from "./useStepExecutor.js"; + +// Avoid firing analytics network calls while executing. +vi.mock("../../../analytics/track/pay.js", () => ({ + trackPayEvent: vi.fn(), +})); + +const { onrampStatusMock } = vi.hoisted(() => ({ + onrampStatusMock: vi.fn(), +})); +vi.mock("../../../bridge/index.js", () => ({ + Onramp: { + status: (options: unknown) => onrampStatusMock(options), + }, +})); + +// Minimal onramp quote with no follow-up transactions, so the executor's +// behaviour depends solely on the onramp outcome. +const ONRAMP_QUOTE: Extract = { + currency: "USD", + currencyAmount: 30, + destinationAmount: 30000000n, + destinationToken: { + address: "0x0000000000000000000000000000000000000000", + chainId: 8453, + decimals: 6, + name: "USD Coin", + prices: {}, + symbol: "USDC", + }, + id: "onramp-session-id", + intent: { + chainId: 8453, + onramp: "transak", + receiver: "0x0000000000000000000000000000000000000001", + tokenAddress: "0x0000000000000000000000000000000000000000", + }, + link: "https://onramp.example.com/session", + steps: [], + type: "onramp", +}; + +function createWindowAdapter(): WindowAdapter { + return { open: vi.fn(async () => {}) }; +} + +describe("useStepExecutor onramp guards", () => { + it("surfaces an error when the onramp reports FAILED", async () => { + onrampStatusMock.mockResolvedValue({ status: "FAILED", transactions: [] }); + const windowAdapter = createWindowAdapter(); + + const { result } = renderHook(() => + useStepExecutor({ + client: TEST_CLIENT, + preparedQuote: ONRAMP_QUOTE, + windowAdapter, + }), + ); + + await act(async () => { + result.current.start(); + }); + + await waitFor(() => expect(result.current.onrampStatus).toBe("failed")); + expect(result.current.error?.message).toBe("Payment failed"); + expect(result.current.executionState).toBe("idle"); + expect(windowAdapter.open).toHaveBeenCalledWith(ONRAMP_QUOTE.link); + }); + + it("does not report success when a prior onramp attempt failed", async () => { + onrampStatusMock.mockResolvedValue({ status: "FAILED", transactions: [] }); + const windowAdapter = createWindowAdapter(); + const onComplete = vi.fn(); + + const { result } = renderHook(() => + useStepExecutor({ + client: TEST_CLIENT, + onComplete, + preparedQuote: ONRAMP_QUOTE, + windowAdapter, + }), + ); + + // First attempt fails and leaves the onramp in the "failed" state. + await act(async () => { + result.current.start(); + }); + await waitFor(() => expect(result.current.onrampStatus).toBe("failed")); + + // Retrying must fail fast on the incomplete onramp rather than proceeding. + await act(async () => { + result.current.retry(); + }); + await waitFor(() => + expect(result.current.error?.message).toBe("Onramp did not complete"), + ); + expect(result.current.error?.statusCode).toBe(500); + // Success must never be reported for an incomplete onramp. + expect(onComplete).not.toHaveBeenCalled(); + }); + + it("marks the onramp complete before reporting success", async () => { + onrampStatusMock.mockResolvedValue({ + status: "COMPLETED", + transactions: [], + }); + const windowAdapter = createWindowAdapter(); + const onComplete = vi.fn(); + + const { result } = renderHook(() => + useStepExecutor({ + client: TEST_CLIENT, + onComplete, + preparedQuote: ONRAMP_QUOTE, + windowAdapter, + }), + ); + + await act(async () => { + result.current.start(); + }); + + await waitFor(() => expect(result.current.onrampStatus).toBe("completed"), { + timeout: 5000, + }); + expect(result.current.error).toBeUndefined(); + expect(onComplete).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/thirdweb/src/react/core/hooks/useStepExecutor.ts b/packages/thirdweb/src/react/core/hooks/useStepExecutor.ts index e0a8f024200..1f96be33dab 100644 --- a/packages/thirdweb/src/react/core/hooks/useStepExecutor.ts +++ b/packages/thirdweb/src/react/core/hooks/useStepExecutor.ts @@ -466,6 +466,7 @@ export function useStepExecutor( return { completed: true }; } else if (status === "FAILED") { setOnrampStatus("failed"); + throw new Error("Payment failed"); } return { completed: false }; @@ -517,12 +518,25 @@ export function useStepExecutor( } // Execute onramp first if configured and not already completed + let onrampCompleted = + preparedQuote.type !== "onramp" || onrampStatus === "completed"; if (preparedQuote.type === "onramp" && onrampStatus === "pending") { await executeOnramp( preparedQuote, completedStatusResults, abortController.signal, ); + onrampCompleted = true; + } + + // An onramp must complete before any follow-up transactions run or + // success is reported. + if (!onrampCompleted) { + throw new ApiError({ + code: "INTERNAL_SERVER_ERROR", + message: "Onramp did not complete", + statusCode: 500, + }); } if (flatTxs.length > 0) { diff --git a/packages/thirdweb/src/react/web/ui/Bridge/BuyWidget.tsx b/packages/thirdweb/src/react/web/ui/Bridge/BuyWidget.tsx index 6f4be0889fb..2c65991e7f8 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/BuyWidget.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/BuyWidget.tsx @@ -731,6 +731,13 @@ function BridgeWidgetContent( completedStatuses, }); }} + onQuoteUpdate={(quote) => { + // A failed onramp is retried with a fresh session; keep the success + // payload pointed at the session that actually completes. + setScreen((prev) => + prev.id === "5:execute" ? { ...prev, preparedQuote: quote } : prev, + ); + }} request={screen.request} wallet={screen.paymentMethod.payerWallet} windowAdapter={webWindowAdapter} diff --git a/packages/thirdweb/src/react/web/ui/Bridge/CheckoutWidget.tsx b/packages/thirdweb/src/react/web/ui/Bridge/CheckoutWidget.tsx index 168165bbe51..b2032e07cff 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/CheckoutWidget.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/CheckoutWidget.tsx @@ -720,6 +720,13 @@ function CheckoutWidgetContent( completedStatuses, }); }} + onQuoteUpdate={(quote) => { + // A failed onramp is retried with a fresh session; keep the success + // payload pointed at the session that actually completes. + setScreen((prev) => + prev.id === "5:execute" ? { ...prev, preparedQuote: quote } : prev, + ); + }} request={screen.request} wallet={screen.paymentMethod.payerWallet} windowAdapter={webWindowAdapter} diff --git a/packages/thirdweb/src/react/web/ui/Bridge/StepRunner.test.tsx b/packages/thirdweb/src/react/web/ui/Bridge/StepRunner.test.tsx new file mode 100644 index 00000000000..a5b52854f7a --- /dev/null +++ b/packages/thirdweb/src/react/web/ui/Bridge/StepRunner.test.tsx @@ -0,0 +1,143 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { TEST_CLIENT } from "~test/test-clients.js"; +import type { WindowAdapter } from "../../../core/adapters/WindowAdapter.js"; +import { CustomThemeProvider } from "../../../core/design-system/CustomThemeProvider.js"; +import type { + BridgePrepareRequest, + BridgePrepareResult, +} from "../../../core/hooks/useBridgePrepare.js"; +import { StepRunner } from "./StepRunner.js"; + +// Controllable executor + prepare seams so the test drives the retry branch +// purely off `onrampStatus`. +const { executor, retrySpy, refetchSpy, FRESH_QUOTE } = vi.hoisted(() => ({ + executor: { + error: undefined as Error | undefined, + onrampStatus: undefined as + | "pending" + | "executing" + | "completed" + | "failed" + | undefined, + }, + FRESH_QUOTE: { + id: "fresh-session", + link: "https://onramp.example.com/fresh", + type: "onramp", + }, + refetchSpy: vi.fn(), + retrySpy: vi.fn(), +})); + +vi.mock("../../../core/hooks/useStepExecutor.js", () => ({ + useStepExecutor: () => ({ + cancel: vi.fn(), + currentStep: undefined, + error: executor.error, + executionState: "idle" as const, + onrampStatus: executor.onrampStatus, + progress: 0, + retry: retrySpy, + start: vi.fn(), + steps: [], + }), +})); + +vi.mock("../../../core/hooks/useBridgePrepare.js", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("../../../core/hooks/useBridgePrepare.js") + >(); + return { ...actual, useBridgePrepare: () => ({ refetch: refetchSpy }) }; +}); + +const ONRAMP_REQUEST: BridgePrepareRequest = { + chainId: 8453, + client: TEST_CLIENT, + onramp: "transak", + receiver: "0x0000000000000000000000000000000000000001", + tokenAddress: "0x0000000000000000000000000000000000000000", + type: "onramp", +}; + +const ONRAMP_QUOTE: Extract = { + currency: "USD", + currencyAmount: 30, + destinationAmount: 30000000n, + destinationToken: { + address: "0x0000000000000000000000000000000000000000", + chainId: 8453, + decimals: 6, + name: "USD Coin", + prices: {}, + symbol: "USDC", + }, + id: "onramp-session-id", + intent: { + chainId: 8453, + onramp: "transak", + receiver: "0x0000000000000000000000000000000000000001", + tokenAddress: "0x0000000000000000000000000000000000000000", + }, + link: "https://onramp.example.com/session", + steps: [], + type: "onramp", +}; + +function renderStepRunner() { + const onQuoteUpdate = vi.fn(); + render( + + {}) } as WindowAdapter} + /> + , + ); + return { onQuoteUpdate }; +} + +describe("StepRunner onramp retry recovery", () => { + beforeEach(() => { + retrySpy.mockReset(); + refetchSpy.mockReset(); + refetchSpy.mockResolvedValue({ data: FRESH_QUOTE }); + executor.error = new Error("Payment failed"); + }); + + it("re-prepares a fresh session when a failed onramp is retried", async () => { + executor.onrampStatus = "failed"; + const { onQuoteUpdate } = renderStepRunner(); + + fireEvent.click(screen.getByText("Retry")); + + await waitFor(() => expect(refetchSpy).toHaveBeenCalledTimes(1)); + // Must NOT replay the dead session in place. + expect(retrySpy).not.toHaveBeenCalled(); + await waitFor(() => + expect(onQuoteUpdate).toHaveBeenCalledWith(FRESH_QUOTE), + ); + }); + + it("retries in place (never re-onramps) once the onramp has completed", async () => { + // A post-onramp transaction failed: funds already arrived, so re-onramping + // would double-charge the buyer. + executor.onrampStatus = "completed"; + renderStepRunner(); + + fireEvent.click(screen.getByText("Retry")); + + expect(retrySpy).toHaveBeenCalledTimes(1); + expect(refetchSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/thirdweb/src/react/web/ui/Bridge/StepRunner.tsx b/packages/thirdweb/src/react/web/ui/Bridge/StepRunner.tsx index ad4a9bf2d65..ffd592695eb 100644 --- a/packages/thirdweb/src/react/web/ui/Bridge/StepRunner.tsx +++ b/packages/thirdweb/src/react/web/ui/Bridge/StepRunner.tsx @@ -1,5 +1,6 @@ "use client"; import { CheckIcon, ClockIcon, Cross1Icon } from "@radix-ui/react-icons"; +import { useCallback, useState } from "react"; import type { RouteStep } from "../../../../bridge/types/Route.js"; import type { Chain } from "../../../../chains/types.js"; import { defineChain } from "../../../../chains/utils.js"; @@ -12,9 +13,11 @@ import { radius, spacing, } from "../../../core/design-system/index.js"; -import type { - BridgePrepareRequest, - BridgePrepareResult, +import { + type BridgePrepareRequest, + type BridgePrepareResult, + type UseBridgePrepareParams, + useBridgePrepare, } from "../../../core/hooks/useBridgePrepare.js"; import { type CompletedStatusResult, @@ -70,9 +73,93 @@ type StepRunnerProps = { * Prepared quote to use */ preparedQuote: BridgePrepareResult; + + /** + * Called when the executing quote is replaced by a freshly prepared one + * (e.g. after retrying a failed onramp, which mints a new session). Lets the + * parent keep any state it derives from the quote — such as the success + * payload — in sync with the session that actually completed. + */ + onQuoteUpdate?: (preparedQuote: BridgePrepareResult) => void; }; export function StepRunner({ + preparedQuote, + request, + onQuoteUpdate, + ...rest +}: StepRunnerProps) { + const theme = useCustomTheme(); + + // The quote currently being executed. A failed onramp replaces this with a + // freshly prepared session (see requestFreshOnramp) rather than replaying the + // dead one. + const [activeQuote, setActiveQuote] = + useState(preparedQuote); + // Bumped whenever the active quote is replaced, to remount the executor with + // a clean slate: a fresh onramp session and reset status. + const [runKey, setRunKey] = useState(0); + const [isRepreparing, setIsRepreparing] = useState(false); + + // Disabled query used only to imperatively mint a fresh quote on retry. + // refetch() bypasses the cache, so it always returns a new onramp session. + const reprepare = useBridgePrepare({ + ...(request as UseBridgePrepareParams), + enabled: false, + }); + + const requestFreshOnramp = useCallback(async () => { + setIsRepreparing(true); + const { data } = await reprepare.refetch(); + setIsRepreparing(false); + if (data) { + setActiveQuote(data); + setRunKey((key) => key + 1); + onQuoteUpdate?.(data); + } + }, [reprepare, onQuoteUpdate]); + + if (isRepreparing) { + return ( + + + + + Preparing a new payment session + + + ); + } + + return ( + + ); +} + +type StepExecutionProps = Omit< + StepRunnerProps, + "preparedQuote" | "onQuoteUpdate" +> & { + preparedQuote: BridgePrepareResult; + onRequestFreshOnramp: () => void; + theme: ReturnType; +}; + +function StepExecution({ title, request, wallet, @@ -83,9 +170,9 @@ export function StepRunner({ onBack, autoStart, preparedQuote, -}: StepRunnerProps) { - const theme = useCustomTheme(); - + onRequestFreshOnramp, + theme, +}: StepExecutionProps) { // Use the real step executor hook const { currentStep, @@ -116,7 +203,15 @@ export function StepRunner({ }; const handleRetry = () => { - retry(); + // A failed onramp (funds never moved) needs a fresh session, not a replay + // of the dead/expired one. Any other failure — including one after the + // onramp already completed — must retry in place: re-onramping there would + // charge the buyer a second time. + if (request.type === "onramp" && onrampStatus === "failed") { + onRequestFreshOnramp(); + } else { + retry(); + } }; const getStepStatus = (