Skip to content
11 changes: 11 additions & 0 deletions apps/dashboard/e2e/support/mockBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,9 @@ interface MockBackendOptions {
rampRegisterError?: string;
// Fail this many POST /v1/ramp/start calls with a 500 before succeeding.
rampStartFailures?: number;
// Fail this many POST /v1/ramp/update calls that report wallet hashes (the offramp's final
// update) with the maintenance guard's 503 before succeeding.
rampHashUpdateFailures?: number;
onrampCurrency?: "ARS" | "BRL" | "COP" | "MXN" | "USD";
quoteOverrides?: (requestIndex: number, requestBody: Record<string, unknown>) => Record<string, unknown>;
tokenBalances?: TokenBalances | null | ((requestIndex: number, network: BalanceNetwork) => TokenBalances | null);
Expand Down Expand Up @@ -1022,6 +1025,14 @@ export async function mockBackend(page: Page, options: MockBackendOptions = {})
}
if (path === "/v1/ramp/update" && method === "POST") {
updateRequests.push(request.postDataJSON() as Record<string, unknown>);
const hashUpdates = updateRequests.filter(body => body.additionalData).length;
if (updateRequests.at(-1)?.additionalData && hashUpdates <= (options.rampHashUpdateFailures ?? 0)) {
await fulfillJson(
{ message: "Vortex services are temporarily unavailable during scheduled maintenance: Upgrade - Back soon." },
503
);
return;
}
const isOnramp = quoteRequests.at(-1)?.rampType === "BUY";
const paymentData = isOnramp
? options.onrampCurrency === "BRL"
Expand Down
6 changes: 5 additions & 1 deletion apps/dashboard/e2e/support/mockWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ export const MOCK_WALLET_ADDRESS = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266";
export const MOCK_WALLET_NAME = "E2E Mock Wallet";
/** The hash the stub returns for every eth_sendTransaction. */
export const MOCK_WALLET_TX_HASH = `0x${"cd".repeat(32)}`;
/** sessionStorage key counting eth_sendTransaction calls in the tab; it survives reloads. */
export const MOCK_WALLET_SEND_COUNT_KEY = "e2e-mock-wallet-sends";

// Copied from apps/frontend/e2e/support/mockWallet.ts. Injects a minimal EIP-1193 provider
// announced via EIP-6963 before the app loads. wagmi discovers announced providers by default
Expand All @@ -14,7 +16,7 @@ export const MOCK_WALLET_TX_HASH = `0x${"cd".repeat(32)}`;
// a mid-flow chain switch in signAndSubmitEvmTransaction.
export async function injectMockWallet(page: Page, options: { chainIdHex?: string } = {}) {
await page.addInitScript(
({ address, name, chainIdHex, txHash }) => {
({ address, name, chainIdHex, sendCountKey, txHash }) => {
// biome-ignore lint/suspicious/noExplicitAny: minimal EIP-1193 stub
const listeners: Record<string, Array<(...args: any[]) => void>> = {};
const provider = {
Expand Down Expand Up @@ -46,6 +48,7 @@ export async function injectMockWallet(page: Page, options: { chainIdHex?: strin
return `0x${"ab".repeat(65)}`;
// The offramp's user-owned squidRouterNoPermitTransfer is broadcast through here.
case "eth_sendTransaction":
sessionStorage.setItem(sendCountKey, String(Number(sessionStorage.getItem(sendCountKey) ?? 0) + 1));
return txHash;
case "eth_getTransactionReceipt":
return {
Expand Down Expand Up @@ -89,6 +92,7 @@ export async function injectMockWallet(page: Page, options: { chainIdHex?: strin
address: MOCK_WALLET_ADDRESS,
chainIdHex: options.chainIdHex ?? "0x2105",
name: MOCK_WALLET_NAME,
sendCountKey: MOCK_WALLET_SEND_COUNT_KEY,
txHash: MOCK_WALLET_TX_HASH
}
);
Expand Down
43 changes: 42 additions & 1 deletion apps/dashboard/e2e/transfer-mxn-journey.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { expect, test } from "@playwright/test";
import { E2E_FIAT_ACCOUNT_ID, E2E_FIAT_ACCOUNT_ID_2, E2E_QUOTE_ID, E2E_RAMP_ID, mockBackend } from "./support/mockBackend";
import { injectMockWallet, MOCK_WALLET_ADDRESS, MOCK_WALLET_TX_HASH } from "./support/mockWallet";
import { injectMockWallet, MOCK_WALLET_ADDRESS, MOCK_WALLET_SEND_COUNT_KEY, MOCK_WALLET_TX_HASH } from "./support/mockWallet";
import { seedSession } from "./support/session";

const EXPECTED_PAYIN_USDC = "54.054054";
Expand Down Expand Up @@ -124,6 +124,47 @@ test("SELL MXN transfer: quote, register, ephemeral presigning, wallet broadcast
expect(backend.unexpectedExternalRequests).toEqual([]);
});

// Once the wallet has broadcast, the tokens sit on the ramp's ephemeral account. A failed final
// update (here the maintenance guard's 503) must keep the ramp and its hash, survive a reload, and
// resend that same update on retry, never the wallet transaction.
test("SELL retries a failed post-broadcast update after a reload without re-broadcasting", async ({ page }) => {
const backend = await mockBackend(page, { rampHashUpdateFailures: 1 });
await injectMockWallet(page, { chainIdHex: "0x89" });
await seedSession(page);
await page.goto("/transfer?network=polygon");

const amountInput = page.locator("#token-amount");
await expect(amountInput).toBeVisible({ timeout: 20_000 });
await amountInput.fill(EXPECTED_PAYIN_USDC);
const sendButton = page.getByRole("button", { name: /Send/ });
await expect(sendButton).toBeEnabled({ timeout: 20_000 });
await sendButton.click();

const notStarted = page.getByRole("alert").filter({ hasText: "Your transfer hasn’t started yet" });
await expect(notStarted).toBeVisible({ timeout: 30_000 });
await expect(notStarted).toContainText("scheduled maintenance");
await expect(page.getByText(/Try again within \d+ min/)).toBeVisible();
expect(backend.updateRequests).toHaveLength(2);
expect(backend.startRequests).toHaveLength(0);

await page.reload();
await expect(page.getByText("The page was reloaded before the transfer started.")).toBeVisible({ timeout: 20_000 });
await page.getByRole("button", { name: "Try again" }).click();
await expect(page.getByText("Transfer initiated")).toBeVisible({ timeout: 30_000 });
await expect(page).toHaveURL(/\/transactions/);

expect(backend.registerRequests).toHaveLength(1);
expect(backend.updateRequests).toHaveLength(3);
expect(backend.updateRequests[2]).toEqual(backend.updateRequests[1]);
expect((backend.updateRequests[2] as { additionalData?: Record<string, unknown> }).additionalData).toEqual({
squidRouterNoPermitTransferHash: MOCK_WALLET_TX_HASH
});
expect(backend.startRequests).toHaveLength(1);
expect(await page.evaluate(key => sessionStorage.getItem(key), MOCK_WALLET_SEND_COUNT_KEY)).toBe("1");
expect(backend.unmatchedRequests).toEqual([]);
expect(backend.unexpectedExternalRequests).toEqual([]);
});

test("SELL refreshes a near-expiry quote before registration", async ({ page }) => {
const backend = await mockBackend(page, {
quoteOverrides: requestIndex => {
Expand Down
4 changes: 3 additions & 1 deletion apps/dashboard/src/components/transfer/OnrampForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,8 +125,10 @@ export function OnrampForm({ account, prefill }: { account: SenderAccount; prefi
transferState.matches("CheckingBalance") ||
transferState.matches("Registering") ||
transferState.matches("SigningUserTxs") ||
transferState.matches("SubmittingUserTxs") ||
transferState.matches("AwaitingPayment") ||
transferState.matches("Starting") ||
transferState.matches("AwaitingRetry") ||
transferState.matches("Tracking");

if (transferState.matches("AwaitingPayment") && transferState.context.ramp && belongsToActiveOwner) {
Expand Down Expand Up @@ -349,7 +351,7 @@ export function OnrampForm({ account, prefill }: { account: SenderAccount; prefi
</div>
<QuoteSummary isFetching={isFetching} quote={quote} />
<Button disabled={activeTransfer || isFetching || !!eurBlocker || !!maintenance} size="lg" type="submit">
{transferState.matches("Registering")
{transferState.matches("Registering") || transferState.matches("SubmittingUserTxs")
? "Preparing payment…"
: transferState.matches("SigningUserTxs")
? "Confirm in your wallet…"
Expand Down
145 changes: 140 additions & 5 deletions apps/dashboard/src/components/transfer/TransferForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@ import {
Networks,
QuoteError,
RampDirection,
type RampProcess,
subscribeEvmTokensLoaded
} from "@vortexfi/shared";
import { useSelector } from "@xstate/react";
import { Lock, TriangleAlert } from "lucide-react";
import { useState, useSyncExternalStore } from "react";
import { Lock, RotateCw, TriangleAlert } from "lucide-react";
import { useEffect, useState, useSyncExternalStore } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
Expand All @@ -20,12 +22,12 @@ import { CORRIDORS } from "@/domain/corridors";
import { getNetworkOptions, getRampTokenOptions } from "@/domain/onramp";
import { recipientLabel } from "@/domain/recipient";
import { RECIPIENT_STATUS_META } from "@/domain/status";
import { PAYMENT_METHOD_LABEL } from "@/domain/transfer";
import { offrampStartsAfterDeadline, PAYMENT_METHOD_LABEL } from "@/domain/transfer";
import type { CorridorId, Recipient, SenderAccount } from "@/domain/types";
import { MAINTENANCE_QUOTE_ERROR, useActiveMaintenance } from "@/hooks/useActiveMaintenance";
import { formatCurrencyAmount } from "@/lib/amount";
import { buildTransferAdditionalData } from "@/machines/registerAdditionalData";
import { transferActor } from "@/machines/transferActor";
import { resetTransferState, transferActor } from "@/machines/transferActor";
import { useQuote } from "@/services/api/hooks";
import { FundingMethods, type FundingSubmit } from "./FundingMethods";
import { QuoteSummary } from "./QuoteSummary";
Expand Down Expand Up @@ -112,6 +114,7 @@ export function TransferForm({ account, prefill, recipients, preselectRecipientI
snapshot.matches("CheckingBalance") ||
snapshot.matches("Registering") ||
snapshot.matches("SigningUserTxs") ||
snapshot.matches("SubmittingUserTxs") ||
snapshot.matches("Starting")
);
// The API permits parallel ramps, but this dashboard intentionally owns one local
Expand All @@ -135,6 +138,20 @@ export function TransferForm({ account, prefill, recipients, preselectRecipientI
: null;
const { data: quote, isFetching, error } = useQuote(quoteParams);
const maintenance = useActiveMaintenance();
// Once the wallet has broadcast, the tokens have left it: the form gives way to the ramp
// that has to be started, until it is tracking.
const broadcastRamp = useSelector(transferActor, snapshot =>
(snapshot.matches("SubmittingUserTxs") || snapshot.matches("Starting") || snapshot.matches("AwaitingRetry")) &&
snapshot.context.quote?.rampType === RampDirection.SELL &&
snapshot.context.meta?.ownerProfileId === snapshot.context.activeOwnerProfileId &&
snapshot.context.meta.accountId === account.id
? snapshot.context.ramp
: null
);

if (broadcastRamp) {
return <OfframpStartRetry ramp={broadcastRamp} />;
}

function submitTransfer(submit: FundingSubmit) {
if (!selected || !isSendable || !quote || !quoteParams || !activeOwnerProfileId || !canStartTransfer || !pixReady) {
Expand All @@ -158,7 +175,7 @@ export function TransferForm({ account, prefill, recipients, preselectRecipientI
description: `Funding via ${submit.label} — we'll pay out ${currentMeta?.summary ?? summary} once your ${formatCurrencyAmount(currentQuote?.inputAmount ?? quote.inputAmount, String(currentQuote?.inputCurrency ?? quote.inputCurrency))} ${currentQuote?.inputCurrency ?? quote.inputCurrency} lands.`
});
navigate({ to: "/transactions" });
} else if (snapshot.matches("Failed")) {
} else if (snapshot.matches("Failed") || snapshot.matches("AwaitingRetry")) {
subscription.unsubscribe();
toast.error("Could not start transfer", { description: snapshot.context.errorMessage ?? undefined });
}
Expand Down Expand Up @@ -337,6 +354,124 @@ export function TransferForm({ account, prefill, recipients, preselectRecipientI
);
}

/**
* A SELL whose wallet transactions are broadcast but whose /ramp/update or /ramp/start has not
* gone through. Retrying resends only that call; the API refuses to start the ramp once its
* start window (expiresAt) has passed, after which the tokens need a manual recovery.
*/
function OfframpStartRetry({ ramp }: { ramp: RampProcess }) {
const navigate = useNavigate();
const busy = useSelector(transferActor, snapshot => !snapshot.matches("AwaitingRetry"));
const startError = useSelector(transferActor, snapshot => snapshot.context.errorMessage);
// No wallet output left to submit means /ramp/update accepted the source hash.
const startsAutomatically = useSelector(
transferActor,
snapshot =>
!!snapshot.context.quote &&
offrampStartsAfterDeadline(snapshot.context.quote.outputCurrency, snapshot.context.userTxSubmission === null)
);
const [now, setNow] = useState(() => Date.now());
const deadline = ramp.expiresAt ? new Date(ramp.expiresAt).getTime() : Number.NaN;
const expired = Number.isFinite(deadline) && deadline <= now;

useEffect(() => {
const timer = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(timer);
}, []);

function retry() {
const ownerProfileId = transferActor.getSnapshot().context.activeOwnerProfileId;
if (!ownerProfileId) {
return;
}
const subscription = transferActor.subscribe(snapshot => {
if (snapshot.context.activeOwnerProfileId !== ownerProfileId) {
subscription.unsubscribe();
return;
}
if (snapshot.matches("Tracking")) {
subscription.unsubscribe();
toast.success("Transfer initiated", { description: snapshot.context.meta?.summary });
navigate({ to: "/transactions" });
} else if (snapshot.matches("AwaitingRetry")) {
subscription.unsubscribe();
}
});
transferActor.send({ ownerProfileId, type: "RETRY" });
}

if (busy) {
return (
<p className="rounded-lg border border-dashed p-4 text-center text-muted-foreground text-sm" role="status">
Starting your transfer… Your tokens have left your wallet, so keep this page open.
</p>
);
}

if (expired) {
return (
<div className="grid gap-5">
<div className="flex items-start gap-3 rounded-lg border border-destructive/40 bg-destructive/5 p-4 text-destructive">
<TriangleAlert className="mt-px size-5 shrink-0" />
<div className="grid gap-1 text-sm">
{startsAutomatically ? (
<>
<h2 className="font-semibold text-base">This transfer will start automatically</h2>
<p>
Your tokens left your wallet and Vortex has the transaction on record, so it starts this transfer on its own
within about 10 minutes. Don’t send this payment again. If it still hasn’t started after that, contact support
with transfer ID <span className="break-all font-mono">{ramp.id}</span>.
</p>
</>
) : (
<>
<h2 className="font-semibold text-base">The window to start this transfer has closed</h2>
<p>
Your tokens left your wallet, but the transfer can no longer be started. Contact support with transfer ID{" "}
<span className="break-all font-mono">{ramp.id}</span>.
</p>
</>
)}
<p>
Before sending this payment again, check Transactions: if this transfer shows as processing, it already started.
Don’t clear this browser’s data: it holds the keys needed to recover your tokens.
</p>
</div>
</div>
<Button onClick={resetTransferState} size="lg" type="button" variant="outline">
Start a new transfer
</Button>
</div>
);
}

return (
<div className="grid gap-5">
<div
className="flex items-start gap-3 rounded-lg border border-destructive/40 bg-destructive/5 p-4 text-destructive"
role="alert"
>
<TriangleAlert className="mt-px size-5 shrink-0" />
<div className="grid gap-1 text-sm">
<h2 className="font-semibold text-base">Your transfer hasn’t started yet</h2>
<p>{startError ?? "The page was reloaded before the transfer started."}</p>
<p>Your tokens already left your wallet. Trying again finishes starting this transfer and never sends them again.</p>
</div>
</div>
{Number.isFinite(deadline) && (
<p className="text-center font-medium text-sm">
Try again within {Math.ceil((deadline - now) / 60_000)} min (before{" "}
{new Date(deadline).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}). After that the transfer can no
longer be started.
</p>
)}
<Button onClick={retry} size="lg" type="button">
<RotateCw /> Try again
</Button>
</div>
);
}

function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex items-center justify-between text-sm">
Expand Down
20 changes: 20 additions & 0 deletions apps/dashboard/src/domain/transfer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { FiatToken } from "@vortexfi/shared";
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { offrampStartsAfterDeadline } from "./transfer";

describe("offrampStartsAfterDeadline", () => {
it("is true for a non-domestic SELL whose source hash the API accepted", () => {
assert.equal(offrampStartsAfterDeadline(FiatToken.BRL, true), true);
});

it("is false while the source hash is still unsubmitted", () => {
assert.equal(offrampStartsAfterDeadline(FiatToken.BRL, false), false);
});

it("is false for AlfredPay pay-outs, which the recovery worker never starts", () => {
for (const currency of [FiatToken.USD, FiatToken.MXN, FiatToken.COP, FiatToken.ARS]) {
assert.equal(offrampStartsAfterDeadline(currency, true), false);
}
});
});
11 changes: 10 additions & 1 deletion apps/dashboard/src/domain/transfer.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Networks } from "@vortexfi/shared";
import { isDomesticToken, Networks, type RampCurrency } from "@vortexfi/shared";
import type { RecipientMethod } from "./types";

/** Display label for the fiat rail each corridor settles on. */
Expand Down Expand Up @@ -29,3 +29,12 @@ export function shortenAddress(address: string): string {
}
return `${address.slice(0, 6)}…${address.slice(-4)}`;
}

/**
* Whether the API starts a broadcast offramp on its own once its 15-minute start deadline has
* passed. The API's recovery worker does so for a non-domestic (non-AlfredPay) SELL whose source
* hash /ramp/update has already accepted.
*/
export function offrampStartsAfterDeadline(outputCurrency: RampCurrency, sourceHashAccepted: boolean): boolean {
return sourceHashAccepted && !isDomesticToken(outputCurrency);
}
Loading
Loading