Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion apps/api/src/api/middlewares/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
}
}

Expand Down
131 changes: 131 additions & 0 deletions apps/api/src/api/services/ramp/ephemeral-freshness.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
121 changes: 121 additions & 0 deletions apps/api/src/api/services/ramp/ephemeral-freshness.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
const checks: Promise<void>[] = [];

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<void> {
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<void> {
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
});
}
Comment on lines +84 to +101
}

async function assertStellarAccountFresh(address: string): Promise<void> {
let account: Awaited<ReturnType<typeof loadAccountWithRetry>>;
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
});
}
}
3 changes: 3 additions & 0 deletions apps/api/src/api/services/ramp/ramp.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions docs/security-spec/02-signing-keys/ephemeral-accounts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand All @@ -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
3 changes: 3 additions & 0 deletions docs/security-spec/03-ramp-engine/transaction-validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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`.
Loading
Loading