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
2 changes: 2 additions & 0 deletions apps/frontend/src/components/widget-steps/ErrorStep/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
30 changes: 28 additions & 2 deletions apps/frontend/src/machines/actors/sign.actor.test.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down
15 changes: 15 additions & 0 deletions apps/frontend/src/machines/actors/sign.actor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}: {
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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")) {
Expand Down
3 changes: 2 additions & 1 deletion apps/frontend/src/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
3 changes: 2 additions & 1 deletion apps/frontend/src/translations/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions docs/security-spec/03-ramp-engine/ramp-phase-flows.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
Loading