From 37cb4de0ce9c4724fc631cb29ab6d6489de395b8 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 29 Sep 2026 16:45:06 +0200 Subject: [PATCH 1/3] fix(frontend): never broadcast a sell after its start window closed The API refuses to record or start a ramp 15 minutes after registration. A user-wallet transfer or Squid swap broadcast later moves the funds to the ephemeral of a ramp that can never start, leaving them for manual recovery. Refuse each broadcast once less than four minutes remain; typed-data permits move nothing until Vortex executes them, so they stay unguarded. --- .../src/machines/actors/sign.actor.test.ts | 30 +++++++++++++++++-- .../src/machines/actors/sign.actor.ts | 15 ++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/apps/frontend/src/machines/actors/sign.actor.test.ts b/apps/frontend/src/machines/actors/sign.actor.test.ts index e59cec4248..aac5e50000 100644 --- a/apps/frontend/src/machines/actors/sign.actor.test.ts +++ b/apps/frontend/src/machines/actors/sign.actor.test.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { http, HttpResponse } from "msw"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; // The real module imports wagmi/walletconnect config at module load; the actor tests only // care about which signing helper is routed to, not the wallet plumbing itself. @@ -31,7 +31,7 @@ const ALICE_SUBSTRATE = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"; function buildRampState(unsignedTxs: UnsignedTx[]): RampState { return { quote: buildQuoteResponse(), - ramp: { ...buildRampProcess("initial"), unsignedTxs }, + ramp: { ...buildRampProcess("initial"), expiresAt: new Date(Date.now() + 15 * 60_000).toISOString(), unsignedTxs }, requiredUserActionsCompleted: false, signedTransactions: [], userSigningMeta: undefined @@ -221,6 +221,32 @@ describe("signTransactionsActor", () => { }); }); + describe("start deadline", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("refuses to broadcast the swap when the approve prompt ran into the start deadline margin", async () => { + const updateCalls = mockUpdateEndpoint(); + vi.mocked(signAndSubmitEvmTransaction).mockImplementationOnce(async () => { + // The user leaves the approve prompt open until only three minutes of the start window remain. + vi.setSystemTime(Date.now() + 12 * 60_000); + return "0xapprovehash"; + }); + const context = buildContext([ + buildUnsignedTx({ nonce: 1, phase: "squidRouterApprove" }), + buildUnsignedTx({ nonce: 2, phase: "squidRouterSwap" }) + ]); + const { parent } = buildParent(); + + await expect(signTransactionsActor({ input: { context, parent } })).rejects.toMatchObject({ + type: SignRampErrorType.StartWindowClosed + }); + expect(vi.mocked(signAndSubmitEvmTransaction).mock.calls.map(call => call[0].phase)).toEqual(["squidRouterApprove"]); + expect(updateCalls).toHaveLength(0); + }); + }); + describe("typed-data signing", () => { it("signs a single typed-data payload, replaces the txData and submits it as a presigned tx", async () => { const updateCalls = mockUpdateEndpoint(); diff --git a/apps/frontend/src/machines/actors/sign.actor.ts b/apps/frontend/src/machines/actors/sign.actor.ts index cf03a28829..ecbd25c849 100644 --- a/apps/frontend/src/machines/actors/sign.actor.ts +++ b/apps/frontend/src/machines/actors/sign.actor.ts @@ -18,6 +18,7 @@ import { RampContext, RampMachineActor, RampState } from "../types"; export enum SignRampErrorType { InvalidInput = "INVALID_INPUT", UserRejected = "USER_REJECTED", + StartWindowClosed = "START_WINDOW_CLOSED", UnknownError = "UNKNOWN_ERROR" } export class SignRampError extends Error implements DomainError { @@ -30,6 +31,10 @@ export class SignRampError extends Error implements DomainError { } } +// The API refuses to record or start a ramp after its start deadline, so funds broadcast later strand on the +// ephemeral. The margin covers the wallet confirmation, the receipt wait and the update/start calls. +const START_DEADLINE_MARGIN_MS = 4 * 60_000; + export const signTransactionsActor = async ({ input }: { @@ -88,6 +93,13 @@ export const signTransactionsActor = async ({ const tx = sortedTxs[idx]; const current = idx + 1; + // Typed-data permits move nothing until the backend executes them after a successful start. + // The negated comparison fails closed when expiresAt is missing (NaN). + const isBroadcast = !isSignedTypedData(tx.txData) && !isSignedTypedDataArray(tx.txData); + if (isBroadcast && !(Date.parse(rampState.ramp?.expiresAt ?? "") - Date.now() > START_DEADLINE_MARGIN_MS)) { + throw new SignRampError("Ramp start window closed before broadcast", SignRampErrorType.StartWindowClosed); + } + if (isSignedTypedData(tx.txData) || isSignedTypedDataArray(tx.txData)) { input.parent.send({ current, max: total, phase: "started", type: "SIGNING_UPDATE" }); if (isSignedTypedData(tx.txData)) { @@ -135,6 +147,9 @@ export const signTransactionsActor = async ({ } } catch (error) { console.log("Error during signing transactions: ", error); + if (error instanceof SignRampError) { + throw error; + } // We try to catch an error caused by user rejection of the signature request. if (error instanceof Error && error.message) { if (error.message.includes("User rejected the request")) { From 6d1a0cf160c4dd80c83e4470bb9e7bea6743b324 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 29 Sep 2026 16:45:06 +0200 Subject: [PATCH 2/3] fix(frontend): tell the user an expired sale sent nothing --- apps/frontend/src/components/widget-steps/ErrorStep/index.tsx | 2 ++ apps/frontend/src/translations/en.json | 3 ++- apps/frontend/src/translations/pt.json | 3 ++- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/frontend/src/components/widget-steps/ErrorStep/index.tsx b/apps/frontend/src/components/widget-steps/ErrorStep/index.tsx index 0d9c052153..26fa8a0a53 100644 --- a/apps/frontend/src/components/widget-steps/ErrorStep/index.tsx +++ b/apps/frontend/src/components/widget-steps/ErrorStep/index.tsx @@ -18,6 +18,8 @@ function convertCommonErrorToMessage(error: string, t: (key: string) => string): return t("components.errorStep.errors.quoteExpired"); } else if (error.includes("Insufficient funds")) { return t("components.errorStep.errors.insufficientFunds"); + } else if (error.includes("Ramp start window closed")) { + return t("components.errorStep.errors.startWindowClosed"); } else { return error; } diff --git a/apps/frontend/src/translations/en.json b/apps/frontend/src/translations/en.json index 7829cc4bef..3f8197c446 100644 --- a/apps/frontend/src/translations/en.json +++ b/apps/frontend/src/translations/en.json @@ -344,7 +344,8 @@ "invalidTransactionParameters": "The transaction parameters provided are invalid. Please verify your inputs.", "networkError": "A network error occurred. Please check your internet connection.", "quoteExpired": "Your quote has expired. Please refresh and request a new quote.", - "serverError": "A server error occurred while processing your request. Please try again later." + "serverError": "A server error occurred while processing your request. Please try again later.", + "startWindowClosed": "This sale expired before your funds were sent. Your funds are still in your wallet. Please start a new sale." }, "retryButton": "Try Again", "title": "Something Went Wrong" diff --git a/apps/frontend/src/translations/pt.json b/apps/frontend/src/translations/pt.json index 7a817a73fc..0cf2d01d97 100644 --- a/apps/frontend/src/translations/pt.json +++ b/apps/frontend/src/translations/pt.json @@ -347,7 +347,8 @@ "invalidTransactionParameters": "Os parâmetros da transação fornecidos são inválidos. Verifique suas entradas.", "networkError": "Ocorreu um erro de rede. Verifique sua conexão com a internet.", "quoteExpired": "Sua cotação expirou. Atualize e solicite uma nova cotação.", - "serverError": "Ocorreu um erro no servidor ao processar sua solicitação. Tente novamente mais tarde." + "serverError": "Ocorreu um erro no servidor ao processar sua solicitação. Tente novamente mais tarde.", + "startWindowClosed": "Esta venda expirou antes do envio dos seus fundos. Seus fundos continuam na sua carteira. Inicie uma nova venda." }, "retryButton": "Tentar Novamente", "title": "Algo deu errado" From d254a5e63637a668c55d21d25fdc7dd5c87906cc Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 29 Sep 2026 16:45:07 +0200 Subject: [PATCH 3/3] docs(frontend): document the sell start-deadline broadcast guard --- docs/security-spec/03-ramp-engine/ramp-phase-flows.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/security-spec/03-ramp-engine/ramp-phase-flows.md b/docs/security-spec/03-ramp-engine/ramp-phase-flows.md index 2e67bccfd4..fe86675649 100644 --- a/docs/security-spec/03-ramp-engine/ramp-phase-flows.md +++ b/docs/security-spec/03-ramp-engine/ramp-phase-flows.md @@ -224,6 +224,7 @@ graph TD | **Subsidy drain** | A crafted ramp triggers multiple subsidization phases, each at the maximum allowed amount, draining the funding account. | Per-ramp subsidy caps (`MAX_FINAL_SETTLEMENT_SUBSIDY_USD`, balance pre-checks in pre/post-swap handlers). EVM pre/post-swap caps are env-configured quote-relative fractions, and EVM post-swap subsidy is split into discrepancy and discount components with independent caps. No aggregate cross-ramp cap exists — many concurrent ramps could still drain funds. | | **Double-execution on retry** | Phase processor retries after timeout. Handler re-executes a swap or transfer that already completed. Funds are consumed twice. | The Hydration handler has a nonce guard. Other handlers rely on transaction nonce uniqueness at the chain level. Not all handlers have explicit re-execution guards. | | **Stale presigned transaction** | Client registers a ramp, waits for market movement, then starts the ramp with presigned transactions based on the old quote. | `RAMP_START_EXPIRATION_TIME_SECONDS` limits the window between registration and start. Quote expiry (10 minutes) limits how old the amounts can be. | +| **User-wallet funding broadcast after the start deadline** | A widget user leaves a wallet prompt open, or resumes a restored signing session, past `RAMP_START_EXPIRATION_TIME_SECONDS`, then broadcasts the transfer or Squid swap that moves funds to the ephemeral. The API refuses the late update/start, so the hash is never recorded and the funds strand on the ephemeral with only manual recovery. | `apps/frontend/src/machines/actors/sign.actor.ts` refuses each user-wallet broadcast (`squidRouter*`, `assethubToPendulum`) once less than 4 minutes remain before the ramp's `expiresAt`, and tells the user the funds were not sent. EIP-712 permits are not guarded: they move nothing until Vortex executes them after a successful start. Residual: the check runs before each wallet prompt, so a single prompt left open across the deadline can still broadcast. | | **Direct API ramp mutation during planned downtime** | A partner bypasses the UI maintenance state and calls register/update/start while operators expect Vortex services to be paused. | Ramp mutation routes run the backend maintenance guard and return `503` with `Retry-After`, `maintenance_start`, and `maintenance_end` before registration, presigned transaction updates, or phase processing begins. | | **Cross-chain race condition** | XCM transfer submitted but not finalized. Next phase on destination chain reads a zero balance. | Most XCM handlers use `waitForFinalization=true`. Exception: Hydration skips finalization (F-009, deferred). | | **Fee distribution failure** | `distributeFees` fails, but ramp is already marked `complete`. Platform loses fee revenue. | `distributeFees` is a phase — if it fails, the ramp enters retry, not `complete`. However, if the ramp fails after user delivery but before fee distribution, fees may be lost. |