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 new file mode 100644 index 000000000..41f73c9a1 --- /dev/null +++ b/apps/api/src/api/services/ramp/ephemeral-freshness.test.ts @@ -0,0 +1,131 @@ +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"; +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 { validateEphemeralAccountsFresh } = await import("./ephemeral-freshness"); + +describe("validateEphemeralAccountsFresh", () => { + beforeEach(() => { + substrateNonce = 0; + substrateFree = "0"; + evmNonce = 0; + stellarAccount = null; + evmGetClientShouldThrow = false; + }); + + 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 + }) + ).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 }); + 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 }); + 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 }); + 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 }); + throw new Error("expected rejection"); + } catch (err) { + expect((err as APIError).status).toBe(400); + expect((err as APIError).message).toContain("already exists"); + } + }); + + it("fails closed with SERVICE_UNAVAILABLE on RPC error", async () => { + evmGetClientShouldThrow = true; + try { + 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 new file mode 100644 index 000000000..103a01df0 --- /dev/null +++ b/apps/api/src/api/services/ramp/ephemeral-freshness.ts @@ -0,0 +1,121 @@ +import { + ApiManager, + EphemeralAccountType, + EvmClientManager, + EvmNetworks, + Networks, + SubstrateApiNetwork +} from "@vortexfi/shared"; +import Big from "big.js"; +import httpStatus from "http-status"; +import { APIError } from "../../errors/api-error"; +import { loadAccountWithRetry } from "../stellar/loadAccount"; + +const SUPPORTED_SUBSTRATE_NETWORKS: SubstrateApiNetwork[] = ["pendulum", "hydration", "assethub"]; + +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; + } +): Promise { + const checks: Promise[] = []; + + const substrateAddress = ephemerals[EphemeralAccountType.Substrate]; + if (substrateAddress) { + for (const network of SUPPORTED_SUBSTRATE_NETWORKS) { + checks.push(assertSubstrateAccountFresh(substrateAddress, network)); + } + } + + const evmAddress = ephemerals[EphemeralAccountType.EVM]; + if (evmAddress) { + for (const network of SUPPORTED_EVM_NETWORKS) { + checks.push(assertEvmAccountFresh(evmAddress, network)); + } + } + + const stellarAddress = ephemerals[EphemeralAccountType.Stellar]; + if (stellarAddress) { + 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..9d6520724 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 { 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); + const { unsignedTxs, stateMeta, depositQrCode, ibanPaymentData, aveniaTicketId } = await this.prepareRampTransactions( quote, normalizedSigningAccounts, diff --git a/docs/security-spec/02-signing-keys/ephemeral-accounts.md b/docs/security-spec/02-signing-keys/ephemeral-accounts.md index 989a713e1..fc73369bf 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 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 @@ -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. 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 @@ -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**: 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 eec318032..939e6375d 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 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 @@ -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`. 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 @@ -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 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 420f510e8..77722c53d 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,32 @@ 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. **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. Scope: `registerRamp` only. `updateRamp` does not re-check, since the ephemeral identity is bound to ramp state at registration time. + +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. + +--- + ## Additional Observations (Not Findings) These are design observations noted during spec writing that may warrant review but aren't direct vulnerabilities: 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(); }