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
8 changes: 8 additions & 0 deletions apps/api/src/api/services/phases/phase-processor.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import httpStatus from "http-status";
import logger from "../../../config/logger";
import { runWithRampContext } from "../../../config/ramp-context";
import { config } from "../../../config/vars";
import RampState from "../../../models/rampState.model";
import { APIError } from "../../errors/api-error";
import { PhaseError, RecoverablePhaseError } from "../../errors/phase-error";
Expand Down Expand Up @@ -40,6 +41,13 @@ export class PhaseProcessor {
});
}

if (state.flowVariant !== config.flowVariant) {
logger.warn(
`Refusing to process ramp ${rampId}: belongs to flow ${state.flowVariant}, this backend is ${config.flowVariant}`
);
return;
}

// Try to acquire the lock
let lockAcquired = await this.acquireLock(state);
if (!lockAcquired) {
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/api/services/quote/engines/finalize/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { getPaymentMethodFromDestinations, QuoteResponse, RampDirection } 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 { trimTrailingZeros } from "../../core/helpers";
Expand Down Expand Up @@ -149,6 +150,7 @@ export abstract class BaseFinalizeEngine implements Stage {
apiKey: request.apiKey || null,
countryCode: request.countryCode,
expiresAt,
flowVariant: config.flowVariant,
from: request.from,
inputAmount: request.inputAmount,
inputCurrency: request.inputCurrency,
Expand Down
5 changes: 5 additions & 0 deletions apps/api/src/api/services/quote/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import Big from "big.js";
import httpStatus from "http-status";
import pLimit from "p-limit";
import logger from "../../../config/logger";
import { config } from "../../../config/vars";
import Partner from "../../../models/partner.model";
import { APIError } from "../../errors/api-error";
import { BaseRampService } from "../ramp/base.service";
Expand All @@ -38,6 +39,10 @@ export class QuoteService extends BaseRampService {
return null;
}

if (quote.flowVariant !== config.flowVariant) {
return null;
}

return buildQuoteResponse(quote);
}

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
});
}
}

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
});
}
}
Loading
Loading