From eaa3a9d043990efc7fb9f53f5451b54bcd45c939 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 29 May 2026 16:06:00 +0200 Subject: [PATCH 1/5] Validate ephemeral account freshness at ramp registration Reject registerRamp if any required ephemeral has on-chain history on a route-relevant chain (non-zero nonce/balance on Substrate/EVM, or a pre-existing Stellar account). Without this, the backend builds presigned transactions assuming nonce 0 and the ramp halts mid-execution after subsidies have already been spent. Route-to-network mapping mirrors the offramp/onramp transaction-builder dispatcher so only chains an ephemeral actually signs on are checked. Fails closed on RPC errors (SERVICE_UNAVAILABLE) since freshness cannot be presumed without on-chain data. --- .../services/ramp/ephemeral-freshness.test.ts | 205 +++++++++++++++ .../api/services/ramp/ephemeral-freshness.ts | 248 ++++++++++++++++++ .../api/src/api/services/ramp/ramp.service.ts | 3 + 3 files changed, 456 insertions(+) create mode 100644 apps/api/src/api/services/ramp/ephemeral-freshness.test.ts create mode 100644 apps/api/src/api/services/ramp/ephemeral-freshness.ts diff --git a/apps/api/src/api/services/ramp/ephemeral-freshness.test.ts b/apps/api/src/api/services/ramp/ephemeral-freshness.test.ts new file mode 100644 index 000000000..eb78fad41 --- /dev/null +++ b/apps/api/src/api/services/ramp/ephemeral-freshness.test.ts @@ -0,0 +1,205 @@ +import { beforeEach, describe, expect, it, mock } from "bun:test"; +import { AssetHubToken, EphemeralAccountType, EvmToken, FiatToken, Networks, RampDirection } from "@vortexfi/shared"; +import type QuoteTicket from "../../../models/quoteTicket.model"; +import { APIError } from "../../errors/api-error"; + +const STELLAR_ADDR = "GABCD"; +const SUBSTRATE_ADDR = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty"; +const EVM_ADDR = "0x1111111111111111111111111111111111111111"; + +let substrateNonce = 0; +let substrateFree = "0"; +let evmNonce = 0; +let stellarAccount: { sequence: string } | null = null; +let evmGetClientShouldThrow = false; + +mock.module("@vortexfi/shared", () => { + const actual = require("@vortexfi/shared"); + return { + ...actual, + ApiManager: { + getInstance: () => ({ + getApi: async (_network: string) => ({ + api: { + query: { + system: { + account: async (_address: string) => ({ + data: { free: { toString: () => substrateFree } }, + nonce: { toNumber: () => substrateNonce } + }) + } + } + } + }) + }) + }, + EvmClientManager: { + getInstance: () => ({ + getClient: (_network: string) => { + if (evmGetClientShouldThrow) throw new Error("RPC down"); + return { + getTransactionCount: async (_args: { address: string }) => evmNonce + }; + } + }) + } + }; +}); + +mock.module("../stellar/loadAccount", () => ({ + loadAccountWithRetry: async (_address: string) => stellarAccount +})); + +// Import AFTER mocks are registered so the module picks up the mocked deps. +const { getEphemeralNetworksForQuote, validateEphemeralAccountsFresh } = await import("./ephemeral-freshness"); + +function makeQuote(overrides: Partial): QuoteTicket { + return { + from: Networks.Polygon, + inputCurrency: EvmToken.USDC, + outputCurrency: FiatToken.BRL, + rampType: RampDirection.SELL, + to: Networks.Pendulum, + ...overrides + } as unknown as QuoteTicket; +} + +describe("getEphemeralNetworksForQuote", () => { + it("offramp BRL with EVM input → EVM:[Base]", () => { + const result = getEphemeralNetworksForQuote( + makeQuote({ from: Networks.Polygon, inputCurrency: EvmToken.USDC, outputCurrency: FiatToken.BRL, rampType: RampDirection.SELL }) + ); + expect(result.evm).toEqual([Networks.Base]); + expect(result.substrate).toEqual([]); + expect(result.stellar).toBe(false); + }); + + it("offramp non-BRL non-Monerium → Substrate:[pendulum] + Stellar", () => { + const result = getEphemeralNetworksForQuote( + makeQuote({ from: Networks.Polygon, inputCurrency: EvmToken.USDC, outputCurrency: FiatToken.EURC, rampType: RampDirection.SELL }) + ); + expect(result.substrate).toEqual(["pendulum"]); + expect(result.stellar).toBe(true); + }); + + it("offramp Monerium → no ephemerals required", () => { + const result = getEphemeralNetworksForQuote( + makeQuote({ from: Networks.Polygon, inputCurrency: EvmToken.USDC, outputCurrency: FiatToken.EURC, rampType: RampDirection.SELL }), + { moneriumAuthToken: "tok" } + ); + expect(result.evm).toEqual([]); + expect(result.substrate).toEqual([]); + expect(result.stellar).toBe(false); + }); + + it("onramp BRL → AssetHub non-USDC → EVM:[Moonbeam] + Substrate:[pendulum, hydration]", () => { + const result = getEphemeralNetworksForQuote( + makeQuote({ + inputCurrency: FiatToken.BRL, + outputCurrency: AssetHubToken.DOT, + rampType: RampDirection.BUY, + to: Networks.AssetHub + }) + ); + expect(result.evm).toEqual([Networks.Moonbeam]); + expect(result.substrate).toEqual(["pendulum", "hydration"]); + }); +}); + +describe("validateEphemeralAccountsFresh", () => { + beforeEach(() => { + substrateNonce = 0; + substrateFree = "0"; + evmNonce = 0; + stellarAccount = null; + evmGetClientShouldThrow = false; + }); + + it("passes when all ephemerals are fresh", async () => { + await expect( + validateEphemeralAccountsFresh( + { [EphemeralAccountType.EVM]: EVM_ADDR, [EphemeralAccountType.Stellar]: STELLAR_ADDR, [EphemeralAccountType.Substrate]: SUBSTRATE_ADDR }, + { evm: [Networks.Base], stellar: true, substrate: ["pendulum"] } + ) + ).resolves.toBeUndefined(); + }); + + it("rejects non-fresh Substrate (non-zero nonce)", async () => { + substrateNonce = 1; + try { + await validateEphemeralAccountsFresh( + { [EphemeralAccountType.Substrate]: SUBSTRATE_ADDR }, + { evm: [], stellar: false, substrate: ["pendulum"] } + ); + throw new Error("expected rejection"); + } catch (err) { + expect(err).toBeInstanceOf(APIError); + expect((err as APIError).status).toBe(400); + expect((err as APIError).message).toContain("not fresh"); + } + }); + + it("rejects non-fresh Substrate (non-zero free balance)", async () => { + substrateFree = "1000"; + try { + await validateEphemeralAccountsFresh( + { [EphemeralAccountType.Substrate]: SUBSTRATE_ADDR }, + { evm: [], stellar: false, substrate: ["pendulum"] } + ); + throw new Error("expected rejection"); + } catch (err) { + expect((err as APIError).status).toBe(400); + } + }); + + it("rejects non-fresh EVM (non-zero nonce)", async () => { + evmNonce = 5; + try { + await validateEphemeralAccountsFresh( + { [EphemeralAccountType.EVM]: EVM_ADDR }, + { evm: [Networks.Base], stellar: false, substrate: [] } + ); + throw new Error("expected rejection"); + } catch (err) { + expect((err as APIError).status).toBe(400); + expect((err as APIError).message).toContain("not fresh"); + } + }); + + it("rejects when Stellar account already exists on-chain", async () => { + stellarAccount = { sequence: "12345" }; + try { + await validateEphemeralAccountsFresh( + { [EphemeralAccountType.Stellar]: STELLAR_ADDR }, + { evm: [], stellar: true, substrate: [] } + ); + throw new Error("expected rejection"); + } catch (err) { + expect((err as APIError).status).toBe(400); + expect((err as APIError).message).toContain("already exists"); + } + }); + + it("rejects when a route-required ephemeral is missing", async () => { + try { + await validateEphemeralAccountsFresh({}, { evm: [Networks.Base], stellar: false, substrate: [] }); + throw new Error("expected rejection"); + } catch (err) { + expect((err as APIError).status).toBe(400); + expect((err as APIError).message).toContain("required"); + } + }); + + it("fails closed with SERVICE_UNAVAILABLE on RPC error", async () => { + evmGetClientShouldThrow = true; + try { + await validateEphemeralAccountsFresh( + { [EphemeralAccountType.EVM]: EVM_ADDR }, + { evm: [Networks.Base], stellar: false, substrate: [] } + ); + throw new Error("expected rejection"); + } catch (err) { + expect((err as APIError).status).toBe(503); + } + }); +}); diff --git a/apps/api/src/api/services/ramp/ephemeral-freshness.ts b/apps/api/src/api/services/ramp/ephemeral-freshness.ts new file mode 100644 index 000000000..e6bfed02e --- /dev/null +++ b/apps/api/src/api/services/ramp/ephemeral-freshness.ts @@ -0,0 +1,248 @@ +import { + ApiManager, + EphemeralAccountType, + EvmClientManager, + EvmNetworks, + FiatToken, + getNetworkFromDestination, + getOnChainTokenDetails, + isAlfredpayToken, + isEvmTokenDetails, + isNetworkEVM, + Networks, + OnChainToken, + RampDirection, + RegisterRampRequest, + SubstrateApiNetwork +} from "@vortexfi/shared"; +import Big from "big.js"; +import httpStatus from "http-status"; +import QuoteTicket from "../../../models/quoteTicket.model"; +import { APIError } from "../../errors/api-error"; +import { loadAccountWithRetry } from "../stellar/loadAccount"; + +export interface EphemeralNetworks { + substrate: SubstrateApiNetwork[]; + evm: EvmNetworks[]; + stellar: boolean; +} + +const USDC = "usdc"; + +// SECURITY: mirrors the dispatcher logic in apps/api/src/api/services/transactions/{offramp,onramp}/index.ts. +// If you add a new route variant or change a sub-builder's network assignments, you MUST update this function too. +// A missed network leaves a freshness-check gap. +export function getEphemeralNetworksForQuote( + quote: QuoteTicket, + additionalData?: RegisterRampRequest["additionalData"] +): EphemeralNetworks { + const result: EphemeralNetworks = { evm: [], stellar: false, substrate: [] }; + + if (quote.rampType === RampDirection.SELL) { + return getOfframpNetworks(quote, additionalData, result); + } + return getOnrampNetworks(quote, result); +} + +function getOfframpNetworks( + quote: QuoteTicket, + additionalData: RegisterRampRequest["additionalData"] | undefined, + result: EphemeralNetworks +): EphemeralNetworks { + const fromNetwork = getNetworkFromDestination(quote.from); + if (!fromNetwork) { + throw new Error(`Invalid network for destination ${quote.from}`); + } + + const inputTokenDetails = getOnChainTokenDetails(fromNetwork, quote.inputCurrency as OnChainToken); + const inputIsEvm = !!(inputTokenDetails && isEvmTokenDetails(inputTokenDetails)); + + if (quote.outputCurrency === FiatToken.BRL) { + if (inputIsEvm) { + result.evm.push(Networks.Base); + } else { + result.substrate.push("pendulum"); + } + return result; + } + + if (quote.outputCurrency === FiatToken.EURC && additionalData?.moneriumAuthToken) { + return result; + } + + if (isAlfredpayToken(quote.outputCurrency as FiatToken)) { + result.evm.push(Networks.Polygon); + return result; + } + + result.substrate.push("pendulum"); + result.stellar = true; + return result; +} + +function getOnrampNetworks(quote: QuoteTicket, result: EphemeralNetworks): EphemeralNetworks { + const toNetwork = getNetworkFromDestination(quote.to); + if (!toNetwork) { + throw new Error(`Invalid network for destination ${quote.to}`); + } + const outputIsUsdc = (quote.outputCurrency as string).toLowerCase() === USDC; + + if (quote.inputCurrency === FiatToken.BRL) { + if (toNetwork === Networks.AssetHub) { + result.evm.push(Networks.Moonbeam); + result.substrate.push("pendulum"); + if (!outputIsUsdc) { + result.substrate.push("hydration"); + } + } else { + pushEvmDedup(result, Networks.Base); + if (isNetworkEVM(toNetwork)) { + pushEvmDedup(result, toNetwork); + } + } + return result; + } + + if (quote.inputCurrency === FiatToken.EURC) { + if (toNetwork === Networks.AssetHub) { + pushEvmDedup(result, Networks.Polygon); + pushEvmDedup(result, Networks.Moonbeam); + result.substrate.push("pendulum"); + if (!outputIsUsdc) { + result.substrate.push("hydration"); + } + } else { + pushEvmDedup(result, Networks.Polygon); + if (isNetworkEVM(toNetwork)) { + pushEvmDedup(result, toNetwork); + } + } + return result; + } + + if (isAlfredpayToken(quote.inputCurrency as FiatToken)) { + pushEvmDedup(result, Networks.Polygon); + if (isNetworkEVM(toNetwork)) { + pushEvmDedup(result, toNetwork); + } + return result; + } + + throw new Error(`Unsupported onramp input currency: ${quote.inputCurrency}`); +} + +function pushEvmDedup(result: EphemeralNetworks, network: EvmNetworks): void { + if (!result.evm.includes(network)) { + result.evm.push(network); + } +} + +// SECURITY: fail-closed. Any RPC error rejects the registration since we cannot prove freshness without on-chain data. +export async function validateEphemeralAccountsFresh( + ephemerals: { [key in EphemeralAccountType]?: string }, + networks: EphemeralNetworks +): Promise { + const checks: Promise[] = []; + + if (networks.substrate.length > 0) { + const substrateAddress = ephemerals[EphemeralAccountType.Substrate]; + if (!substrateAddress) { + throw new APIError({ + message: "Substrate ephemeral address is required for this ramp route but was not provided.", + status: httpStatus.BAD_REQUEST + }); + } + for (const network of networks.substrate) { + checks.push(assertSubstrateAccountFresh(substrateAddress, network)); + } + } + + if (networks.evm.length > 0) { + const evmAddress = ephemerals[EphemeralAccountType.EVM]; + if (!evmAddress) { + throw new APIError({ + message: "EVM ephemeral address is required for this ramp route but was not provided.", + status: httpStatus.BAD_REQUEST + }); + } + for (const network of networks.evm) { + checks.push(assertEvmAccountFresh(evmAddress, network)); + } + } + + if (networks.stellar) { + const stellarAddress = ephemerals[EphemeralAccountType.Stellar]; + if (!stellarAddress) { + throw new APIError({ + message: "Stellar ephemeral address is required for this ramp route but was not provided.", + status: httpStatus.BAD_REQUEST + }); + } + checks.push(assertStellarAccountFresh(stellarAddress)); + } + + await Promise.all(checks); +} + +async function assertSubstrateAccountFresh(address: string, network: SubstrateApiNetwork): Promise { + let nonce: number; + let free: string; + try { + const { api } = await ApiManager.getInstance().getApi(network); + // @ts-ignore - api.query.system.account return type is dynamic per chain + const accountInfo = await api.query.system.account(address); + nonce = accountInfo.nonce.toNumber(); + free = accountInfo.data.free.toString(); + } catch (error) { + throw new APIError({ + message: `Could not verify freshness of Substrate ephemeral ${address} on ${network}: ${(error as Error).message}`, + status: httpStatus.SERVICE_UNAVAILABLE + }); + } + + if (nonce !== 0 || !Big(free).eq(0)) { + throw new APIError({ + message: `Substrate ephemeral ${address} is not fresh on ${network} (nonce=${nonce}, free=${free}). A new, unused ephemeral account must be provided.`, + status: httpStatus.BAD_REQUEST + }); + } +} + +async function assertEvmAccountFresh(address: string, network: EvmNetworks): Promise { + let nonce: number; + try { + const client = EvmClientManager.getInstance().getClient(network); + nonce = await client.getTransactionCount({ address: address as `0x${string}` }); + } catch (error) { + throw new APIError({ + message: `Could not verify freshness of EVM ephemeral ${address} on ${network}: ${(error as Error).message}`, + status: httpStatus.SERVICE_UNAVAILABLE + }); + } + + if (nonce !== 0) { + throw new APIError({ + message: `EVM ephemeral ${address} is not fresh on ${network} (nonce=${nonce}). A new, unused ephemeral account must be provided.`, + status: httpStatus.BAD_REQUEST + }); + } +} + +async function assertStellarAccountFresh(address: string): Promise { + let account: Awaited>; + try { + account = await loadAccountWithRetry(address); + } catch (error) { + throw new APIError({ + message: `Could not verify freshness of Stellar ephemeral ${address}: ${(error as Error).message}`, + status: httpStatus.SERVICE_UNAVAILABLE + }); + } + + if (account !== null) { + throw new APIError({ + message: `Stellar ephemeral ${address} already exists on-chain (sequence=${account.sequence}). The server creates and funds this account during the ramp; the provided address must not exist yet.`, + status: httpStatus.BAD_REQUEST + }); + } +} diff --git a/apps/api/src/api/services/ramp/ramp.service.ts b/apps/api/src/api/services/ramp/ramp.service.ts index a6f9418ad..1c4537062 100644 --- a/apps/api/src/api/services/ramp/ramp.service.ts +++ b/apps/api/src/api/services/ramp/ramp.service.ts @@ -64,6 +64,7 @@ import { AveniaOnrampTransactionParams, MoneriumOnrampTransactionParams } from " import { validatePresignedTxs } from "../transactions/validation"; import webhookDeliveryService from "../webhook/webhook-delivery.service"; import { BaseRampService } from "./base.service"; +import { getEphemeralNetworksForQuote, validateEphemeralAccountsFresh } from "./ephemeral-freshness"; import { getFinalTransactionHashForRamp } from "./helpers"; import { validateMoneriumOnrampPermit } from "./monerium-permit"; import { RampTransactionPreparationKind, selectRampTransactionPreparationKind } from "./ramp-transaction-preparation"; @@ -215,6 +216,8 @@ export class RampService extends BaseRampService { const { normalizedSigningAccounts, ephemerals } = normalizeAndValidateSigningAccounts(signingAccounts); + await validateEphemeralAccountsFresh(ephemerals, getEphemeralNetworksForQuote(quote, additionalData)); + const { unsignedTxs, stateMeta, depositQrCode, ibanPaymentData, aveniaTicketId } = await this.prepareRampTransactions( quote, normalizedSigningAccounts, From 8d91c4a7f6a1cdec269cc5238e7820f5207530ab Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 29 May 2026 16:06:06 +0200 Subject: [PATCH 2/5] Surface ephemeral-not-fresh errors in SDK Add EphemeralNotFreshError and EphemeralFreshnessCheckError so partner clients can distinguish stale-ephemeral failures from other registerRamp errors and recover by retrying (the SDK regenerates ephemerals on every call). Document the retry pattern in the SDK README. --- packages/sdk/README.md | 25 ++++++++++++++++++++++++ packages/sdk/src/errors.ts | 39 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 44c914024..5ab77ef9e 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -85,6 +85,31 @@ Submits route-specific transaction hashes after off-chain steps complete. Used f ##### `startRamp(rampId: string): Promise` Starts a registered ramp process. +## Error Handling + +### Ephemeral Account Freshness + +`registerRamp` will throw `EphemeralNotFreshError` if the SDK-generated ephemeral address already has on-chain history (non-zero nonce or balance on Substrate/EVM, or a pre-existing account on Stellar) on any chain the ramp route uses. This should not happen during normal operation because the SDK generates fresh keypairs on every call, but it can occur on environments where ephemeral storage is reused across processes or if the same keys are imported elsewhere. + +`EphemeralFreshnessCheckError` (HTTP 503) is thrown when the backend cannot reach an RPC endpoint to verify freshness. This is a transient failure. + +Both errors are recoverable by simply re-invoking `registerRamp` — the SDK generates new ephemerals on every call: + +```typescript +import { EphemeralNotFreshError, EphemeralFreshnessCheckError } from "@vortexfi/sdk"; + +try { + const { rampProcess } = await sdk.registerRamp(quote, additionalData); +} catch (err) { + if (err instanceof EphemeralNotFreshError || err instanceof EphemeralFreshnessCheckError) { + // The SDK regenerates ephemerals on every call - retry once. + const { rampProcess } = await sdk.registerRamp(quote, additionalData); + } else { + throw err; + } +} +``` + ## Configuration ```typescript diff --git a/packages/sdk/src/errors.ts b/packages/sdk/src/errors.ts index c111618bb..cb7ede03d 100644 --- a/packages/sdk/src/errors.ts +++ b/packages/sdk/src/errors.ts @@ -65,6 +65,32 @@ export class InvalidAdditionalDataError extends RegisterRampError { } } +export type EphemeralChain = "Substrate" | "EVM" | "Stellar"; + +export class EphemeralNotFreshError extends RegisterRampError { + public readonly chain: EphemeralChain; + public readonly ephemeralAddress: string; + + constructor(message: string, chain: EphemeralChain, ephemeralAddress: string, status = 400) { + super(message, status); + this.name = "EphemeralNotFreshError"; + this.chain = chain; + this.ephemeralAddress = ephemeralAddress; + } +} + +export class EphemeralFreshnessCheckError extends RegisterRampError { + public readonly chain: EphemeralChain; + public readonly ephemeralAddress: string; + + constructor(message: string, chain: EphemeralChain, ephemeralAddress: string) { + super(message, 503); + this.name = "EphemeralFreshnessCheckError"; + this.chain = chain; + this.ephemeralAddress = ephemeralAddress; + } +} + // BRL Onramp specific errors export class BrlOnrampError extends RegisterRampError { constructor(message: string, status = 400) { @@ -338,6 +364,19 @@ export function parseAPIError(response: any): VortexSdkError { const network = errorMessage.match(/"([^"]+)"/)?.[1] || "unknown"; return new InvalidNetworkError(network); } + + const freshnessMatch = errorMessage.match(/^(Substrate|EVM|Stellar) ephemeral (\S+) (?:is not fresh|already exists)/); + if (freshnessMatch) { + return new EphemeralNotFreshError(errorMessage, freshnessMatch[1] as EphemeralChain, freshnessMatch[2]); + } + const freshnessCheckMatch = errorMessage.match(/^Could not verify freshness of (Substrate|EVM|Stellar) ephemeral (\S+)/); + if (freshnessCheckMatch) { + return new EphemeralFreshnessCheckError(errorMessage, freshnessCheckMatch[1] as EphemeralChain, freshnessCheckMatch[2]); + } + const missingEphemeralMatch = errorMessage.match(/^(Substrate|EVM|Stellar) ephemeral address is required/); + if (missingEphemeralMatch) { + return new EphemeralNotFreshError(errorMessage, missingEphemeralMatch[1] as EphemeralChain, ""); + } if (errorMessage === "Parameters destinationAddress and taxId are required for onramp") { return new MissingBrlParametersError(); } From 4fe2a1b10bd7072dd9edb2b051e10e55ccfbb8a8 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 29 May 2026 16:06:08 +0200 Subject: [PATCH 3/5] Document F-068 ephemeral freshness validation in security spec Add F-068 to FINDINGS.md (Medium, fixed), extend the ephemeral-accounts spec with invariant 9 + threat row + audit checklist item, and add invariant 10 + threat row + checklist item to transaction-validation. --- .../02-signing-keys/ephemeral-accounts.md | 3 ++ .../03-ramp-engine/transaction-validation.md | 3 ++ docs/security-spec/FINDINGS.md | 35 ++++++++++++++++--- 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/docs/security-spec/02-signing-keys/ephemeral-accounts.md b/docs/security-spec/02-signing-keys/ephemeral-accounts.md index 989a713e1..104df228e 100644 --- a/docs/security-spec/02-signing-keys/ephemeral-accounts.md +++ b/docs/security-spec/02-signing-keys/ephemeral-accounts.md @@ -22,6 +22,7 @@ The SDK optionally stores ephemeral keys to a local JSON file (`ephemerals_{ramp 6. **Stellar ephemeral funding MUST use a bounded starting balance** — The `STELLAR_EPHEMERAL_STARTING_BALANCE_UNITS` constant defines the XLM sent to new ephemerals. This should be the minimum needed for operations (trustlines + transaction fees), not more. 7. **The API MUST NOT assume the ephemeral address belongs to an honest user** — An attacker could register a ramp with an address they don't control or an address that's a contract (on EVM). Phase handlers must account for this. 8. **Pre-signed transactions MUST be bound to the specific ephemeral address** — Transactions generated by the API for client signing must include the ephemeral address as the source/signer, not a wildcard. +9. **Ephemeral addresses MUST be proven fresh on every chain they will sign on, at ramp registration time** — Before building any transactions, the API MUST verify on-chain that the submitted ephemeral address has zero nonce / zero balance / does not exist (chain-appropriate definition) on every chain the ramp route will use. Freshness checks MUST fail closed: any RPC error rejects the registration. Reused ephemerals cause mid-ramp halt because the server assumes a clean nonce and (for Stellar) creates the account from scratch. ## Threat Vectors & Mitigations @@ -33,6 +34,7 @@ The SDK optionally stores ephemeral keys to a local JSON file (`ephemerals_{ramp | **Funding account drain** | Attacker creates many ramps to drain the Stellar funding account's XLM balance | Rate limiting on ramp creation; monitoring funding account balance; bounded starting balance | | **Orphaned ephemerals** | Ramp fails mid-way, leaving funded ephemeral accounts unclaimed | Stellar 2-of-2 multisig allows the funding account to reclaim funds; Substrate/EVM ephemerals can be swept by the key holder | | **Malicious ephemeral address (contract)** | On EVM, attacker provides a smart contract address as ephemeral, which could behave unexpectedly when receiving tokens | Validate that EVM ephemeral addresses are externally-owned accounts (EOAs), not contracts, before sending funds | +| **Reused / non-fresh ephemeral** | Client (buggy SDK, attacker, or replay) submits an ephemeral address that already has on-chain history — non-zero nonce on Substrate/EVM, or an existing Stellar account. The server builds transactions assuming nonce 0 / no account, so mid-ramp execution halts with nonce-mismatch or "account already exists" errors after subsidies/funding have been spent. | **MITIGATED (F-068)**: `validateEphemeralAccountsFresh()` runs in `registerRamp` immediately after format validation. It queries every route-relevant chain (derived via `getEphemeralNetworksForQuote`) and rejects the registration if any required ephemeral has non-zero nonce / non-zero free balance / pre-existing Stellar account. Fail-closed on RPC errors. | ## Audit Checklist @@ -46,3 +48,4 @@ The SDK optionally stores ephemeral keys to a local JSON file (`ephemerals_{ramp - [x] Each call to `generateEphemerals()` produces fresh, unique keypairs — no memoization or caching — ✅ PASS - [x] Unsigned transactions returned to the client are bound to the specific ephemeral addresses provided during registration — ✅ PASS - [ ] The API does not trust that an ephemeral address is an EOA on EVM — verify if contract address detection is needed — 🟡 PARTIAL (no check, but low self-harm risk) +- [x] **F-068**: Ephemeral addresses are verified fresh on every chain the ramp route will sign on at `registerRamp` — `validateEphemeralAccountsFresh()` in `apps/api/src/api/services/ramp/ephemeral-freshness.ts`, invoked after `normalizeAndValidateSigningAccounts`. Substrate: `nonce === 0 && free === 0`. EVM: `nonce === 0`. Stellar: account must not exist on Horizon. Route → network mapping in `getEphemeralNetworksForQuote` mirrors the dispatcher logic in `apps/api/src/api/services/transactions/{offramp,onramp}/index.ts` and MUST be updated whenever a phase handler adds a new chain. Fail-closed on RPC errors (`SERVICE_UNAVAILABLE`). — ✅ PASS diff --git a/docs/security-spec/03-ramp-engine/transaction-validation.md b/docs/security-spec/03-ramp-engine/transaction-validation.md index eec318032..bfa8d7e45 100644 --- a/docs/security-spec/03-ramp-engine/transaction-validation.md +++ b/docs/security-spec/03-ramp-engine/transaction-validation.md @@ -49,6 +49,7 @@ The two layers together guarantee that the client cannot (a) sneak a malicious p 7. **`areAllTxsIncluded` is only an inclusion guard** — It may remain metadata-only (`phase + network + nonce + signer`) if each submitted non-skipped transaction is content-bound in `validatePresignedTxs` against the unsigned transaction selected with the same identity keys. 8. **No chain type or transaction format may be silently skipped during validation** — If a new chain or transaction format is added, the validator must either handle it or reject it. Silent pass-through (`return` without validation) is forbidden. 9. **Validation MUST occur before any presigned transaction is persisted or executed** — The `updateRamp` and `startRamp` flows must reject invalid transactions before merging them into ramp state. +10. **Ephemeral addresses submitted at `registerRamp` MUST be proven fresh on every route-relevant chain before transactions are built** — Address format validation is insufficient. The server MUST query each chain the ramp will use and reject the registration if any required ephemeral has non-zero nonce, non-zero free balance, or (for Stellar) already exists on-chain. Fail-closed on RPC errors. Without this, the server builds presigned transactions with assumed-fresh nonces, and execution halts mid-ramp on the first chain where the assumption breaks. ## Threat Vectors & Mitigations @@ -63,6 +64,7 @@ The two layers together guarantee that the client cannot (a) sneak a malicious p | **Transaction data substitution via metadata matching** | Client submits transactions with correct phase/network/nonce/signer metadata but different txData content. | **MITIGATED (F-043)**: `validatePresignedTxs` resolves the matching unsigned transaction by the same identity keys and performs content validation before `areAllTxsIncluded` is used as the final inclusion guard. | | **EVM contract target or execution-parameter substitution** | Client signs a raw EVM transaction to an attacker-controlled contract, or signs the expected transaction with gas/fee parameters too low to execute reliably. | **MITIGATED (F-050)**: Raw signed EVM transactions are recovered and compared to the server-issued unsigned `to`, `data`, `value`, and `nonce`; gas limit and fee caps must be at least the server-issued values, and contract-creation transactions are rejected. | | **New phase/format added without validation** | A developer adds a new phase and the validator silently treats it as EVM because the phase type falls through to a default. | **MITIGATED (F-047)**: `getTransactionTypeForPhase` now throws for unknown phases instead of defaulting to EVM. | +| **Non-fresh ephemeral submitted at registration** | Client submits an ephemeral address that already has on-chain history (non-zero nonce on Substrate/EVM, or an existing Stellar account). Backend builds presigned transactions assuming nonce 0; execution halts mid-ramp on the first signed broadcast after subsidies/funding have already been committed. | **MITIGATED (F-068)**: `registerRamp` invokes `validateEphemeralAccountsFresh()` after `normalizeAndValidateSigningAccounts`. Route-relevant chains are derived via `getEphemeralNetworksForQuote` (mirrors the offramp/onramp dispatcher). Substrate: requires `nonce === 0 && free === 0`. EVM: requires `nonce === 0`. Stellar: account must not exist on Horizon. Fail-closed on RPC errors. | ## Audit Checklist @@ -97,3 +99,4 @@ The two layers together guarantee that the client cannot (a) sneak a malicious p - [x] **Chainless EVM tx rejection**: `verifySignedEvmTransaction` rejects raw txs whose decoded `chainId` is `undefined` (pre-EIP-155 legacy txs), closing a cross-chain replay bypass that existed even when `sandboxEnabled` was false. - [x] **Backup re-verification**: `meta.additionalTxs` must contain exactly the expected backup set, and every backup is re-run through the primary's validator (EVM signer + nonce + content; Substrate signer + call-equality via `method.toHex()`; Stellar signer + per-phase shape), so a malicious client cannot register ignored extras or backups that encode a different call or signer than the primary tx. - [x] **`updateRamp` subset submissions**: `validatePresignedTxs` accepts `{ requireComplete: false }` for partial submissions but still rejects extra/unknown txs and still applies full per-tx content validation; `requireComplete` defaults to `true` for `startRamp`. +- [x] **F-068**: `registerRamp` proves ephemeral addresses fresh on every route-relevant chain before building transactions. `validateEphemeralAccountsFresh()` (`apps/api/src/api/services/ramp/ephemeral-freshness.ts`) is invoked after `normalizeAndValidateSigningAccounts`. Route → network mapping in `getEphemeralNetworksForQuote` mirrors the dispatcher logic in `apps/api/src/api/services/transactions/{offramp,onramp}/index.ts` and MUST be kept in sync. Substrate `nonce === 0 && free === 0`; EVM `nonce === 0`; Stellar account must not exist. RPC errors fail closed with `SERVICE_UNAVAILABLE`. diff --git a/docs/security-spec/FINDINGS.md b/docs/security-spec/FINDINGS.md index 420f510e8..85c71b4dd 100644 --- a/docs/security-spec/FINDINGS.md +++ b/docs/security-spec/FINDINGS.md @@ -1,8 +1,8 @@ # Audit Findings Tracker -> **Generated:** 2026-04-02 | **Last Updated:** 2026-05-12 | **Status:** F-001 through F-067: 49 fixed, 9 accepted risk, 9 deferred, 0 open. Additional discount-mechanism findings F-DISC-01 through F-DISC-05 remain open in `03-ramp-engine/discount-mechanism.md` and are not included in the counts below. +> **Generated:** 2026-04-02 | **Last Updated:** 2026-05-29 | **Status:** F-001 through F-068: 50 fixed, 9 accepted risk, 9 deferred, 0 open. Additional discount-mechanism findings F-DISC-01 through F-DISC-05 remain open in `03-ramp-engine/discount-mechanism.md` and are not included in the counts below. -This file consolidates all security findings from the Vortex platform audit. Findings were discovered across four phases: specification writing (F-001 through F-012), code-vs-spec audit across all 8 modules (F-013 through F-037), transaction validation / ephemeral account / phase flow audit (F-038 through F-058), and fresh security audit pass (F-059 through F-067). +This file consolidates all security findings from the Vortex platform audit. Findings were discovered across five phases: specification writing (F-001 through F-012), code-vs-spec audit across all 8 modules (F-013 through F-037), transaction validation / ephemeral account / phase flow audit (F-038 through F-058), fresh security audit pass (F-059 through F-067), and ephemeral lifecycle review (F-068). ## Summary @@ -10,9 +10,9 @@ This file consolidates all security findings from the Vortex platform audit. Fin |---|---|---|---|---|---| | 🔴 Critical | 5 | 0 | 0 | 0 | 5 | | 🟠 High | 11 | 3 | 3 | 0 | 17 | -| 🟡 Medium | 25 | 3 | 6 | 0 | 34 | +| 🟡 Medium | 26 | 3 | 6 | 0 | 35 | | 🔵 Low / ⚪ Info | 8 | 3 | 0 | 0 | 11 | -| **Total** | **49** | **9** | **9** | **0** | **67** | +| **Total** | **50** | **9** | **9** | **0** | **68** | > **Fixed** = code change implemented and verified. **Accepted** = CTO reviewed and accepted risk, no code change. **Deferred** = requires architectural work, separate app changes, or future investigation. **Open** = newly identified, awaiting fix or CTO decision. @@ -1455,6 +1455,33 @@ If a database partner record has `markupValue = -0.01` and `markupType = "relati --- +### F-068: Ephemeral Account Freshness Not Validated at Ramp Registration + +| Field | Value | +|---|---| +| **Severity** | 🟡 **Medium** | +| **Location** | `apps/api/src/api/services/ramp/ramp.service.ts` (`registerRamp` → `normalizeAndValidateSigningAccounts`, lines 141-216) | +| **Spec** | `02-signing-keys/ephemeral-accounts.md`, `03-ramp-engine/transaction-validation.md` | +| **Status** | ✅ **FIXED** | +| **Found** | Fresh audit pass, ephemeral lifecycle review | +| **Impact** | An API client could submit an ephemeral address that has already been used on one or more route-relevant chains (non-zero nonce, existing balance, or pre-existing Stellar account). The backend would build presigned transactions assuming nonce 0 / fresh account; mid-ramp execution would then halt with nonce mismatches, "account already exists" errors, or unexpected leftover balances, leaving subsidies/funding spent and ramps stuck. | + +**Description:** `normalizeAndValidateSigningAccounts` only validated the *format* of each provided ephemeral address (StrKey, SS58, EVM `isAddress`). It did not verify that the addresses were actually fresh on the chains the ramp would touch. Because the SDK generates ephemerals client-side and the API trusts whatever address is submitted, a buggy or malicious client could replay an old ephemeral address. Stellar is especially sensitive: the server's first action is to *create* the Stellar account on-chain with a 2-of-2 multisig and starting balance — if the account already exists, that creation operation fails and the ramp cannot proceed. + +**Fix:** Added `validateEphemeralAccountsFresh()` (`apps/api/src/api/services/ramp/ephemeral-freshness.ts`), invoked in `registerRamp` immediately after `normalizeAndValidateSigningAccounts`. The validator: + +1. **Computes the route-relevant network set** via `getEphemeralNetworksForQuote(quote, additionalData)`, which mirrors the offramp/onramp transaction-builder dispatcher logic in `apps/api/src/api/services/transactions/{offramp,onramp}/index.ts`. Only chains an ephemeral will actually sign on are checked (e.g. SELL/BRL with EVM input → EVM:[Base]; BUY/EURC → AssetHub → EVM:[Polygon, Moonbeam] + Substrate:[pendulum, hydration-if-non-USDC]). +2. **Substrate**: queries `system.account(address)` on each required chain; requires `nonce === 0` AND `free === 0`. +3. **EVM**: queries `getTransactionCount(address)` on each required chain; requires `nonce === 0`. +4. **Stellar**: calls `loadAccountWithRetry(address)` against Horizon; requires the account to **not exist** (the server creates and funds it during the ramp). +5. **Fail-closed**: any RPC error rejects the registration with `SERVICE_UNAVAILABLE` rather than allowing freshness to be presumed. +6. **Required-but-missing ephemerals** for a route are rejected with `BAD_REQUEST` before any RPC call. +7. Scope: `registerRamp` only. `updateRamp` does not re-check, since the ephemeral identity is bound to ramp state at registration time. + +If a chain is added to a phase handler that an ephemeral signs on, `getEphemeralNetworksForQuote` MUST be updated to include it, otherwise the freshness gap reopens for that route. + +--- + ## Additional Observations (Not Findings) These are design observations noted during spec writing that may warrant review but aren't direct vulnerabilities: From ce34a9b59d7c383540167dabec33da25c3ef252d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 29 May 2026 14:28:59 +0000 Subject: [PATCH 4/5] fix: address review comments on ephemeral freshness checks 1. EVM freshness now checks both nonce AND balance (not just nonce), rejecting addresses that received native funds but never sent a tx. 2. Use sandbox-aware Polygon network (PolygonAmoy when sandboxEnabled) in the freshness mapping, matching the transaction builders. 3. Error middleware now only masks 500 messages in production, preserving 503 SERVICE_UNAVAILABLE messages so the SDK can parse freshness errors. --- apps/api/src/api/middlewares/error.ts | 6 +++++- .../services/ramp/ephemeral-freshness.test.ts | 17 ++++++++++++++++ .../api/services/ramp/ephemeral-freshness.ts | 20 ++++++++++++------- 3 files changed, 35 insertions(+), 8 deletions(-) diff --git a/apps/api/src/api/middlewares/error.ts b/apps/api/src/api/middlewares/error.ts index a893e334a..69068a4cd 100644 --- a/apps/api/src/api/middlewares/error.ts +++ b/apps/api/src/api/middlewares/error.ts @@ -32,7 +32,11 @@ const handler = (err: APIError | Error, _req: Request, res: Response, _next: Nex if (env !== "development") { delete response.stack; if (statusCode >= 500) { - response.message = "Internal server error"; + // Preserve messages for intentional 5xx codes (e.g. 503 SERVICE_UNAVAILABLE used by + // ephemeral freshness checks). Only mask unexpected internal server errors (500). + if (statusCode === httpStatus.INTERNAL_SERVER_ERROR) { + response.message = "Internal server error"; + } } } diff --git a/apps/api/src/api/services/ramp/ephemeral-freshness.test.ts b/apps/api/src/api/services/ramp/ephemeral-freshness.test.ts index eb78fad41..afcdd0f8b 100644 --- a/apps/api/src/api/services/ramp/ephemeral-freshness.test.ts +++ b/apps/api/src/api/services/ramp/ephemeral-freshness.test.ts @@ -10,6 +10,7 @@ const EVM_ADDR = "0x1111111111111111111111111111111111111111"; let substrateNonce = 0; let substrateFree = "0"; let evmNonce = 0; +let evmBalance = 0n; let stellarAccount: { sequence: string } | null = null; let evmGetClientShouldThrow = false; @@ -38,6 +39,7 @@ mock.module("@vortexfi/shared", () => { getClient: (_network: string) => { if (evmGetClientShouldThrow) throw new Error("RPC down"); return { + getBalance: async (_args: { address: string }) => evmBalance, getTransactionCount: async (_args: { address: string }) => evmNonce }; } @@ -111,6 +113,7 @@ describe("validateEphemeralAccountsFresh", () => { substrateNonce = 0; substrateFree = "0"; evmNonce = 0; + evmBalance = 0n; stellarAccount = null; evmGetClientShouldThrow = false; }); @@ -166,6 +169,20 @@ describe("validateEphemeralAccountsFresh", () => { } }); + it("rejects non-fresh EVM (non-zero balance)", async () => { + evmBalance = 1000000000000000n; + try { + await validateEphemeralAccountsFresh( + { [EphemeralAccountType.EVM]: EVM_ADDR }, + { evm: [Networks.Base], stellar: false, substrate: [] } + ); + throw new Error("expected rejection"); + } catch (err) { + expect((err as APIError).status).toBe(400); + expect((err as APIError).message).toContain("not fresh"); + } + }); + it("rejects when Stellar account already exists on-chain", async () => { stellarAccount = { sequence: "12345" }; try { diff --git a/apps/api/src/api/services/ramp/ephemeral-freshness.ts b/apps/api/src/api/services/ramp/ephemeral-freshness.ts index e6bfed02e..60cb5185e 100644 --- a/apps/api/src/api/services/ramp/ephemeral-freshness.ts +++ b/apps/api/src/api/services/ramp/ephemeral-freshness.ts @@ -17,6 +17,7 @@ import { } from "@vortexfi/shared"; import Big from "big.js"; import httpStatus from "http-status"; +import { config } from "../../../config/vars"; import QuoteTicket from "../../../models/quoteTicket.model"; import { APIError } from "../../errors/api-error"; import { loadAccountWithRetry } from "../stellar/loadAccount"; @@ -28,6 +29,7 @@ export interface EphemeralNetworks { } const USDC = "usdc"; +const POLYGON_NETWORK: EvmNetworks = config.sandboxEnabled ? Networks.PolygonAmoy : Networks.Polygon; // SECURITY: mirrors the dispatcher logic in apps/api/src/api/services/transactions/{offramp,onramp}/index.ts. // If you add a new route variant or change a sub-builder's network assignments, you MUST update this function too. @@ -71,7 +73,7 @@ function getOfframpNetworks( } if (isAlfredpayToken(quote.outputCurrency as FiatToken)) { - result.evm.push(Networks.Polygon); + result.evm.push(POLYGON_NETWORK); return result; } @@ -105,14 +107,14 @@ function getOnrampNetworks(quote: QuoteTicket, result: EphemeralNetworks): Ephem if (quote.inputCurrency === FiatToken.EURC) { if (toNetwork === Networks.AssetHub) { - pushEvmDedup(result, Networks.Polygon); + pushEvmDedup(result, POLYGON_NETWORK); pushEvmDedup(result, Networks.Moonbeam); result.substrate.push("pendulum"); if (!outputIsUsdc) { result.substrate.push("hydration"); } } else { - pushEvmDedup(result, Networks.Polygon); + pushEvmDedup(result, POLYGON_NETWORK); if (isNetworkEVM(toNetwork)) { pushEvmDedup(result, toNetwork); } @@ -121,7 +123,7 @@ function getOnrampNetworks(quote: QuoteTicket, result: EphemeralNetworks): Ephem } if (isAlfredpayToken(quote.inputCurrency as FiatToken)) { - pushEvmDedup(result, Networks.Polygon); + pushEvmDedup(result, POLYGON_NETWORK); if (isNetworkEVM(toNetwork)) { pushEvmDedup(result, toNetwork); } @@ -210,9 +212,13 @@ async function assertSubstrateAccountFresh(address: string, network: SubstrateAp async function assertEvmAccountFresh(address: string, network: EvmNetworks): Promise { let nonce: number; + let balance: bigint; try { const client = EvmClientManager.getInstance().getClient(network); - nonce = await client.getTransactionCount({ address: address as `0x${string}` }); + [nonce, balance] = await Promise.all([ + client.getTransactionCount({ address: address as `0x${string}` }), + client.getBalance({ address: address as `0x${string}` }) + ]); } catch (error) { throw new APIError({ message: `Could not verify freshness of EVM ephemeral ${address} on ${network}: ${(error as Error).message}`, @@ -220,9 +226,9 @@ async function assertEvmAccountFresh(address: string, network: EvmNetworks): Pro }); } - if (nonce !== 0) { + if (nonce !== 0 || balance !== 0n) { throw new APIError({ - message: `EVM ephemeral ${address} is not fresh on ${network} (nonce=${nonce}). A new, unused ephemeral account must be provided.`, + message: `EVM ephemeral ${address} is not fresh on ${network} (nonce=${nonce}, balance=${balance}). A new, unused ephemeral account must be provided.`, status: httpStatus.BAD_REQUEST }); } From d98a1c6d50e7d07879734a13b925e25839fb019d Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 29 May 2026 16:41:41 +0200 Subject: [PATCH 5/5] Make ephemeral freshness check consider all chains --- .../services/ramp/ephemeral-freshness.test.ts | 129 ++---------- .../api/services/ramp/ephemeral-freshness.ts | 189 +++--------------- .../api/src/api/services/ramp/ramp.service.ts | 4 +- .../02-signing-keys/ephemeral-accounts.md | 6 +- .../03-ramp-engine/transaction-validation.md | 6 +- docs/security-spec/FINDINGS.md | 11 +- 6 files changed, 60 insertions(+), 285 deletions(-) diff --git a/apps/api/src/api/services/ramp/ephemeral-freshness.test.ts b/apps/api/src/api/services/ramp/ephemeral-freshness.test.ts index afcdd0f8b..41f73c9a1 100644 --- a/apps/api/src/api/services/ramp/ephemeral-freshness.test.ts +++ b/apps/api/src/api/services/ramp/ephemeral-freshness.test.ts @@ -1,7 +1,6 @@ -import { beforeEach, describe, expect, it, mock } from "bun:test"; -import { AssetHubToken, EphemeralAccountType, EvmToken, FiatToken, Networks, RampDirection } from "@vortexfi/shared"; -import type QuoteTicket from "../../../models/quoteTicket.model"; -import { APIError } from "../../errors/api-error"; +import {beforeEach, describe, expect, it, mock} from "bun:test"; +import {EphemeralAccountType} from "@vortexfi/shared"; +import {APIError} from "../../errors/api-error"; const STELLAR_ADDR = "GABCD"; const SUBSTRATE_ADDR = "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty"; @@ -10,7 +9,6 @@ const EVM_ADDR = "0x1111111111111111111111111111111111111111"; let substrateNonce = 0; let substrateFree = "0"; let evmNonce = 0; -let evmBalance = 0n; let stellarAccount: { sequence: string } | null = null; let evmGetClientShouldThrow = false; @@ -39,7 +37,6 @@ mock.module("@vortexfi/shared", () => { getClient: (_network: string) => { if (evmGetClientShouldThrow) throw new Error("RPC down"); return { - getBalance: async (_args: { address: string }) => evmBalance, getTransactionCount: async (_args: { address: string }) => evmNonce }; } @@ -53,87 +50,35 @@ mock.module("../stellar/loadAccount", () => ({ })); // Import AFTER mocks are registered so the module picks up the mocked deps. -const { getEphemeralNetworksForQuote, validateEphemeralAccountsFresh } = await import("./ephemeral-freshness"); - -function makeQuote(overrides: Partial): QuoteTicket { - return { - from: Networks.Polygon, - inputCurrency: EvmToken.USDC, - outputCurrency: FiatToken.BRL, - rampType: RampDirection.SELL, - to: Networks.Pendulum, - ...overrides - } as unknown as QuoteTicket; -} - -describe("getEphemeralNetworksForQuote", () => { - it("offramp BRL with EVM input → EVM:[Base]", () => { - const result = getEphemeralNetworksForQuote( - makeQuote({ from: Networks.Polygon, inputCurrency: EvmToken.USDC, outputCurrency: FiatToken.BRL, rampType: RampDirection.SELL }) - ); - expect(result.evm).toEqual([Networks.Base]); - expect(result.substrate).toEqual([]); - expect(result.stellar).toBe(false); - }); - - it("offramp non-BRL non-Monerium → Substrate:[pendulum] + Stellar", () => { - const result = getEphemeralNetworksForQuote( - makeQuote({ from: Networks.Polygon, inputCurrency: EvmToken.USDC, outputCurrency: FiatToken.EURC, rampType: RampDirection.SELL }) - ); - expect(result.substrate).toEqual(["pendulum"]); - expect(result.stellar).toBe(true); - }); - - it("offramp Monerium → no ephemerals required", () => { - const result = getEphemeralNetworksForQuote( - makeQuote({ from: Networks.Polygon, inputCurrency: EvmToken.USDC, outputCurrency: FiatToken.EURC, rampType: RampDirection.SELL }), - { moneriumAuthToken: "tok" } - ); - expect(result.evm).toEqual([]); - expect(result.substrate).toEqual([]); - expect(result.stellar).toBe(false); - }); - - it("onramp BRL → AssetHub non-USDC → EVM:[Moonbeam] + Substrate:[pendulum, hydration]", () => { - const result = getEphemeralNetworksForQuote( - makeQuote({ - inputCurrency: FiatToken.BRL, - outputCurrency: AssetHubToken.DOT, - rampType: RampDirection.BUY, - to: Networks.AssetHub - }) - ); - expect(result.evm).toEqual([Networks.Moonbeam]); - expect(result.substrate).toEqual(["pendulum", "hydration"]); - }); -}); +const { validateEphemeralAccountsFresh } = await import("./ephemeral-freshness"); describe("validateEphemeralAccountsFresh", () => { beforeEach(() => { substrateNonce = 0; substrateFree = "0"; evmNonce = 0; - evmBalance = 0n; stellarAccount = null; evmGetClientShouldThrow = false; }); - it("passes when all ephemerals are fresh", async () => { + it("passes when all submitted ephemerals are fresh on every supported network", async () => { await expect( - validateEphemeralAccountsFresh( - { [EphemeralAccountType.EVM]: EVM_ADDR, [EphemeralAccountType.Stellar]: STELLAR_ADDR, [EphemeralAccountType.Substrate]: SUBSTRATE_ADDR }, - { evm: [Networks.Base], stellar: true, substrate: ["pendulum"] } - ) + validateEphemeralAccountsFresh({ + [EphemeralAccountType.EVM]: EVM_ADDR, + [EphemeralAccountType.Stellar]: STELLAR_ADDR, + [EphemeralAccountType.Substrate]: SUBSTRATE_ADDR + }) ).resolves.toBeUndefined(); }); + it("passes when no ephemerals are submitted", async () => { + await expect(validateEphemeralAccountsFresh({})).resolves.toBeUndefined(); + }); + it("rejects non-fresh Substrate (non-zero nonce)", async () => { substrateNonce = 1; try { - await validateEphemeralAccountsFresh( - { [EphemeralAccountType.Substrate]: SUBSTRATE_ADDR }, - { evm: [], stellar: false, substrate: ["pendulum"] } - ); + await validateEphemeralAccountsFresh({ [EphemeralAccountType.Substrate]: SUBSTRATE_ADDR }); throw new Error("expected rejection"); } catch (err) { expect(err).toBeInstanceOf(APIError); @@ -145,10 +90,7 @@ describe("validateEphemeralAccountsFresh", () => { it("rejects non-fresh Substrate (non-zero free balance)", async () => { substrateFree = "1000"; try { - await validateEphemeralAccountsFresh( - { [EphemeralAccountType.Substrate]: SUBSTRATE_ADDR }, - { evm: [], stellar: false, substrate: ["pendulum"] } - ); + await validateEphemeralAccountsFresh({ [EphemeralAccountType.Substrate]: SUBSTRATE_ADDR }); throw new Error("expected rejection"); } catch (err) { expect((err as APIError).status).toBe(400); @@ -158,24 +100,7 @@ describe("validateEphemeralAccountsFresh", () => { it("rejects non-fresh EVM (non-zero nonce)", async () => { evmNonce = 5; try { - await validateEphemeralAccountsFresh( - { [EphemeralAccountType.EVM]: EVM_ADDR }, - { evm: [Networks.Base], stellar: false, substrate: [] } - ); - throw new Error("expected rejection"); - } catch (err) { - expect((err as APIError).status).toBe(400); - expect((err as APIError).message).toContain("not fresh"); - } - }); - - it("rejects non-fresh EVM (non-zero balance)", async () => { - evmBalance = 1000000000000000n; - try { - await validateEphemeralAccountsFresh( - { [EphemeralAccountType.EVM]: EVM_ADDR }, - { evm: [Networks.Base], stellar: false, substrate: [] } - ); + await validateEphemeralAccountsFresh({ [EphemeralAccountType.EVM]: EVM_ADDR }); throw new Error("expected rejection"); } catch (err) { expect((err as APIError).status).toBe(400); @@ -186,10 +111,7 @@ describe("validateEphemeralAccountsFresh", () => { it("rejects when Stellar account already exists on-chain", async () => { stellarAccount = { sequence: "12345" }; try { - await validateEphemeralAccountsFresh( - { [EphemeralAccountType.Stellar]: STELLAR_ADDR }, - { evm: [], stellar: true, substrate: [] } - ); + await validateEphemeralAccountsFresh({ [EphemeralAccountType.Stellar]: STELLAR_ADDR }); throw new Error("expected rejection"); } catch (err) { expect((err as APIError).status).toBe(400); @@ -197,23 +119,10 @@ describe("validateEphemeralAccountsFresh", () => { } }); - it("rejects when a route-required ephemeral is missing", async () => { - try { - await validateEphemeralAccountsFresh({}, { evm: [Networks.Base], stellar: false, substrate: [] }); - throw new Error("expected rejection"); - } catch (err) { - expect((err as APIError).status).toBe(400); - expect((err as APIError).message).toContain("required"); - } - }); - it("fails closed with SERVICE_UNAVAILABLE on RPC error", async () => { evmGetClientShouldThrow = true; try { - await validateEphemeralAccountsFresh( - { [EphemeralAccountType.EVM]: EVM_ADDR }, - { evm: [Networks.Base], stellar: false, substrate: [] } - ); + await validateEphemeralAccountsFresh({ [EphemeralAccountType.EVM]: EVM_ADDR }); throw new Error("expected rejection"); } catch (err) { expect((err as APIError).status).toBe(503); diff --git a/apps/api/src/api/services/ramp/ephemeral-freshness.ts b/apps/api/src/api/services/ramp/ephemeral-freshness.ts index 60cb5185e..103a01df0 100644 --- a/apps/api/src/api/services/ramp/ephemeral-freshness.ts +++ b/apps/api/src/api/services/ramp/ephemeral-freshness.ts @@ -3,183 +3,54 @@ import { EphemeralAccountType, EvmClientManager, EvmNetworks, - FiatToken, - getNetworkFromDestination, - getOnChainTokenDetails, - isAlfredpayToken, - isEvmTokenDetails, - isNetworkEVM, Networks, - OnChainToken, - RampDirection, - RegisterRampRequest, SubstrateApiNetwork } from "@vortexfi/shared"; import Big from "big.js"; import httpStatus from "http-status"; -import { config } from "../../../config/vars"; -import QuoteTicket from "../../../models/quoteTicket.model"; import { APIError } from "../../errors/api-error"; import { loadAccountWithRetry } from "../stellar/loadAccount"; -export interface EphemeralNetworks { - substrate: SubstrateApiNetwork[]; - evm: EvmNetworks[]; - stellar: boolean; -} - -const USDC = "usdc"; -const POLYGON_NETWORK: EvmNetworks = config.sandboxEnabled ? Networks.PolygonAmoy : Networks.Polygon; - -// SECURITY: mirrors the dispatcher logic in apps/api/src/api/services/transactions/{offramp,onramp}/index.ts. -// If you add a new route variant or change a sub-builder's network assignments, you MUST update this function too. -// A missed network leaves a freshness-check gap. -export function getEphemeralNetworksForQuote( - quote: QuoteTicket, - additionalData?: RegisterRampRequest["additionalData"] -): EphemeralNetworks { - const result: EphemeralNetworks = { evm: [], stellar: false, substrate: [] }; - - if (quote.rampType === RampDirection.SELL) { - return getOfframpNetworks(quote, additionalData, result); - } - return getOnrampNetworks(quote, result); -} - -function getOfframpNetworks( - quote: QuoteTicket, - additionalData: RegisterRampRequest["additionalData"] | undefined, - result: EphemeralNetworks -): EphemeralNetworks { - const fromNetwork = getNetworkFromDestination(quote.from); - if (!fromNetwork) { - throw new Error(`Invalid network for destination ${quote.from}`); - } +const SUPPORTED_SUBSTRATE_NETWORKS: SubstrateApiNetwork[] = ["pendulum", "hydration", "assethub"]; - const inputTokenDetails = getOnChainTokenDetails(fromNetwork, quote.inputCurrency as OnChainToken); - const inputIsEvm = !!(inputTokenDetails && isEvmTokenDetails(inputTokenDetails)); - - if (quote.outputCurrency === FiatToken.BRL) { - if (inputIsEvm) { - result.evm.push(Networks.Base); - } else { - result.substrate.push("pendulum"); - } - return result; - } - - if (quote.outputCurrency === FiatToken.EURC && additionalData?.moneriumAuthToken) { - return result; - } - - if (isAlfredpayToken(quote.outputCurrency as FiatToken)) { - result.evm.push(POLYGON_NETWORK); - return result; - } - - result.substrate.push("pendulum"); - result.stellar = true; - return result; -} - -function getOnrampNetworks(quote: QuoteTicket, result: EphemeralNetworks): EphemeralNetworks { - const toNetwork = getNetworkFromDestination(quote.to); - if (!toNetwork) { - throw new Error(`Invalid network for destination ${quote.to}`); - } - const outputIsUsdc = (quote.outputCurrency as string).toLowerCase() === USDC; - - if (quote.inputCurrency === FiatToken.BRL) { - if (toNetwork === Networks.AssetHub) { - result.evm.push(Networks.Moonbeam); - result.substrate.push("pendulum"); - if (!outputIsUsdc) { - result.substrate.push("hydration"); - } - } else { - pushEvmDedup(result, Networks.Base); - if (isNetworkEVM(toNetwork)) { - pushEvmDedup(result, toNetwork); - } - } - return result; - } - - if (quote.inputCurrency === FiatToken.EURC) { - if (toNetwork === Networks.AssetHub) { - pushEvmDedup(result, POLYGON_NETWORK); - pushEvmDedup(result, Networks.Moonbeam); - result.substrate.push("pendulum"); - if (!outputIsUsdc) { - result.substrate.push("hydration"); - } - } else { - pushEvmDedup(result, POLYGON_NETWORK); - if (isNetworkEVM(toNetwork)) { - pushEvmDedup(result, toNetwork); - } - } - return result; - } - - if (isAlfredpayToken(quote.inputCurrency as FiatToken)) { - pushEvmDedup(result, POLYGON_NETWORK); - if (isNetworkEVM(toNetwork)) { - pushEvmDedup(result, toNetwork); - } - return result; - } - - throw new Error(`Unsupported onramp input currency: ${quote.inputCurrency}`); -} - -function pushEvmDedup(result: EphemeralNetworks, network: EvmNetworks): void { - if (!result.evm.includes(network)) { - result.evm.push(network); - } -} +const SUPPORTED_EVM_NETWORKS: EvmNetworks[] = [ + Networks.Arbitrum, + Networks.Avalanche, + Networks.Base, + Networks.BSC, + Networks.Ethereum, + Networks.Moonbeam, + Networks.Polygon, + Networks.PolygonAmoy, + Networks.BaseSepolia +]; // SECURITY: fail-closed. Any RPC error rejects the registration since we cannot prove freshness without on-chain data. +// We check every supported network unconditionally rather than deriving the route-relevant subset, so a buggy/missing +// route mapping cannot reopen a freshness gap when new phase handlers add chains an ephemeral signs on. export async function validateEphemeralAccountsFresh( - ephemerals: { [key in EphemeralAccountType]?: string }, - networks: EphemeralNetworks + ephemerals: { + [key in EphemeralAccountType]?: string; + } ): Promise { const checks: Promise[] = []; - if (networks.substrate.length > 0) { - const substrateAddress = ephemerals[EphemeralAccountType.Substrate]; - if (!substrateAddress) { - throw new APIError({ - message: "Substrate ephemeral address is required for this ramp route but was not provided.", - status: httpStatus.BAD_REQUEST - }); - } - for (const network of networks.substrate) { + const substrateAddress = ephemerals[EphemeralAccountType.Substrate]; + if (substrateAddress) { + for (const network of SUPPORTED_SUBSTRATE_NETWORKS) { checks.push(assertSubstrateAccountFresh(substrateAddress, network)); } } - if (networks.evm.length > 0) { - const evmAddress = ephemerals[EphemeralAccountType.EVM]; - if (!evmAddress) { - throw new APIError({ - message: "EVM ephemeral address is required for this ramp route but was not provided.", - status: httpStatus.BAD_REQUEST - }); - } - for (const network of networks.evm) { + const evmAddress = ephemerals[EphemeralAccountType.EVM]; + if (evmAddress) { + for (const network of SUPPORTED_EVM_NETWORKS) { checks.push(assertEvmAccountFresh(evmAddress, network)); } } - if (networks.stellar) { - const stellarAddress = ephemerals[EphemeralAccountType.Stellar]; - if (!stellarAddress) { - throw new APIError({ - message: "Stellar ephemeral address is required for this ramp route but was not provided.", - status: httpStatus.BAD_REQUEST - }); - } + const stellarAddress = ephemerals[EphemeralAccountType.Stellar]; + if (stellarAddress) { checks.push(assertStellarAccountFresh(stellarAddress)); } @@ -212,13 +83,9 @@ async function assertSubstrateAccountFresh(address: string, network: SubstrateAp async function assertEvmAccountFresh(address: string, network: EvmNetworks): Promise { let nonce: number; - let balance: bigint; try { const client = EvmClientManager.getInstance().getClient(network); - [nonce, balance] = await Promise.all([ - client.getTransactionCount({ address: address as `0x${string}` }), - client.getBalance({ address: address as `0x${string}` }) - ]); + nonce = await client.getTransactionCount({ address: address as `0x${string}` }); } catch (error) { throw new APIError({ message: `Could not verify freshness of EVM ephemeral ${address} on ${network}: ${(error as Error).message}`, @@ -226,9 +93,9 @@ async function assertEvmAccountFresh(address: string, network: EvmNetworks): Pro }); } - if (nonce !== 0 || balance !== 0n) { + if (nonce !== 0) { throw new APIError({ - message: `EVM ephemeral ${address} is not fresh on ${network} (nonce=${nonce}, balance=${balance}). A new, unused ephemeral account must be provided.`, + message: `EVM ephemeral ${address} is not fresh on ${network} (nonce=${nonce}). A new, unused ephemeral account must be provided.`, status: httpStatus.BAD_REQUEST }); } diff --git a/apps/api/src/api/services/ramp/ramp.service.ts b/apps/api/src/api/services/ramp/ramp.service.ts index 1c4537062..9d6520724 100644 --- a/apps/api/src/api/services/ramp/ramp.service.ts +++ b/apps/api/src/api/services/ramp/ramp.service.ts @@ -64,7 +64,7 @@ import { AveniaOnrampTransactionParams, MoneriumOnrampTransactionParams } from " import { validatePresignedTxs } from "../transactions/validation"; import webhookDeliveryService from "../webhook/webhook-delivery.service"; import { BaseRampService } from "./base.service"; -import { getEphemeralNetworksForQuote, validateEphemeralAccountsFresh } from "./ephemeral-freshness"; +import { validateEphemeralAccountsFresh } from "./ephemeral-freshness"; import { getFinalTransactionHashForRamp } from "./helpers"; import { validateMoneriumOnrampPermit } from "./monerium-permit"; import { RampTransactionPreparationKind, selectRampTransactionPreparationKind } from "./ramp-transaction-preparation"; @@ -216,7 +216,7 @@ export class RampService extends BaseRampService { const { normalizedSigningAccounts, ephemerals } = normalizeAndValidateSigningAccounts(signingAccounts); - await validateEphemeralAccountsFresh(ephemerals, getEphemeralNetworksForQuote(quote, additionalData)); + await validateEphemeralAccountsFresh(ephemerals); const { unsignedTxs, stateMeta, depositQrCode, ibanPaymentData, aveniaTicketId } = await this.prepareRampTransactions( quote, diff --git a/docs/security-spec/02-signing-keys/ephemeral-accounts.md b/docs/security-spec/02-signing-keys/ephemeral-accounts.md index 104df228e..fc73369bf 100644 --- a/docs/security-spec/02-signing-keys/ephemeral-accounts.md +++ b/docs/security-spec/02-signing-keys/ephemeral-accounts.md @@ -22,7 +22,7 @@ The SDK optionally stores ephemeral keys to a local JSON file (`ephemerals_{ramp 6. **Stellar ephemeral funding MUST use a bounded starting balance** — The `STELLAR_EPHEMERAL_STARTING_BALANCE_UNITS` constant defines the XLM sent to new ephemerals. This should be the minimum needed for operations (trustlines + transaction fees), not more. 7. **The API MUST NOT assume the ephemeral address belongs to an honest user** — An attacker could register a ramp with an address they don't control or an address that's a contract (on EVM). Phase handlers must account for this. 8. **Pre-signed transactions MUST be bound to the specific ephemeral address** — Transactions generated by the API for client signing must include the ephemeral address as the source/signer, not a wildcard. -9. **Ephemeral addresses MUST be proven fresh on every chain they will sign on, at ramp registration time** — Before building any transactions, the API MUST verify on-chain that the submitted ephemeral address has zero nonce / zero balance / does not exist (chain-appropriate definition) on every chain the ramp route will use. Freshness checks MUST fail closed: any RPC error rejects the registration. Reused ephemerals cause mid-ramp halt because the server assumes a clean nonce and (for Stellar) creates the account from scratch. +9. **Ephemeral addresses MUST be proven fresh on every chain the platform supports, at ramp registration time** — Before building any transactions, the API MUST verify on-chain that each submitted ephemeral address has zero nonce / zero balance / does not exist (chain-appropriate definition) on every supported chain of its type — not only the chains the specific ramp route will use. Checking the full supported set (rather than a route-derived subset) prevents a future phase-handler addition from silently reopening the freshness gap. Freshness checks MUST fail closed: any RPC error rejects the registration. Reused ephemerals cause mid-ramp halt because the server assumes a clean nonce and (for Stellar) creates the account from scratch. ## Threat Vectors & Mitigations @@ -34,7 +34,7 @@ The SDK optionally stores ephemeral keys to a local JSON file (`ephemerals_{ramp | **Funding account drain** | Attacker creates many ramps to drain the Stellar funding account's XLM balance | Rate limiting on ramp creation; monitoring funding account balance; bounded starting balance | | **Orphaned ephemerals** | Ramp fails mid-way, leaving funded ephemeral accounts unclaimed | Stellar 2-of-2 multisig allows the funding account to reclaim funds; Substrate/EVM ephemerals can be swept by the key holder | | **Malicious ephemeral address (contract)** | On EVM, attacker provides a smart contract address as ephemeral, which could behave unexpectedly when receiving tokens | Validate that EVM ephemeral addresses are externally-owned accounts (EOAs), not contracts, before sending funds | -| **Reused / non-fresh ephemeral** | Client (buggy SDK, attacker, or replay) submits an ephemeral address that already has on-chain history — non-zero nonce on Substrate/EVM, or an existing Stellar account. The server builds transactions assuming nonce 0 / no account, so mid-ramp execution halts with nonce-mismatch or "account already exists" errors after subsidies/funding have been spent. | **MITIGATED (F-068)**: `validateEphemeralAccountsFresh()` runs in `registerRamp` immediately after format validation. It queries every route-relevant chain (derived via `getEphemeralNetworksForQuote`) and rejects the registration if any required ephemeral has non-zero nonce / non-zero free balance / pre-existing Stellar account. Fail-closed on RPC errors. | +| **Reused / non-fresh ephemeral** | Client (buggy SDK, attacker, or replay) submits an ephemeral address that already has on-chain history — non-zero nonce on Substrate/EVM, or an existing Stellar account. The server builds transactions assuming nonce 0 / no account, so mid-ramp execution halts with nonce-mismatch or "account already exists" errors after subsidies/funding have been spent. | **MITIGATED (F-068)**: `validateEphemeralAccountsFresh()` runs in `registerRamp` immediately after format validation. For each ephemeral type the client provides, it queries every supported chain of that type (Substrate: pendulum, hydration, assethub; EVM: all configured EVM networks including Moonbeam; Stellar) and rejects the registration if any check finds non-zero nonce / non-zero free balance / pre-existing Stellar account. Fail-closed on RPC errors. | ## Audit Checklist @@ -48,4 +48,4 @@ The SDK optionally stores ephemeral keys to a local JSON file (`ephemerals_{ramp - [x] Each call to `generateEphemerals()` produces fresh, unique keypairs — no memoization or caching — ✅ PASS - [x] Unsigned transactions returned to the client are bound to the specific ephemeral addresses provided during registration — ✅ PASS - [ ] The API does not trust that an ephemeral address is an EOA on EVM — verify if contract address detection is needed — 🟡 PARTIAL (no check, but low self-harm risk) -- [x] **F-068**: Ephemeral addresses are verified fresh on every chain the ramp route will sign on at `registerRamp` — `validateEphemeralAccountsFresh()` in `apps/api/src/api/services/ramp/ephemeral-freshness.ts`, invoked after `normalizeAndValidateSigningAccounts`. Substrate: `nonce === 0 && free === 0`. EVM: `nonce === 0`. Stellar: account must not exist on Horizon. Route → network mapping in `getEphemeralNetworksForQuote` mirrors the dispatcher logic in `apps/api/src/api/services/transactions/{offramp,onramp}/index.ts` and MUST be updated whenever a phase handler adds a new chain. Fail-closed on RPC errors (`SERVICE_UNAVAILABLE`). — ✅ PASS +- [x] **F-068**: Each submitted ephemeral address is verified fresh on every supported chain of its type at `registerRamp` — `validateEphemeralAccountsFresh()` in `apps/api/src/api/services/ramp/ephemeral-freshness.ts`, invoked after `normalizeAndValidateSigningAccounts`. Substrate (pendulum, hydration, assethub): `nonce === 0 && free === 0`. EVM (all configured EVM networks): `nonce === 0`. Stellar: account must not exist on Horizon. The supported-network lists `SUPPORTED_SUBSTRATE_NETWORKS` and `SUPPORTED_EVM_NETWORKS` MUST be updated whenever the platform adds a new chain an ephemeral can ever sign on. Fail-closed on RPC errors (`SERVICE_UNAVAILABLE`). — ✅ PASS diff --git a/docs/security-spec/03-ramp-engine/transaction-validation.md b/docs/security-spec/03-ramp-engine/transaction-validation.md index bfa8d7e45..939e6375d 100644 --- a/docs/security-spec/03-ramp-engine/transaction-validation.md +++ b/docs/security-spec/03-ramp-engine/transaction-validation.md @@ -49,7 +49,7 @@ The two layers together guarantee that the client cannot (a) sneak a malicious p 7. **`areAllTxsIncluded` is only an inclusion guard** — It may remain metadata-only (`phase + network + nonce + signer`) if each submitted non-skipped transaction is content-bound in `validatePresignedTxs` against the unsigned transaction selected with the same identity keys. 8. **No chain type or transaction format may be silently skipped during validation** — If a new chain or transaction format is added, the validator must either handle it or reject it. Silent pass-through (`return` without validation) is forbidden. 9. **Validation MUST occur before any presigned transaction is persisted or executed** — The `updateRamp` and `startRamp` flows must reject invalid transactions before merging them into ramp state. -10. **Ephemeral addresses submitted at `registerRamp` MUST be proven fresh on every route-relevant chain before transactions are built** — Address format validation is insufficient. The server MUST query each chain the ramp will use and reject the registration if any required ephemeral has non-zero nonce, non-zero free balance, or (for Stellar) already exists on-chain. Fail-closed on RPC errors. Without this, the server builds presigned transactions with assumed-fresh nonces, and execution halts mid-ramp on the first chain where the assumption breaks. +10. **Ephemeral addresses submitted at `registerRamp` MUST be proven fresh on every supported chain of their type before transactions are built** — Address format validation is insufficient. For each ephemeral type the client submits, the server MUST query every supported chain of that type (not only the chains the specific ramp route will use) and reject the registration if any check finds non-zero nonce, non-zero free balance, or (for Stellar) an account that already exists on-chain. Checking the full supported set prevents future phase-handler additions from silently reopening the freshness gap. Fail-closed on RPC errors. Without this, the server builds presigned transactions with assumed-fresh nonces, and execution halts mid-ramp on the first chain where the assumption breaks. ## Threat Vectors & Mitigations @@ -64,7 +64,7 @@ The two layers together guarantee that the client cannot (a) sneak a malicious p | **Transaction data substitution via metadata matching** | Client submits transactions with correct phase/network/nonce/signer metadata but different txData content. | **MITIGATED (F-043)**: `validatePresignedTxs` resolves the matching unsigned transaction by the same identity keys and performs content validation before `areAllTxsIncluded` is used as the final inclusion guard. | | **EVM contract target or execution-parameter substitution** | Client signs a raw EVM transaction to an attacker-controlled contract, or signs the expected transaction with gas/fee parameters too low to execute reliably. | **MITIGATED (F-050)**: Raw signed EVM transactions are recovered and compared to the server-issued unsigned `to`, `data`, `value`, and `nonce`; gas limit and fee caps must be at least the server-issued values, and contract-creation transactions are rejected. | | **New phase/format added without validation** | A developer adds a new phase and the validator silently treats it as EVM because the phase type falls through to a default. | **MITIGATED (F-047)**: `getTransactionTypeForPhase` now throws for unknown phases instead of defaulting to EVM. | -| **Non-fresh ephemeral submitted at registration** | Client submits an ephemeral address that already has on-chain history (non-zero nonce on Substrate/EVM, or an existing Stellar account). Backend builds presigned transactions assuming nonce 0; execution halts mid-ramp on the first signed broadcast after subsidies/funding have already been committed. | **MITIGATED (F-068)**: `registerRamp` invokes `validateEphemeralAccountsFresh()` after `normalizeAndValidateSigningAccounts`. Route-relevant chains are derived via `getEphemeralNetworksForQuote` (mirrors the offramp/onramp dispatcher). Substrate: requires `nonce === 0 && free === 0`. EVM: requires `nonce === 0`. Stellar: account must not exist on Horizon. Fail-closed on RPC errors. | +| **Non-fresh ephemeral submitted at registration** | Client submits an ephemeral address that already has on-chain history (non-zero nonce on Substrate/EVM, or an existing Stellar account). Backend builds presigned transactions assuming nonce 0; execution halts mid-ramp on the first signed broadcast after subsidies/funding have already been committed. | **MITIGATED (F-068)**: `registerRamp` invokes `validateEphemeralAccountsFresh()` after `normalizeAndValidateSigningAccounts`. For each ephemeral type the client provides, it checks every supported chain of that type (Substrate: pendulum, hydration, assethub; EVM: all configured EVM networks including Moonbeam; Stellar). Substrate: requires `nonce === 0 && free === 0`. EVM: requires `nonce === 0`. Stellar: account must not exist on Horizon. Fail-closed on RPC errors. | ## Audit Checklist @@ -99,4 +99,4 @@ The two layers together guarantee that the client cannot (a) sneak a malicious p - [x] **Chainless EVM tx rejection**: `verifySignedEvmTransaction` rejects raw txs whose decoded `chainId` is `undefined` (pre-EIP-155 legacy txs), closing a cross-chain replay bypass that existed even when `sandboxEnabled` was false. - [x] **Backup re-verification**: `meta.additionalTxs` must contain exactly the expected backup set, and every backup is re-run through the primary's validator (EVM signer + nonce + content; Substrate signer + call-equality via `method.toHex()`; Stellar signer + per-phase shape), so a malicious client cannot register ignored extras or backups that encode a different call or signer than the primary tx. - [x] **`updateRamp` subset submissions**: `validatePresignedTxs` accepts `{ requireComplete: false }` for partial submissions but still rejects extra/unknown txs and still applies full per-tx content validation; `requireComplete` defaults to `true` for `startRamp`. -- [x] **F-068**: `registerRamp` proves ephemeral addresses fresh on every route-relevant chain before building transactions. `validateEphemeralAccountsFresh()` (`apps/api/src/api/services/ramp/ephemeral-freshness.ts`) is invoked after `normalizeAndValidateSigningAccounts`. Route → network mapping in `getEphemeralNetworksForQuote` mirrors the dispatcher logic in `apps/api/src/api/services/transactions/{offramp,onramp}/index.ts` and MUST be kept in sync. Substrate `nonce === 0 && free === 0`; EVM `nonce === 0`; Stellar account must not exist. RPC errors fail closed with `SERVICE_UNAVAILABLE`. +- [x] **F-068**: `registerRamp` proves each submitted ephemeral fresh on every supported chain of its type before building transactions. `validateEphemeralAccountsFresh()` (`apps/api/src/api/services/ramp/ephemeral-freshness.ts`) is invoked after `normalizeAndValidateSigningAccounts`. The supported-network lists (`SUPPORTED_SUBSTRATE_NETWORKS`: pendulum, hydration, assethub; `SUPPORTED_EVM_NETWORKS`: all configured EVM networks including Moonbeam) MUST be kept in sync with any chain an ephemeral may ever sign on. Substrate `nonce === 0 && free === 0`; EVM `nonce === 0`; Stellar account must not exist. RPC errors fail closed with `SERVICE_UNAVAILABLE`. diff --git a/docs/security-spec/FINDINGS.md b/docs/security-spec/FINDINGS.md index 85c71b4dd..77722c53d 100644 --- a/docs/security-spec/FINDINGS.md +++ b/docs/security-spec/FINDINGS.md @@ -1470,15 +1470,14 @@ If a database partner record has `markupValue = -0.01` and `markupType = "relati **Fix:** Added `validateEphemeralAccountsFresh()` (`apps/api/src/api/services/ramp/ephemeral-freshness.ts`), invoked in `registerRamp` immediately after `normalizeAndValidateSigningAccounts`. The validator: -1. **Computes the route-relevant network set** via `getEphemeralNetworksForQuote(quote, additionalData)`, which mirrors the offramp/onramp transaction-builder dispatcher logic in `apps/api/src/api/services/transactions/{offramp,onramp}/index.ts`. Only chains an ephemeral will actually sign on are checked (e.g. SELL/BRL with EVM input → EVM:[Base]; BUY/EURC → AssetHub → EVM:[Polygon, Moonbeam] + Substrate:[pendulum, hydration-if-non-USDC]). -2. **Substrate**: queries `system.account(address)` on each required chain; requires `nonce === 0` AND `free === 0`. -3. **EVM**: queries `getTransactionCount(address)` on each required chain; requires `nonce === 0`. +1. **Checks every supported chain of each submitted ephemeral type unconditionally** — rather than deriving a route-relevant subset from the quote. Supported sets: Substrate = `[pendulum, hydration, assethub]`; EVM = all configured EVM networks including Moonbeam (`SUPPORTED_EVM_NETWORKS`); Stellar = single Horizon check. This is intentionally wider than strictly required so that a future phase-handler addition cannot silently reopen the freshness gap by routing through a chain not covered by a route-derived mapping. +2. **Substrate**: queries `system.account(address)` on each supported chain; requires `nonce === 0` AND `free === 0`. +3. **EVM**: queries `getTransactionCount(address)` on each supported chain; requires `nonce === 0`. 4. **Stellar**: calls `loadAccountWithRetry(address)` against Horizon; requires the account to **not exist** (the server creates and funds it during the ramp). 5. **Fail-closed**: any RPC error rejects the registration with `SERVICE_UNAVAILABLE` rather than allowing freshness to be presumed. -6. **Required-but-missing ephemerals** for a route are rejected with `BAD_REQUEST` before any RPC call. -7. Scope: `registerRamp` only. `updateRamp` does not re-check, since the ephemeral identity is bound to ramp state at registration time. +6. Scope: `registerRamp` only. `updateRamp` does not re-check, since the ephemeral identity is bound to ramp state at registration time. -If a chain is added to a phase handler that an ephemeral signs on, `getEphemeralNetworksForQuote` MUST be updated to include it, otherwise the freshness gap reopens for that route. +If the platform adds a new chain an ephemeral can ever sign on, `SUPPORTED_SUBSTRATE_NETWORKS` / `SUPPORTED_EVM_NETWORKS` MUST be updated, otherwise the freshness check leaves that chain unverified. ---