From a61b9c197b0877cdb6de78a02ce5b3aed2538f01 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 29 Sep 2026 16:11:02 +0200 Subject: [PATCH 1/6] feat(api): add recoverFundedSellRamp for SELL ramps with a reported source hash A SELL ramp whose user broadcast the source transaction and reported its hash inside the 15 minute window, but whose client never reached /ramp/start, stays initial forever with the funds on the ephemeral. Add a service-level start that skips only the deadline check and requires a non-domestic SELL with a reported hash; FundEphemeral still verifies the hash on-chain before any platform spend. The public start and update routes stay strict. Keep the flow versions of these rows registered through the startup compatibility check, since the recovery worker will be able to start them. --- .../blocks/core/compatibility-scope.test.ts | 26 +++++++- .../phases/blocks/core/compatibility-scope.ts | 37 +++++++++-- .../ramp.service.moonbeam-retirement.test.ts | 8 ++- .../ramp.service.recover-funded-sell.test.ts | 64 +++++++++++++++++++ .../api/src/api/services/ramp/ramp.service.ts | 30 ++++++++- 5 files changed, 155 insertions(+), 10 deletions(-) create mode 100644 apps/api/src/api/services/ramp/ramp.service.recover-funded-sell.test.ts diff --git a/apps/api/src/api/services/phases/blocks/core/compatibility-scope.test.ts b/apps/api/src/api/services/phases/blocks/core/compatibility-scope.test.ts index 004fe9ce5..2f80142e1 100644 --- a/apps/api/src/api/services/phases/blocks/core/compatibility-scope.test.ts +++ b/apps/api/src/api/services/phases/blocks/core/compatibility-scope.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from "bun:test"; +import { RampDirection } from "@vortexfi/shared"; import { Op } from "sequelize"; import { RAMP_START_EXPIRATION_TIME_SECONDS } from "../../../../../constants/constants"; -import { getPersistedBlockFlowCompatibilityScope } from "./compatibility-scope"; +import { getFundedInitialSellRampWhere, getPersistedBlockFlowCompatibilityScope } from "./compatibility-scope"; + +const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000; describe("persisted block-flow compatibility scope", () => { it("scopes pending quotes and resumable ramps to the current flow variant", () => { @@ -19,9 +22,28 @@ describe("persisted block-flow compatibility scope", () => { [Op.or]: [ { currentPhase: { [Op.notIn]: ["complete", "failed", "timedOut", "initial"] } }, { createdAt: { [Op.gte]: initialRampCutoff }, currentPhase: "initial" }, - { currentPhase: "initial", "state.aveniaTicketId": { [Op.ne]: null } } + { currentPhase: "initial", "state.aveniaTicketId": { [Op.ne]: null } }, + // Funded SELL ramps the recovery worker may start past the client window. + { + createdAt: { [Op.gt]: new Date(now.getTime() - THREE_DAYS_MS), [Op.lt]: now }, + currentPhase: "initial", + type: RampDirection.SELL, + [Op.or]: [ + { "state.squidRouterSwapHash": { [Op.ne]: null } }, + { "state.squidRouterNoPermitTransferHash": { [Op.ne]: null } } + ] + } ] } }); }); + + it("selects funded SELL ramps only between the minimum age and the three-day recovery window", () => { + const now = new Date("2026-07-31T12:00:00.000Z"); + + expect(getFundedInitialSellRampWhere(now, 16 * 60 * 1000).createdAt).toEqual({ + [Op.gt]: new Date(now.getTime() - THREE_DAYS_MS), + [Op.lt]: new Date(now.getTime() - 16 * 60 * 1000) + }); + }); }); diff --git a/apps/api/src/api/services/phases/blocks/core/compatibility-scope.ts b/apps/api/src/api/services/phases/blocks/core/compatibility-scope.ts index c3b18ddbf..f4246fc8e 100644 --- a/apps/api/src/api/services/phases/blocks/core/compatibility-scope.ts +++ b/apps/api/src/api/services/phases/blocks/core/compatibility-scope.ts @@ -1,17 +1,43 @@ +import { RampDirection } from "@vortexfi/shared"; import { Op } from "sequelize"; import type { FlowVariant } from "../../../../../config/vars"; import { RAMP_START_EXPIRATION_TIME_SECONDS } from "../../../../../constants/constants"; const TERMINAL_RAMP_PHASES = ["complete", "failed", "timedOut"] as const; +const FUNDED_SELL_RECOVERY_WINDOW_MS = 3 * 24 * 60 * 60 * 1000; + +/** + * `initial` SELL ramps whose user-broadcast source transaction hash was already reported, created + * within the recovery window and at least `minAgeMs` ago. The user's funds are on the ephemeral + * once that transaction mines, so the recovery worker starts these past the client start window. + * The worker selects with this predicate and the startup check keeps their flow versions + * registered, so the two cannot drift apart. + */ +export function getFundedInitialSellRampWhere(now = new Date(), minAgeMs = 0) { + return { + createdAt: { + [Op.gt]: new Date(now.getTime() - FUNDED_SELL_RECOVERY_WINDOW_MS), + [Op.lt]: new Date(now.getTime() - minAgeMs) + }, + currentPhase: "initial" as const, + type: RampDirection.SELL, + [Op.or]: [ + { "state.squidRouterSwapHash": { [Op.ne]: null } }, + { "state.squidRouterNoPermitTransferHash": { [Op.ne]: null } } + ] + }; +} /** * Selects only persisted state that this backend could still execute. * * A registered ramp remains in `initial` until startRamp is called. Both updateRamp - * and the public startRamp reject it after the shared expiration window. Avenia ramps - * are the exception: registration creates a payable PIX ticket, and the recovery - * worker may start an expired initial ramp after the provider confirms payment. Those - * rows therefore remain deployment dependencies. Once a ramp has entered a financial + * and the public startRamp reject it after the shared expiration window. Two kinds of + * ramp are the exception: Avenia registration creates a payable PIX ticket, and the + * recovery worker may start an expired initial ramp after the provider confirms payment; + * and a SELL ramp whose user already reported its source transaction hash is started by + * the same worker (see getFundedInitialSellRampWhere). Those rows therefore remain + * deployment dependencies. Once a ramp has entered a financial * phase, age never makes it safe to ignore: every non-terminal phase owned by this flow * variant stays fail-closed. */ @@ -29,7 +55,8 @@ export function getPersistedBlockFlowCompatibilityScope(flowVariant: FlowVariant [Op.or]: [ { currentPhase: { [Op.notIn]: [...TERMINAL_RAMP_PHASES, "initial"] } }, { createdAt: { [Op.gte]: initialRampCutoff }, currentPhase: "initial" }, - { currentPhase: "initial", "state.aveniaTicketId": { [Op.ne]: null } } + { currentPhase: "initial", "state.aveniaTicketId": { [Op.ne]: null } }, + getFundedInitialSellRampWhere(now) ] } }; diff --git a/apps/api/src/api/services/ramp/ramp.service.moonbeam-retirement.test.ts b/apps/api/src/api/services/ramp/ramp.service.moonbeam-retirement.test.ts index 5f5ebf704..a53e798a4 100644 --- a/apps/api/src/api/services/ramp/ramp.service.moonbeam-retirement.test.ts +++ b/apps/api/src/api/services/ramp/ramp.service.moonbeam-retirement.test.ts @@ -71,7 +71,7 @@ describe("RampService Moonbeam retirement", () => { expect(update).not.toHaveBeenCalled(); }); - it("rejects public and provider-paid starts before persisted flow execution", async () => { + it("rejects public, provider-paid, and funded-SELL starts before persisted flow execution", async () => { RampState.findByPk = mock(async () => ({ createdAt: new Date(), currentPhase: "initial", @@ -85,7 +85,11 @@ describe("RampService Moonbeam retirement", () => { })) as unknown as typeof RampState.findByPk; const service = new TestRampService(); - for (const start of [() => service.startRamp({ rampId: "ramp-1" }), () => service.recoverPaidAveniaRamp("ramp-1")]) { + for (const start of [ + () => service.startRamp({ rampId: "ramp-1" }), + () => service.recoverPaidAveniaRamp("ramp-1"), + () => service.recoverFundedSellRamp("ramp-1") + ]) { await expect(start()).rejects.toMatchObject({ status: httpStatus.SERVICE_UNAVAILABLE }); } }); diff --git a/apps/api/src/api/services/ramp/ramp.service.recover-funded-sell.test.ts b/apps/api/src/api/services/ramp/ramp.service.recover-funded-sell.test.ts new file mode 100644 index 000000000..969652045 --- /dev/null +++ b/apps/api/src/api/services/ramp/ramp.service.recover-funded-sell.test.ts @@ -0,0 +1,64 @@ +import { afterEach, describe, expect, it, mock } from "bun:test"; +import { FiatToken, Networks, RampDirection } from "@vortexfi/shared"; +import httpStatus from "http-status"; +import type { Transaction } from "sequelize"; +import { config } from "../../../config/vars"; +import QuoteTicket from "../../../models/quoteTicket.model"; +import RampState from "../../../models/rampState.model"; +import { RampService } from "./ramp.service"; + +class TestRampService extends RampService { + protected async withTransaction(callback: (transaction: Transaction) => Promise): Promise { + return callback({} as Transaction); + } +} + +const originalQuoteFindByPk = QuoteTicket.findByPk; +const originalRampFindByPk = RampState.findByPk; + +afterEach(() => { + QuoteTicket.findByPk = originalQuoteFindByPk; + RampState.findByPk = originalRampFindByPk; +}); + +function stubRampAndQuote(ramp: { state: Record; type: RampDirection }, outputCurrency: string) { + RampState.findByPk = mock(async () => ({ + createdAt: new Date(Date.now() - 60 * 60 * 1000), + currentPhase: "initial", + flowVariant: config.flowVariant, + from: Networks.Ethereum, + id: "ramp-1", + presignedTxs: [], + quoteId: "quote-1", + to: "pix", + unsignedTxs: [], + ...ramp + })) as unknown as typeof RampState.findByPk; + QuoteTicket.findByPk = mock(async () => ({ + id: "quote-1", + metadata: { blocks: {}, flow: { id: "BrlOfframpBase" }, globals: { fees: { usd: {} }, request: {} } }, + outputCurrency + })) as unknown as typeof QuoteTicket.findByPk; +} + +describe("RampService.recoverFundedSellRamp guards", () => { + const conflict = { message: "Ramp does not have a reported source transaction", status: httpStatus.CONFLICT }; + + it("refuses a SELL ramp whose source transaction hash was never reported", async () => { + stubRampAndQuote({ state: {}, type: RampDirection.SELL }, FiatToken.BRL); + + await expect(new TestRampService().recoverFundedSellRamp("ramp-1")).rejects.toMatchObject(conflict); + }); + + it("refuses a BUY ramp even when a hash-shaped field is present", async () => { + stubRampAndQuote({ state: { squidRouterSwapHash: "0xabc" }, type: RampDirection.BUY }, FiatToken.BRL); + + await expect(new TestRampService().recoverFundedSellRamp("ramp-1")).rejects.toMatchObject(conflict); + }); + + it("refuses a domestic (AlfredPay) SELL whose reported hash FundEphemeral does not verify", async () => { + stubRampAndQuote({ state: { squidRouterNoPermitTransferHash: "0xabc" }, type: RampDirection.SELL }, FiatToken.MXN); + + await expect(new TestRampService().recoverFundedSellRamp("ramp-1")).rejects.toMatchObject(conflict); + }); +}); diff --git a/apps/api/src/api/services/ramp/ramp.service.ts b/apps/api/src/api/services/ramp/ramp.service.ts index fc5968e62..6d8c2dc49 100644 --- a/apps/api/src/api/services/ramp/ramp.service.ts +++ b/apps/api/src/api/services/ramp/ramp.service.ts @@ -576,9 +576,22 @@ export class RampService extends BaseRampService { return this.startRampWithOptions({ rampId }, { enforceDeadline: false, requirePaidAveniaTicket: true }); } + /** + * Start an EVM SELL ramp whose user already reported the hash of their source transaction but + * whose client never reached /ramp/start inside the window. That transaction delivers the funds + * to the ephemeral, so the deadline no longer protects anyone; FundEphemeral verifies the + * reported hash against the issued blueprint on-chain before any platform spend. + */ + public async recoverFundedSellRamp(rampId: string): Promise { + return this.startRampWithOptions( + { rampId }, + { enforceDeadline: false, requirePaidAveniaTicket: false, requireReportedSellSource: true } + ); + } + private async startRampWithOptions( request: StartRampRequest, - options: { enforceDeadline: boolean; requirePaidAveniaTicket: boolean } + options: { enforceDeadline: boolean; requirePaidAveniaTicket: boolean; requireReportedSellSource?: boolean } ): Promise { return this.withTransaction(async transaction => { const rampState = await RampState.findByPk(request.rampId, { lock: Transaction.LOCK.UPDATE, transaction }); @@ -622,6 +635,21 @@ export class RampService extends BaseRampService { status: httpStatus.CONFLICT }); } + if (options.requireReportedSellSource) { + // Domestic (AlfredPay) SELLs are excluded: FundEphemeral only verifies the reported hash + // for the other EVM SELLs, so this recovery has no pre-spend proof for them. + const { squidRouterNoPermitTransferHash, squidRouterSwapHash } = rampState.state; + if ( + rampState.type !== RampDirection.SELL || + isDomesticToken(quote.outputCurrency as FiatToken) || + !(squidRouterSwapHash || squidRouterNoPermitTransferHash) + ) { + throw new APIError({ + message: "Ramp does not have a reported source transaction", + status: httpStatus.CONFLICT + }); + } + } if (options.enforceDeadline) { RampService.assertStartDeadlineNotExceeded(rampState); } From b1f3efffa9133bd08a3e8e34c8b7bc3422b1ff82 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 29 Sep 2026 16:16:57 +0200 Subject: [PATCH 2/6] fix(api): start funded SELL ramps from the recovery worker An EVM SELL ramp whose user broadcast the source transaction and reported its hash, but whose client never called /ramp/start within 15 minutes, stayed initial forever with the funds on the ephemeral: the recovery worker skipped initial ramps and the unhandled-payment worker only knows Avenia tickets. Each cycle the recovery worker now starts initial SELL ramps of this flow variant with a reported swap or direct-transfer hash, once they are more than a minute past the public deadline and less than three days old. Failures are logged on the ramp and retried next cycle. Public /ramp/start and /ramp/update stay strict, and FundEphemeral still verifies the hash on-chain before any platform spend. --- .../api/workers/ramp-recovery.worker.test.ts | 87 ++++++- .../src/api/workers/ramp-recovery.worker.ts | 26 +- .../brl-offramp-crosschain.scenario.test.ts | 235 ++++++++++++++++-- .../corridors/brl-offramp.scenario.test.ts | 28 +++ 4 files changed, 357 insertions(+), 19 deletions(-) diff --git a/apps/api/src/api/workers/ramp-recovery.worker.test.ts b/apps/api/src/api/workers/ramp-recovery.worker.test.ts index a8abb6f2a..b2df76785 100644 --- a/apps/api/src/api/workers/ramp-recovery.worker.test.ts +++ b/apps/api/src/api/workers/ramp-recovery.worker.test.ts @@ -1,7 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; -import { EPaymentMethod, Networks } from "@vortexfi/shared"; +import { EPaymentMethod, Networks, RampDirection } from "@vortexfi/shared"; +import { Op } from "sequelize"; +import { config } from "../../config/vars"; import RampState from "../../models/rampState.model"; import phaseProcessor from "../services/phases/phase-processor"; +import rampService from "../services/ramp/ramp.service"; import RampRecoveryWorker from "./ramp-recovery.worker"; const originalFindAll = RampState.findAll; @@ -37,3 +40,85 @@ describe("RampRecoveryWorker Moonbeam retirement", () => { expect(processRamp).not.toHaveBeenCalled(); }); }); + +describe("RampRecoveryWorker funded SELL start", () => { + const originalRecoverFundedSellRamp = rampService.recoverFundedSellRamp; + const originalAppendErrorLog = rampService.appendErrorLog; + const recoverFundedSellRamp = mock(async (_rampId: string): Promise => undefined); + const appendErrorLog = mock(async (_id: string, _entry: unknown) => undefined); + const fundedSell = { + currentPhase: "initial", + from: Networks.Ethereum, + id: "funded-sell-ramp", + state: { flow: { id: "BrlOfframpBase" }, squidRouterSwapHash: "0xabc" }, + to: EPaymentMethod.PIX, + unsignedTxs: [] + }; + let queries: Array<{ where: Record }>; + + beforeEach(() => { + queries = []; + RampState.findAll = mock(async (options: { where: Record }) => { + queries.push(options); + return options.where.currentPhase === "initial" ? [fundedSell] : []; + }) as unknown as typeof RampState.findAll; + rampService.recoverFundedSellRamp = recoverFundedSellRamp as unknown as typeof rampService.recoverFundedSellRamp; + rampService.appendErrorLog = appendErrorLog as unknown as typeof rampService.appendErrorLog; + recoverFundedSellRamp.mockReset(); + recoverFundedSellRamp.mockImplementation(async () => undefined); + appendErrorLog.mockClear(); + }); + + afterEach(() => { + rampService.recoverFundedSellRamp = originalRecoverFundedSellRamp; + rampService.appendErrorLog = originalAppendErrorLog; + }); + + async function runWorker() { + const worker = new RampRecoveryWorker("*/5 * * * *", false) as unknown as { recover: () => Promise }; + await worker.recover(); + } + + it("selects initial SELL ramps with a reported source hash between 16 minutes and 3 days old", async () => { + const before = Date.now(); + await runWorker(); + const after = Date.now(); + + const where = queries.find(query => query.where.currentPhase === "initial")?.where as Record; + expect(where.type).toBe(RampDirection.SELL); + expect(where.flowVariant).toBe(config.flowVariant); + expect(where[Op.or]).toEqual([ + { "state.squidRouterSwapHash": { [Op.ne]: null } }, + { "state.squidRouterNoPermitTransferHash": { [Op.ne]: null } } + ]); + const createdAt = where.createdAt as Record; + const minute = 60 * 1000; + expect(before - createdAt[Op.lt].getTime()).toBeGreaterThanOrEqual(16 * minute); + expect(after - createdAt[Op.lt].getTime()).toBeLessThan(16 * minute + 5000); + expect(before - createdAt[Op.gt].getTime()).toBeGreaterThanOrEqual(3 * 24 * 60 * minute); + expect(after - createdAt[Op.gt].getTime()).toBeLessThan(3 * 24 * 60 * minute + 5000); + }); + + it("starts each selected ramp through the funded SELL path, not the phase processor", async () => { + await runWorker(); + + expect(recoverFundedSellRamp).toHaveBeenCalledTimes(1); + expect(recoverFundedSellRamp).toHaveBeenCalledWith("funded-sell-ramp"); + expect(processRamp).not.toHaveBeenCalled(); + expect(appendErrorLog).not.toHaveBeenCalled(); + }); + + it("logs a failed start on the ramp and selects it again on the next cycle", async () => { + recoverFundedSellRamp.mockImplementation(async () => { + throw new Error("database unavailable"); + }); + + await runWorker(); + await runWorker(); + + expect(appendErrorLog).toHaveBeenCalledTimes(2); + expect(appendErrorLog.mock.calls[0]?.[0]).toBe("funded-sell-ramp"); + expect(appendErrorLog.mock.calls[0]?.[1]).toMatchObject({ error: "database unavailable", phase: "initial" }); + expect(recoverFundedSellRamp).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/api/src/api/workers/ramp-recovery.worker.ts b/apps/api/src/api/workers/ramp-recovery.worker.ts index f0b3f3992..ad2f782e8 100644 --- a/apps/api/src/api/workers/ramp-recovery.worker.ts +++ b/apps/api/src/api/workers/ramp-recovery.worker.ts @@ -3,12 +3,16 @@ import { CronJob } from "cron"; import { Op } from "sequelize"; import logger from "../../config/logger"; import { config } from "../../config/vars"; +import { RAMP_START_EXPIRATION_TIME_SECONDS } from "../../constants/constants"; import RampState from "../../models/rampState.model"; +import { getFundedInitialSellRampWhere } from "../services/phases/blocks/core/compatibility-scope"; import { isMoonbeamRuntimeDisabledForState } from "../services/phases/moonbeam-runtime"; import phaseProcessor from "../services/phases/phase-processor"; import rampService from "../services/ramp/ramp.service"; const TEN_MINUTES_IN_MS = 10 * 60 * 1000; +// Funded SELL ramps are started only once the public start window has certainly closed. +const FUNDED_SELL_MIN_AGE_MS = (RAMP_START_EXPIRATION_TIME_SECONDS + 60) * 1000; const DISABLED_HYDRATION_PHASES = ["pendulumToHydrationXcm", "hydrationSwap", "hydrationToAssethubXcm"]; /** @@ -69,8 +73,17 @@ class RampRecoveryWorker { } }); - const statesToRecover = staleStates.filter(state => !isMoonbeamRuntimeDisabledForState(state)); - const retiredStateCount = staleStates.length - statesToRecover.length; + // SELL ramps whose user reported the source transaction hash but whose client never started + // them before the public start window closed. Their funds are already on the ephemeral. + const fundedSellStates = await RampState.findAll({ + where: { + ...getFundedInitialSellRampWhere(new Date(), FUNDED_SELL_MIN_AGE_MS), + flowVariant: config.flowVariant + } + }); + + const statesToRecover = [...staleStates, ...fundedSellStates].filter(state => !isMoonbeamRuntimeDisabledForState(state)); + const retiredStateCount = staleStates.length + fundedSellStates.length - statesToRecover.length; if (retiredStateCount > 0) { logger.warn(`Skipped ${retiredStateCount} Moonbeam-dependent ramp states during automatic recovery.`); } @@ -82,12 +95,17 @@ class RampRecoveryWorker { logger.info(`Found ${statesToRecover.length} stale ramp states to process.`); - // Process each stale state concurrently + // Process each state concurrently. A funded initial SELL ramp is started (past the public + // deadline); every other state resumes its current phase. const recoveryPromises = statesToRecover.map(async state => { try { logger.info(`Attempting recovery in phase ${state.currentPhase} for ramp ${state.id}`); // Process the state (processRamp already wraps execution with runWithRampContext) - await phaseProcessor.processRamp(state.id); + if (state.currentPhase === "initial") { + await rampService.recoverFundedSellRamp(state.id); + } else { + await phaseProcessor.processRamp(state.id); + } logger.info(`Successfully processed ramp state ${state.id}`); return { stateId: state.id, status: "fulfilled" }; } catch (e: unknown) { diff --git a/apps/api/src/tests/corridors/brl-offramp-crosschain.scenario.test.ts b/apps/api/src/tests/corridors/brl-offramp-crosschain.scenario.test.ts index 7f6587044..ec9f3fae9 100644 --- a/apps/api/src/tests/corridors/brl-offramp-crosschain.scenario.test.ts +++ b/apps/api/src/tests/corridors/brl-offramp-crosschain.scenario.test.ts @@ -1,4 +1,4 @@ -import { afterAll, beforeAll, beforeEach, describe, expect, it, mock } from "bun:test"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"; import * as shared from "@vortexfi/shared"; import { AveniaTicketStatus, @@ -18,6 +18,8 @@ import { import { parseUnits } from "viem"; import { generatePrivateKey, privateKeyToAccount, type PrivateKeyAccount } from "viem/accounts"; import phaseProcessor from "../../api/services/phases/phase-processor"; +import rampService from "../../api/services/ramp/ramp.service"; +import RampRecoveryWorker from "../../api/workers/ramp-recovery.worker"; import { getFlowMetadata } from "../../api/services/phases/blocks/core/metadata"; import { resolvePersistedBlockFlow } from "../../api/services/phases/blocks/flows/catalog"; import { assertPersistedBlockFlowVersionsSupported } from "../../api/services/phases/blocks/register-handlers"; @@ -78,6 +80,7 @@ interface CorridorSetup { approveHash: `0x${string}`; /** Hash of the user's broadcast squidRouterSwap on Polygon. */ swapHash: `0x${string}`; + userId: string; } /** @@ -201,6 +204,28 @@ describe("BRL offramp cross-chain corridor (USDC on Polygon → Base → pix via }); } + /** + * Signs a blueprint plus the four required same-call backups at the following + * nonces, shaped for /v1/ramp/update. + */ + async function signBlueprintWithBackups(ephemeral: PrivateKeyAccount, blueprint: UnsignedTx) { + const additionalTxs: Record = {}; + for (let i = 1; i <= 4; i++) { + additionalTxs[`${blueprint.phase}${i}`] = { + nonce: blueprint.nonce + i, + txData: await signBlueprint(ephemeral, { ...blueprint, nonce: blueprint.nonce + i }) + }; + } + return { + meta: { additionalTxs }, + network: blueprint.network, + nonce: blueprint.nonce, + phase: blueprint.phase, + signer: ephemeral.address, + txData: await signBlueprint(ephemeral, blueprint) + }; + } + /** Broadcasts a user-wallet blueprint on its source chain exactly as issued. */ function broadcastUserBlueprint(userWallet: PrivateKeyAccount, blueprint: UnsignedTx): `0x${string}` { const txData = blueprint.txData as unknown as { to: `0x${string}`; data: `0x${string}`; value?: string }; @@ -217,7 +242,7 @@ describe("BRL offramp cross-chain corridor (USDC on Polygon → Base → pix via * plus the ephemeral's presigned Base-side transactions the way the * frontend/SDK would via /v1/ramp/update. */ - async function setUpRegisteredRamp(options: { reportHashes?: boolean } = {}): Promise { + async function setUpRegisteredRamp(options: { reportHashes?: boolean; viaApi?: boolean } = {}): Promise { const reportHashes = options.reportHashes ?? true; const ephemeral = privateKeyToAccount(generatePrivateKey()); const userWallet = privateKeyToAccount(generatePrivateKey()); @@ -266,16 +291,39 @@ describe("BRL offramp cross-chain corridor (USDC on Polygon → Base → pix via const approveHash = broadcastUserBlueprint(userWallet, approveBlueprint); const swapHash = broadcastUserBlueprint(userWallet, swapBlueprint); - await rampState.update({ - presignedTxs: [ - presign(nablaApproveBlueprint, signedNablaApprove), - presign(nablaSwapBlueprint, signedNablaSwap), - presign(payoutBlueprint, signedPayout) - ], - state: reportHashes - ? { ...rampState.state, squidRouterApproveHash: approveHash, squidRouterSwapHash: swapHash } - : rampState.state - }); + let effectiveSignedNablaSwap = signedNablaSwap; + let effectiveSignedPayout = signedPayout; + if (options.viaApi) { + // Full API flow: sign EVERY ephemeral blueprint (with the required backups) and submit + // through /v1/ramp/update, so the later /v1/ramp/start validation sees a complete set. + const apiPresignedTxs = []; + for (const blueprint of unsignedTxs.filter(tx => tx.signer.toLowerCase() === ephemeral.address.toLowerCase())) { + apiPresignedTxs.push(await signBlueprintWithBackups(ephemeral, blueprint)); + } + effectiveSignedNablaSwap = apiPresignedTxs.find(tx => tx.phase === "nablaSwap")?.txData as `0x${string}`; + effectiveSignedPayout = apiPresignedTxs.find(tx => tx.phase === "brlaPayoutOnBase")?.txData as `0x${string}`; + const updateResponse = await app.request("/v1/ramp/update", { + body: JSON.stringify({ + additionalData: reportHashes ? { squidRouterApproveHash: approveHash, squidRouterSwapHash: swapHash } : {}, + presignedTxs: apiPresignedTxs, + rampId: ramp.id + }), + headers: { Authorization: `Bearer ${testUserToken(user.id)}`, "Content-Type": "application/json" }, + method: "POST" + }); + expect(updateResponse.status).toBe(200); + } else { + await rampState.update({ + presignedTxs: [ + presign(nablaApproveBlueprint, signedNablaApprove), + presign(nablaSwapBlueprint, signedNablaSwap), + presign(payoutBlueprint, signedPayout) + ], + state: reportHashes + ? { ...rampState.state, squidRouterApproveHash: approveHash, squidRouterSwapHash: swapHash } + : rampState.state + }); + } return { approveBlueprint, @@ -283,12 +331,13 @@ describe("BRL offramp cross-chain corridor (USDC on Polygon → Base → pix via ephemeral, quoteId: quote.id, rampId: ramp.id, - signedNablaSwap, - signedPayout, + signedNablaSwap: effectiveSignedNablaSwap, + signedPayout: effectiveSignedPayout, swapBlueprint, swapHash, swapInputRaw, swapOutputRaw, + userId: user.id, userWallet }; } @@ -486,6 +535,164 @@ describe("BRL offramp cross-chain corridor (USDC on Polygon → Base → pix via 30000 ); + describe("recovery worker starts funded SELL ramps the client never started", () => { + const MINUTE = 60 * 1000; + let startSpy: ReturnType>; + + beforeEach(() => { + startSpy = spyOn(rampService, "recoverFundedSellRamp"); + }); + + afterEach(() => { + startSpy.mockRestore(); + }); + + async function backdate(rampId: string, ageMs: number): Promise { + await RampState.update({ createdAt: new Date(Date.now() - ageMs) }, { where: { id: rampId } }); + } + + async function runRecoveryWorker(): Promise { + const worker = new RampRecoveryWorker("*/5 * * * *", false) as unknown as { recover: () => Promise }; + await worker.recover(); + } + + async function waitForPhase(rampId: string, phase: RampPhase): Promise { + const deadline = Date.now() + 20000; + for (;;) { + const ramp = await RampState.findByPk(rampId); + if (ramp?.currentPhase === phase) { + return ramp; + } + if (Date.now() > deadline) { + throw new Error(`Ramp ${rampId} did not reach ${phase}; stuck in ${ramp?.currentPhase}`); + } + await new Promise(resolve => setTimeout(resolve, 50)); + } + } + + // The worker's only way to start a ramp is recoverFundedSellRamp, and a started ramp advances + // asynchronously, so the spy (not the persisted phase alone) proves nothing was started. + async function expectStillInitialAndUntouched(setup: CorridorSetup): Promise { + expect(startSpy).not.toHaveBeenCalled(); + const ramp = await RampState.findByPk(setup.rampId); + expect(ramp?.currentPhase).toBe("initial"); + expect(ramp?.errorLogs).toEqual([]); + expect(ramp?.phaseHistory.map(entry => entry.phase)).toEqual(["initial"]); + expect(submissionsOf(setup.signedNablaSwap)).toBe(0); + expect(submissionsOf(setup.signedPayout)).toBe(0); + } + + it( + "starts and completes a ramp whose hash was reported inside the window but was never started", + async () => { + const setup = await setUpRegisteredRamp({ viaApi: true }); + scriptHappyWorld(setup); + const pixOutBefore = world.brla.pixOutputTickets.length; + await backdate(setup.rampId, 17 * MINUTE); + + await runRecoveryWorker(); + + expect(startSpy).toHaveBeenCalledTimes(1); + expect(startSpy).toHaveBeenCalledWith(setup.rampId); + const final = await waitForPhase(setup.rampId, "complete"); + expect(final.phaseHistory.map(entry => entry.phase)).toEqual(HAPPY_PATH_PHASES); + expect(submissionsOf(setup.signedNablaSwap)).toBe(1); + expect(submissionsOf(setup.signedPayout)).toBe(1); + expect(world.evm.erc20Balance(Networks.Base, BRLA_ON_BASE, world.brla.subaccountEvmWallet)).toBe(setup.swapOutputRaw); + expect(world.brla.pixOutputTickets.length).toBe(pixOutBefore + 1); + }, + 60000 + ); + + it("keeps the public start and update strict: both still reject the same ramp with 400 after the deadline", async () => { + const setup = await setUpRegisteredRamp({ viaApi: true }); + await backdate(setup.rampId, 17 * MINUTE); + const headers = { Authorization: `Bearer ${testUserToken(setup.userId)}`, "Content-Type": "application/json" }; + + const start = await app.request("/v1/ramp/start", { + body: JSON.stringify({ rampId: setup.rampId }), + headers, + method: "POST" + }); + const update = await app.request("/v1/ramp/update", { + body: JSON.stringify({ + additionalData: { squidRouterSwapHash: setup.swapHash }, + presignedTxs: [], + rampId: setup.rampId + }), + headers, + method: "POST" + }); + + expect(start.status).toBe(400); + expect(await start.text()).toContain("Maximum time window to start process exceeded"); + expect(update.status).toBe(400); + expect(await update.text()).toContain("Maximum time window to start process exceeded"); + await expectStillInitialAndUntouched(setup); + }); + + it("leaves a ramp whose source hash was never reported initial", async () => { + const setup = await setUpRegisteredRamp({ reportHashes: false, viaApi: true }); + scriptHappyWorld(setup); + await backdate(setup.rampId, 17 * MINUTE); + + await runRecoveryWorker(); + + await expectStillInitialAndUntouched(setup); + }); + + for (const ageMinutes of [14, 15.5]) { + it(`leaves a ramp untouched while the public start window or its one-minute grace is open (${ageMinutes} min)`, async () => { + const setup = await setUpRegisteredRamp({ viaApi: true }); + scriptHappyWorld(setup); + await backdate(setup.rampId, ageMinutes * MINUTE); + + await runRecoveryWorker(); + + await expectStillInitialAndUntouched(setup); + }); + } + + it("leaves a ramp older than the three-day recovery window untouched", async () => { + const setup = await setUpRegisteredRamp({ viaApi: true }); + scriptHappyWorld(setup); + await backdate(setup.rampId, 3 * 24 * 60 * MINUTE + 60 * MINUTE); + + await runRecoveryWorker(); + + await expectStillInitialAndUntouched(setup); + }); + + it( + "security: a reported hash whose calldata differs from the blueprint fails the started ramp before any spend", + async () => { + const setup = await setUpRegisteredRamp({ reportHashes: false, viaApi: true }); + scriptHappyWorld(setup); + const swapTxData = setup.swapBlueprint.txData as unknown as { to: `0x${string}`; value?: string }; + const tamperedHash = world.evm.broadcastUserTransaction(Networks.Polygon, setup.userWallet.address, { + data: "0xdeadbeef", + to: swapTxData.to, + value: BigInt(swapTxData.value ?? "0") + }); + const rampState = await RampState.findByPk(setup.rampId); + await rampState?.update({ + state: { ...rampState.state, squidRouterApproveHash: setup.approveHash, squidRouterSwapHash: tamperedHash } + }); + await backdate(setup.rampId, 17 * MINUTE); + + await runRecoveryWorker(); + + const final = await waitForPhase(setup.rampId, "failed"); + expect(final.phaseHistory.map(entry => entry.phase)).not.toContain("complete"); + expect(final.errorLogs.some(log => log.error.includes("calldata does not match"))).toBe(true); + expect(submissionsOf(setup.signedNablaSwap)).toBe(0); + expect(submissionsOf(setup.signedPayout)).toBe(0); + expect(world.evm.erc20Balance(Networks.Base, BRLA_ON_BASE, world.brla.subaccountEvmWallet)).toBe(0n); + }, + 60000 + ); + }); + async function requestSellQuote(inputCurrency: string, inputAmount: string, network: Networks = Networks.Ethereum) { return app.request("/v1/quotes", { body: JSON.stringify({ diff --git a/apps/api/src/tests/corridors/brl-offramp.scenario.test.ts b/apps/api/src/tests/corridors/brl-offramp.scenario.test.ts index 6f9892885..31a53a566 100644 --- a/apps/api/src/tests/corridors/brl-offramp.scenario.test.ts +++ b/apps/api/src/tests/corridors/brl-offramp.scenario.test.ts @@ -15,6 +15,7 @@ import { import { decodeFunctionData, encodeFunctionData, erc20Abi, parseTransaction, parseUnits } from "viem"; import { generatePrivateKey, privateKeyToAccount, type PrivateKeyAccount } from "viem/accounts"; import phaseProcessor from "../../api/services/phases/phase-processor"; +import RampRecoveryWorker from "../../api/workers/ramp-recovery.worker"; import { getEvmFundingAccount } from "../../api/services/phases/blocks/core/evm-funding"; import { getFlowMetadata } from "../../api/services/phases/blocks/core/metadata"; import FinancialOperation from "../../models/financialOperation.model"; @@ -698,4 +699,31 @@ describe("BRL offramp swap corridor (USDC on Base → pix via Avenia)", () => { }, 30000 ); + + it( + "recovery worker: starts a direct-transfer ramp whose transfer hash was reported but never started", + async () => { + const setup = await setUpRegisteredRamp({ submitViaApi: true }); + scriptHappyWorld(setup); + // The client reported the transfer hash inside the window, then never called /v1/ramp/start. + await RampState.update({ createdAt: new Date(Date.now() - 17 * 60 * 1000) }, { where: { id: setup.rampId } }); + const pixOutBefore = world.brla.pixOutputTickets.length; + + const worker = new RampRecoveryWorker("*/5 * * * *", false) as unknown as { recover: () => Promise }; + await worker.recover(); + + const deadline = Date.now() + 20000; + let final = await RampState.findByPk(setup.rampId); + while (final?.currentPhase !== "complete" && Date.now() < deadline) { + await new Promise(resolve => setTimeout(resolve, 50)); + final = await RampState.findByPk(setup.rampId); + } + expect(final?.currentPhase).toBe("complete"); + expect(final?.phaseHistory.map(entry => entry.phase)).toEqual(HAPPY_PATH_PHASES); + expect(submissionsOf(setup.signedNablaSwap)).toBe(1); + expect(submissionsOf(setup.signedPayout)).toBe(1); + expect(world.brla.pixOutputTickets.length).toBe(pixOutBefore + 1); + }, + 60000 + ); }); From 6b28050e2b4c951d6545a19ce746e487406a1788 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Tue, 29 Sep 2026 16:18:59 +0200 Subject: [PATCH 3/6] docs(api): document worker-driven start of funded SELL ramps Record the one deadline bypass added for SELL ramps whose source hash was reported: who may start them, why a bogus hash cannot cause platform spend, how late execution is bounded, and the manual case that remains (broadcast after the deadline or hash never reported). --- docs/operations-testing.md | 5 +++++ .../03-ramp-engine/block-flow-architecture.md | 14 ++++++++++---- .../03-ramp-engine/ephemeral-accounts.md | 1 + .../03-ramp-engine/ramp-phase-flows.md | 3 ++- .../03-ramp-engine/transaction-validation.md | 2 +- docs/security-spec/RISK-REGISTER.md | 4 ++-- 6 files changed, 21 insertions(+), 8 deletions(-) diff --git a/docs/operations-testing.md b/docs/operations-testing.md index 08df62311..29e75ac43 100644 --- a/docs/operations-testing.md +++ b/docs/operations-testing.md @@ -44,6 +44,11 @@ Derived from `docs/security-spec/` — these must never regress, and each has de `failed`. Locks are released on terminal states; only `currentPhase`/`phaseHistory` are updated by the processor. - Presigned transaction and ephemeral address validation (F-021, F-038 class). +- The start deadline is relaxed only for worker-driven recovery: public `/ramp/update` and + `/ramp/start` keep rejecting an expired ramp even with a source hash reported, while the + recovery worker starts (and completes) a non-domestic SELL ramp with a reported hash between + 16 minutes and 3 days old and leaves every other `initial` ramp untouched + (`corridors/brl-offramp-crosschain.scenario.test.ts`, `brl-offramp.scenario.test.ts`). - External swap/route outputs are validated against expectations before funds move (F-030). When a new security finding is fixed, add a regression test in the same PR and reference the diff --git a/docs/security-spec/03-ramp-engine/block-flow-architecture.md b/docs/security-spec/03-ramp-engine/block-flow-architecture.md index 81d90fb6c..7133dd5c4 100644 --- a/docs/security-spec/03-ramp-engine/block-flow-architecture.md +++ b/docs/security-spec/03-ramp-engine/block-flow-architecture.md @@ -30,10 +30,16 @@ runtime validation, and startup wiring checks therefore remain mandatory. backend can still dispatch persisted state that references it. The startup check MUST cover every unexpired pending quote and every resumable ramp owned by the configured flow variant. Resumable ramps include all nonterminal ramps after `initial`, regardless - of age, plus `initial` ramps within the start deadline and every Avenia `initial` ramp - with a payable ticket. Public update/start calls MUST reject an `initial` ramp after + of age, plus `initial` ramps within the start deadline, every Avenia `initial` ramp + with a payable ticket, and every `initial` SELL ramp under three days old whose + user-reported source hash (`squidRouterSwapHash` or `squidRouterNoPermitTransferHash`) + is present. Public update/start calls MUST reject an `initial` ramp after the deadline, but the unhandled-payment worker MAY start an expired Avenia ramp only - after the provider reports its exact persisted ticket as paid. A deployment may remove + after the provider reports its exact persisted ticket as paid, and the ramp-recovery + worker MAY start an expired non-domestic EVM SELL ramp only through + `RampService.recoverFundedSellRamp` once that hash is reported. The worker's selection + and the startup scope share one predicate (`getFundedInitialSellRampWhere`) so they + cannot drift apart. A deployment may remove a version only after this scoped check proves that the backend cannot dispatch it. Rollback MUST retain every version introduced by the deployment being rolled back. @@ -132,7 +138,7 @@ runtime validation, and startup wiring checks therefore remain mandatory. | A registry registration silently replaces another handler | Duplicate registration is rejected | | Old or manually edited JSONB is cast into a new TypeScript type | Versioned envelope validation before registration, start, or recovery | | Two blocks flatten different values into one legacy field | Compatibility merge rejects conflicting values | -| An old flow implementation is removed too early | Per-variant deployment/removal check against unexpired pending quotes, active ramps, and Avenia initial ramps with payable tickets; public update/start reject expired initial ramps while provider-confirmed payment recovery retains the persisted flow | +| An old flow implementation is removed too early | Per-variant deployment/removal check against unexpired pending quotes, active ramps, Avenia initial ramps with payable tickets, and funded initial SELL ramps (reported source hash, under three days old); public update/start reject expired initial ramps while provider-confirmed payment recovery and funded-SELL recovery retain the persisted flow | | An old worker consumes newly introduced executor metadata during a rolling deploy | Keep new quote production behind a default-off activation flag; deploy the dual legacy/v2 executor everywhere before enabling the new program | | A provider accepts an order and the database transaction later rolls back | Independent durable financial-operation claim; retry reuses the confirmed response or halts on ambiguity | | Two workers attempt the same external side effect | Unique operation key and atomic `not_started` → `submitted` claim | diff --git a/docs/security-spec/03-ramp-engine/ephemeral-accounts.md b/docs/security-spec/03-ramp-engine/ephemeral-accounts.md index 8f7871e26..162e487f9 100644 --- a/docs/security-spec/03-ramp-engine/ephemeral-accounts.md +++ b/docs/security-spec/03-ramp-engine/ephemeral-accounts.md @@ -55,6 +55,7 @@ The cleanup worker (`cleanup.worker.ts`) selects ramps where `currentPhase ∈ { | Threat | Attack Scenario | Mitigation | |---|---|---| | **Stuck funds on failed ramp** | Ramp fails after `fundEphemeral` but before any swap executes. Tokens sit on an ephemeral account. | The cleanup worker selects on `currentPhase ∈ {"complete", "failed", "timedOut"}`, but the Base and Polygon handlers process only completed ramps. Polygon Monerium cleanup also covers USDC only, not EURe. These gaps are deferred under RISK-016; AssetHub is a no-op stub and retired Moonbeam requires manual reconciliation under RISK-020. | +| **Stuck funds on an unstarted SELL ramp** | An EVM SELL user's source transaction delivers USDC to the ephemeral, but the ramp stays `initial` because the client never reached `/v1/ramp/start` inside the window. The cleanup worker selects only terminal ramps and the stale-ramp recovery query excludes `initial`. | **Mitigated when the hash was reported.** `RampRecoveryWorker` starts such non-domestic SELL ramps through `recoverFundedSellRamp` (see `ramp-phase-flows.md`); `fundEphemeral` verifies the hash on-chain before any platform funding. **Known gap:** if the user broadcast after the deadline, or the hash was never reported, nothing records the transaction and the funds need manual recovery with the client-held ephemeral key. AlfredPay SELLs are not covered by this recovery. | | **Stuck ERC-20 dust on Base** | BRL on/off-ramps could leave BRLA/USDC residuals on the Base ephemeral. | **Mitigated.** `BaseChainPostProcessHandler` sweeps both BRLA and USDC after `currentPhase === "complete"` via presigned `approve` + funding-key `transferFrom`. ETH gas dust is not swept. | | **Native gas dust on cross-chain EVM destinations** | A native-token reserve funds a destination ephemeral for its payout. Any unused reserve remains after the ramp. | **Known gap.** All EVM destination funding is derived from the signed transaction fee cap and only the balance shortfall is sent. Quotes charge the estimated funding-plus-payout execution cost rather than the maximum signed reserve. The remaining `maxFeePerGas` versus effective-gas-price difference is intentionally accepted until the flow moves to a smart-contract or paymaster model. | | **Client-inflated destination reserve** | The client signs the expected payout call with an excessive gas limit or fee cap, causing Vortex to transfer a large native balance to an ephemeral whose key the client controls. | **Mitigated.** API validation requires exact server gas and bounds both fee fields by the production 3× multiplier for primaries and backups. `fundEphemeral` re-binds the signed transaction to the unsigned blueprint before computing its reserve. | diff --git a/docs/security-spec/03-ramp-engine/ramp-phase-flows.md b/docs/security-spec/03-ramp-engine/ramp-phase-flows.md index 2e67bccfd..3dca74947 100644 --- a/docs/security-spec/03-ramp-engine/ramp-phase-flows.md +++ b/docs/security-spec/03-ramp-engine/ramp-phase-flows.md @@ -223,7 +223,8 @@ graph TD | **Phase skip / injection** | Attacker with DB access modifies `currentPhase` to skip subsidization or jump to `complete`. | Phase transitions are controlled by handler return values, not external input. DB access is a prerequisite (see `state-machine.md`, Threat: "Phase skip attack"). No DB-level constraints on valid transitions exist. | | **Subsidy drain** | A crafted ramp triggers multiple subsidization phases, each at the maximum allowed amount, draining the funding account. | Per-ramp subsidy caps (`MAX_FINAL_SETTLEMENT_SUBSIDY_USD`, balance pre-checks in pre/post-swap handlers). EVM pre/post-swap caps are env-configured quote-relative fractions, and EVM post-swap subsidy is split into discrepancy and discount components with independent caps. No aggregate cross-ramp cap exists — many concurrent ramps could still drain funds. | | **Double-execution on retry** | Phase processor retries after timeout. Handler re-executes a swap or transfer that already completed. Funds are consumed twice. | The Hydration handler has a nonce guard. Other handlers rely on transaction nonce uniqueness at the chain level. Not all handlers have explicit re-execution guards. | -| **Stale presigned transaction** | Client registers a ramp, waits for market movement, then starts the ramp with presigned transactions based on the old quote. | `RAMP_START_EXPIRATION_TIME_SECONDS` limits the window between registration and start. Quote expiry (10 minutes) limits how old the amounts can be. | +| **Stale presigned transaction** | Client registers a ramp, waits for market movement, then starts the ramp with presigned transactions based on the old quote. | `RAMP_START_EXPIRATION_TIME_SECONDS` limits the window between registration and start on the public `updateRamp`/`startRamp` routes, which never relax it. Quote expiry (10 minutes) limits how old the amounts can be. The only deadline bypasses are worker-only starts: the funded SELL recovery in the next row and Avenia paid-ticket recovery (`05-integrations/brla.md`). | +| **Funded SELL ramp never started** | An EVM SELL user broadcasts the source transaction and reports its hash inside the start window (for example after a slow Ethereum receipt wait), but `/v1/ramp/start` is called after the window or never. The bridged USDC sits on the ephemeral while the ramp stays `initial`, which no other worker advances. | `RampRecoveryWorker` starts the ramp through `RampService.recoverFundedSellRamp`, which skips only the deadline check. Eligibility: `initial`, type SELL, this flow variant, non-domestic (AlfredPay SELLs are excluded), `squidRouterSwapHash` or `squidRouterNoPermitTransferHash` reported, created more than 16 minutes and less than 3 days ago, not Moonbeam-dependent. Complete-presign, blueprint-binding, flow-identity, and Moonbeam-retirement validation still apply. `fundEphemeral` verifies the reported hash against the issued blueprint on-chain (receipt sender, target, calldata, value) before any platform funding, so a bogus or tampered hash fails the ramp with no spend. Late execution is bounded: normally about 16-21 minutes after registration, up to three days when start attempts fail and retry, and always inside the presigned Nabla swap's one-week deadline. The swap carries the on-chain 5% hard minimum output (`AMM_MINIMUM_OUTPUT_HARD_MARGIN`) and is dry-run before broadcast, and the pre/post-swap subsidy caps still apply, so price drift beyond them pauses the ramp instead of paying. Not covered: a user who broadcasts after the deadline, or whose hash is never reported, leaves funds on the ephemeral for manual recovery because nothing records that the transaction exists. | | **Direct API ramp mutation during planned downtime** | A partner bypasses the UI maintenance state and calls register/update/start while operators expect Vortex services to be paused. | Ramp mutation routes run the backend maintenance guard and return `503` with `Retry-After`, `maintenance_start`, and `maintenance_end` before registration, presigned transaction updates, or phase processing begins. | | **Cross-chain race condition** | XCM transfer submitted but not finalized. Next phase on destination chain reads a zero balance. | Most XCM handlers use `waitForFinalization=true`. Exception: Hydration skips finalization (F-009, deferred). | | **Fee distribution failure** | `distributeFees` fails, but ramp is already marked `complete`. Platform loses fee revenue. | `distributeFees` is a phase — if it fails, the ramp enters retry, not `complete`. However, if the ramp fails after user delivery but before fee distribution, fees may be lost. | diff --git a/docs/security-spec/03-ramp-engine/transaction-validation.md b/docs/security-spec/03-ramp-engine/transaction-validation.md index 558327f88..988ed2a28 100644 --- a/docs/security-spec/03-ramp-engine/transaction-validation.md +++ b/docs/security-spec/03-ramp-engine/transaction-validation.md @@ -86,7 +86,7 @@ The two layers together guarantee that the client cannot (a) sneak a malicious p - [x] Onramp-specific validation checks quote amounts and integration-specific fields; Monerium registration derives provider identity and its self-transfer validates the exact permit and `transferFrom` payloads - [x] Polygon `uniswapApprove` and `uniswapSwap` transactions use generic signed-EVM blueprint validation plus exact decoded route validation before persistence and again before broadcast. - [x] Offramp-specific validation (`validateOfframpQuote`, `validateBRLOfframp`) checks quote consistency -- [x] `RAMP_START_EXPIRATION_TIME_SECONDS` enforces a time window between registration and start — prevents stale presigned transactions from being executed +- [x] `RAMP_START_EXPIRATION_TIME_SECONDS` enforces a time window between registration and start on the public `updateRamp`/`startRamp` routes — prevents stale presigned transactions from being executed. Two worker-only starts skip this one check and keep every other `startRamp` validation: `recoverPaidAveniaRamp` (provider-confirmed `PAID` Avenia ticket) and `recoverFundedSellRamp` (non-domestic SELL with a reported source-transaction hash, verified on-chain by `fundEphemeral` before any platform spend; see `ramp-phase-flows.md`). - [x] Default rejection for unrecognized phases — `getTransactionTypeForPhase` throws instead of defaulting to EVM (see F-047) - [ ] **F-055**: Backup presigned transactions (`backupApprove`) use unlimited `maxUint256` ERC-20 approval amount — excessive blast radius if funding key is compromised. - [ ] **F-056**: `sandboxEnabled` bypasses chainId validation in `validateEvmTransaction` and skips entire ramp flow in `initial-phase-handler` — no production guard prevents accidental activation. diff --git a/docs/security-spec/RISK-REGISTER.md b/docs/security-spec/RISK-REGISTER.md index 0d68a7221..039d3c7b0 100644 --- a/docs/security-spec/RISK-REGISTER.md +++ b/docs/security-spec/RISK-REGISTER.md @@ -26,7 +26,7 @@ register and the owning module specification. | RISK-002 | Accepted | Medium | Operations | Administrative writes on the shared-secret `/v1/admin/*` surface use one `ADMIN_SECRET`; there is no individual principal, MFA, role separation, selective revocation, or per-operator attribution on that surface. | Independent high-entropy secret, constant-time equal-length comparison, route middleware, rate limiting, operational rotation. `HTTP_GRANTABLE_PROFILE_ROLES` additionally prevents this shared secret from granting `vortex_admin` (`admin-auth.md` Invariant 8), so it cannot bootstrap its way onto the identity-bearing `/v1/admin-console/*` surface. | Introduce an identity provider before broadening the `/v1/admin/*` surface or team access. The Supabase-authenticated, role-gated `/v1/admin-console/*` surface (RISK-018) satisfies this warning for its own bounded scope by using per-operator identity instead of a shared secret; `/v1/admin/*` itself is unchanged and this entry still applies to it. | | RISK-003 | Accepted | Medium | Product + Security | Pending recipient invitations retain the raw bearer token so the sender can re-copy the link. | 192-bit random token, 14-day TTL, hash-only redemption lookup, sender-scoped listing, optional email binding, first-redeemer binding, raw token cleared on acceptance/observed expiry. | Revisit if invitations gain money-movement authority or threat exposure changes. | | RISK-004 | Deferred | High | Product + Payments Architecture | Recipient eligibility is advisory; recipient-directed payout is unsupported. Ramp registration is a sender self-offramp and rejects common recipient-context fields. | Authenticated/entity-scoped recipient APIs; explicit registration rejection prevents accidental reliance on ignored fields. | A separate PR must define the second principal, relationship ownership, hard eligibility gate, and provider-side payout reference resolution before enabling recipient payout. | -| RISK-005 | Accepted | Medium | Product + Operations | The product promises the exact quoted amount. A ramp does not downgrade that promise or report a lesser amount as successful when automated delivery cannot complete. | Exact quote-bound targets, balance checks, capped subsidy paths, recoverable/terminal phase states, reconciliation data. | Add a formal deadline and automatic return of in-transit funds without weakening the exact-amount promise. | +| RISK-005 | Accepted | Medium | Product + Operations | The product promises the exact quoted amount. A ramp does not downgrade that promise or report a lesser amount as successful when automated delivery cannot complete. A funded EVM SELL ramp that the client never started is started by the recovery worker roughly 16-21 minutes after registration (up to three days when starts retry), so the platform absorbs Nabla price drift over that longer gap only within the caps below; a SELL whose user broadcast after the deadline or never reported the hash is not started and needs manual recovery. | Exact quote-bound targets, balance checks, capped subsidy paths (per-ramp subsidy caps, Nabla 5% on-chain hard minimum, pre-broadcast dry-run), recoverable/terminal phase states, reconciliation data. Worker-driven start requires a reported source hash that `fundEphemeral` verifies on-chain before any platform spend. | Add a formal deadline and automatic return of in-transit funds without weakening the exact-amount promise. | | RISK-006 | Accepted | Medium | Client Platform | Widget/dashboard recovery keys are retained until a terminal ramp state is observed, then for 90 days; unresolved ramps are retained indefinitely. The prototype browser SDK backup has no automatic terminal pruning and remains in plaintext same-origin localStorage until the integrator removes it. | Route-scoped freshness; widget/dashboard pruning on storage access; explicit browser-SDK documentation and origin allowlisting. | Add a browser storage adapter and terminal-aware pruning before presenting browser SDK custody as a hardened production default, or sooner if storage pressure or client compromise data warrants it. | | RISK-007 | Deployment pending | High | Smart Contracts + Operations | TokenRelayer source rejects fee-on-transfer shortfalls, partial consumption, codeless destinations, and cross-execution balance subsidy. Existing deployed addresses do not inherit the fix. Automatic discrepancy subsidy is intentionally absent because no immutable cap/funding policy has been approved. | Execution-local token/native balance accounting, exact transient allowance, refund attribution, events, contract tests. | Redeploy and verify bytecode on every supported chain, update the address registry, retire old deployments, and record rollout evidence. | | RISK-008 | Accepted | High | Payments Platform | Squid/Axelar terminal status is preferred, but an EVM destination-balance fallback remains necessary because provider indexing can miss real arrivals. The fallback waits for baseline plus 90% of the exact route output; a remainder racing the final pre-claim balance read and broadcast can still overfund the ephemeral. | Route/source/token/amount/baseline-bound persisted evidence, explicit fallback kind and ratio, structured logging, per-ramp settlement cap, and two live shortfall reads inside the funding FIFO before the durable claim. This prevents queue delay from making the subsidy stale. | Add provider receipt proof or late-arrival reconciliation/recovery before raising caps or expanding exposure. | @@ -36,7 +36,7 @@ register and the owning module specification. | RISK-012 | Accepted | High | Rebalancer Operations | Rebalancer state has no distributed lock and several externally visible steps can be ambiguous across a crash; single-run scheduling is an operational assumption. | One-shot process, saved state, chain nonces/balance checks on some steps, daily bridge limit and route-cost policy. | Add a lease and durable operation claims before allowing overlapping schedules or multiple replicas. | | RISK-014 | Accepted | Medium | Pricing + Treasury | CoinGecko’s `usd-coin` price is used as a USD/fiat fallback or sanity reference, so a USDC depeg can distort the reference. | FastForex/Binance primary routes, sanity bands, short cache TTL, fail-closed when no valid provider remains, operational depeg monitoring. | Replace with an independent fiat FX reference before raising depeg-sensitive exposure. | | RISK-015 | Accepted | Low | EVM Operations | Base cleanup sweeps supported ERC-20 residuals after completion but does not sweep small native gas dust. | Just-in-time gas funding and token sweeps limit residual value. | Revisit if observed native residuals become material. | -| RISK-016 | Deferred | High | Payments Platform | Failed/timed-out Base and Polygon ramps are not swept by their post-process handlers, and AssetHub cleanup is a no-op. The Monerium Polygon cleanup intent covers residual USDC, not EURe. | Cleanup worker selects terminal ramps; completed Base token cleanup and completed Polygon USDC cleanup work; AssetHub corridors remain quote-disabled; client-held ephemeral keys are retained for recovery. | Add safe failed/timed-out handling and EURe coverage on Polygon, widen safe Base cleanup, and implement/remove AssetHub cleanup before relying on automatic failure refunds. | +| RISK-016 | Deferred | High | Payments Platform | Failed/timed-out Base and Polygon ramps are not swept by their post-process handlers, and AssetHub cleanup is a no-op. The Monerium Polygon cleanup intent covers residual USDC, not EURe. | Cleanup worker selects terminal ramps; completed Base token cleanup and completed Polygon USDC cleanup work; AssetHub corridors remain quote-disabled; client-held ephemeral keys are retained for recovery. The recovery worker starts funded `initial` SELL ramps instead of leaving them stranded, but one that later fails falls under this risk like any failed ramp. | Add safe failed/timed-out handling and EURe coverage on Polygon, widen safe Base cleanup, and implement/remove AssetHub cleanup before relying on automatic failure refunds. | | RISK-017 | Deferred | High | Operations + Data | Migrations 060-061 permanently delete legacy provider/KYC/credential records and schema objects. Approved legacy-only data has no archive or database down migration; unknown consumers, old processes, incomplete canonical mappings, or an unusable backup could turn deployment into unrecoverable loss or outage. | Fail-closed parity script, maintenance-window hard cutover and process drain, PostgreSQL catalog/external-consumer audit, five-second lock timeout, default `RESTRICT`, and rehearsed pre-migration restore. | Complete and retain every gate in [`operations-legacy-schema-cleanup.md`](../operations-legacy-schema-cleanup.md), verify production cleanup, and confirm the post-deploy observation window is clean. | | RISK-018 | Accepted | High | Operations + Security | `/v1/admin-console/*` lets a `vortex_admin` operator impersonate a customer with broad read and mutation rights. Ramp registration, update, and start; provider onboarding and KYC/KYB mutations; managed-child creation/deletion; and manager/child credential creation/revocation are denied. Quote generation, recipient, active-entity, notification, and other customer-account operations remain available. Alfredpay fiat-account creation and deletion are explicitly accepted even though these provider-side payout-account mutations outlive the session. | Per-operator Supabase identity plus per-request `vortex_admin` re-check; role removal atomically revokes live sessions; 30-minute non-renewable TTL; transaction-serialized and database-unique active session per (actor, target); hash-only token storage so a leaked row cannot be replayed and revocation is instant; `IMPERSONATION_ENABLED` kill switch that invalidates in-flight sessions, not just new mints; `rejectImpersonation` blocks ramp money movement, KYC/KYB mutations, managed-child lifecycle, manager/child credential lifecycle, and re-entry into the admin console during an impersonated request, with a narrow self-revoke carve-out on `DELETE /impersonation/:sessionId`; `vortex_admin` excluded from `HTTP_GRANTABLE_PROFILE_ROLES` so `ADMIN_SECRET` cannot grant it; `impersonationSessionId`/`impersonatorProfileId` stamped on every `api_client_events` row raised during the request; actor/target foreign keys restrict deletion so audit history is retained. | Revisit before changing the allowed mutation scope; managed sub-account composition has been reviewed with lifecycle and credential mutations denied. See `01-auth/admin-impersonation.md`. | | RISK-019 | Accepted | High | Product + Compliance | Managed-profile contact email uniqueness is manager-scoped, while Alfredpay uses email as provider identity. Different managers can submit the same normalized email; on an Alfredpay `409`, Vortex may adopt the provider customer returned for that email when country and customer type match, without independent proof that the second manager controls that provider identity. | Manager/child authorization remains isolated; contact email is immutable and unique within one manager; conflict recovery rejects country/type mismatch; the provider customer ID remains globally unique locally. Partners must supply an email identity they are authorized to use, and operations must investigate cross-manager collision errors rather than bypass uniqueness. | Before onboarding managers whose customer-email namespaces may overlap, enforce global or provider-scoped ownership of contact email, or replace email-based adoption with a provider ownership/claim proof and migrate existing relationships. | From 4ba3461081febff0141bbebae96ae392fd74ef71 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 1 Oct 2026 18:48:25 +0200 Subject: [PATCH 4/6] test(api): bracket funded sell cutoffs between the clock reads The worker reads Date.now() after the test's 'before' sample, so 'before - cutoff' is at most the minimum age, never at least it; the assertion failed whenever a millisecond ticked in between (CI saw 959999 vs 960000). --- apps/api/src/api/workers/ramp-recovery.worker.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/api/src/api/workers/ramp-recovery.worker.test.ts b/apps/api/src/api/workers/ramp-recovery.worker.test.ts index b2df76785..c017eb5da 100644 --- a/apps/api/src/api/workers/ramp-recovery.worker.test.ts +++ b/apps/api/src/api/workers/ramp-recovery.worker.test.ts @@ -93,10 +93,11 @@ describe("RampRecoveryWorker funded SELL start", () => { ]); const createdAt = where.createdAt as Record; const minute = 60 * 1000; - expect(before - createdAt[Op.lt].getTime()).toBeGreaterThanOrEqual(16 * minute); - expect(after - createdAt[Op.lt].getTime()).toBeLessThan(16 * minute + 5000); - expect(before - createdAt[Op.gt].getTime()).toBeGreaterThanOrEqual(3 * 24 * 60 * minute); - expect(after - createdAt[Op.gt].getTime()).toBeLessThan(3 * 24 * 60 * minute + 5000); + // The worker reads the clock between `before` and `after`, so each cutoff lies in that window. + expect(createdAt[Op.lt].getTime()).toBeGreaterThanOrEqual(before - 16 * minute); + expect(createdAt[Op.lt].getTime()).toBeLessThanOrEqual(after - 16 * minute); + expect(createdAt[Op.gt].getTime()).toBeGreaterThanOrEqual(before - 3 * 24 * 60 * minute); + expect(createdAt[Op.gt].getTime()).toBeLessThanOrEqual(after - 3 * 24 * 60 * minute); }); it("starts each selected ramp through the funded SELL path, not the phase processor", async () => { From 12288e0209580356010d58c8ecc10002d9acee73 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 1 Oct 2026 18:48:32 +0200 Subject: [PATCH 5/6] fix(api): exclude assethub sells from funded sell recovery FundEphemeral verifies a reported Squid hash on-chain only for EVM sources; an AssetHub SELL takes its own branch and never checks that hash. Quote creation already rejects AssetHub BRL SELLs and EUR SELLs, but the guard should not rely on that. --- .../ramp.service.recover-funded-sell.test.ts | 19 ++++++++++++++++++- .../api/src/api/services/ramp/ramp.service.ts | 5 +++-- .../03-ramp-engine/ramp-phase-flows.md | 2 +- .../03-ramp-engine/transaction-validation.md | 2 +- 4 files changed, 23 insertions(+), 5 deletions(-) diff --git a/apps/api/src/api/services/ramp/ramp.service.recover-funded-sell.test.ts b/apps/api/src/api/services/ramp/ramp.service.recover-funded-sell.test.ts index 969652045..6f2561cd8 100644 --- a/apps/api/src/api/services/ramp/ramp.service.recover-funded-sell.test.ts +++ b/apps/api/src/api/services/ramp/ramp.service.recover-funded-sell.test.ts @@ -21,7 +21,10 @@ afterEach(() => { RampState.findByPk = originalRampFindByPk; }); -function stubRampAndQuote(ramp: { state: Record; type: RampDirection }, outputCurrency: string) { +function stubRampAndQuote( + ramp: { from?: Networks; state: Record; to?: string; type: RampDirection }, + outputCurrency: string +) { RampState.findByPk = mock(async () => ({ createdAt: new Date(Date.now() - 60 * 60 * 1000), currentPhase: "initial", @@ -61,4 +64,18 @@ describe("RampService.recoverFundedSellRamp guards", () => { await expect(new TestRampService().recoverFundedSellRamp("ramp-1")).rejects.toMatchObject(conflict); }); + + it("refuses an AssetHub SELL whose reported Squid hash FundEphemeral does not verify", async () => { + stubRampAndQuote( + { + from: Networks.AssetHub, + state: { assethubToPendulumHash: "0xdef", squidRouterSwapHash: "0xabc" }, + to: "sepa", + type: RampDirection.SELL + }, + FiatToken.EURC + ); + + await expect(new TestRampService().recoverFundedSellRamp("ramp-1")).rejects.toMatchObject(conflict); + }); }); diff --git a/apps/api/src/api/services/ramp/ramp.service.ts b/apps/api/src/api/services/ramp/ramp.service.ts index 6d8c2dc49..e060e51a5 100644 --- a/apps/api/src/api/services/ramp/ramp.service.ts +++ b/apps/api/src/api/services/ramp/ramp.service.ts @@ -636,11 +636,12 @@ export class RampService extends BaseRampService { }); } if (options.requireReportedSellSource) { - // Domestic (AlfredPay) SELLs are excluded: FundEphemeral only verifies the reported hash - // for the other EVM SELLs, so this recovery has no pre-spend proof for them. + // Domestic (AlfredPay) and AssetHub SELLs are excluded: FundEphemeral only verifies the + // reported hash for the other EVM SELLs, so this recovery has no pre-spend proof for them. const { squidRouterNoPermitTransferHash, squidRouterSwapHash } = rampState.state; if ( rampState.type !== RampDirection.SELL || + rampState.from === Networks.AssetHub || isDomesticToken(quote.outputCurrency as FiatToken) || !(squidRouterSwapHash || squidRouterNoPermitTransferHash) ) { diff --git a/docs/security-spec/03-ramp-engine/ramp-phase-flows.md b/docs/security-spec/03-ramp-engine/ramp-phase-flows.md index 352d6e005..7f0c9a163 100644 --- a/docs/security-spec/03-ramp-engine/ramp-phase-flows.md +++ b/docs/security-spec/03-ramp-engine/ramp-phase-flows.md @@ -224,7 +224,7 @@ graph TD | **Subsidy drain** | A crafted ramp triggers multiple subsidization phases, each at the maximum allowed amount, draining the funding account. | Per-ramp subsidy caps (`MAX_FINAL_SETTLEMENT_SUBSIDY_USD`, balance pre-checks in pre/post-swap handlers). EVM pre/post-swap caps are env-configured quote-relative fractions, and EVM post-swap subsidy is split into discrepancy and discount components with independent caps. No aggregate cross-ramp cap exists — many concurrent ramps could still drain funds. | | **Double-execution on retry** | Phase processor retries after timeout. Handler re-executes a swap or transfer that already completed. Funds are consumed twice. | The Hydration handler has a nonce guard. Other handlers rely on transaction nonce uniqueness at the chain level. Not all handlers have explicit re-execution guards. | | **Stale presigned transaction** | Client registers a ramp, waits for market movement, then starts the ramp with presigned transactions based on the old quote. | `RAMP_START_EXPIRATION_TIME_SECONDS` limits the window between registration and start on the public `updateRamp`/`startRamp` routes, which never relax it. Quote expiry (10 minutes) limits how old the amounts can be. The only deadline bypasses are worker-only starts: the funded SELL recovery in the next row and Avenia paid-ticket recovery (`05-integrations/brla.md`). | -| **Funded SELL ramp never started** | An EVM SELL user broadcasts the source transaction and reports its hash inside the start window (for example after a slow Ethereum receipt wait), but `/v1/ramp/start` is called after the window or never. The bridged USDC sits on the ephemeral while the ramp stays `initial`, which no other worker advances. | `RampRecoveryWorker` starts the ramp through `RampService.recoverFundedSellRamp`, which skips only the deadline check. Eligibility: `initial`, type SELL, this flow variant, non-domestic (AlfredPay SELLs are excluded), `squidRouterSwapHash` or `squidRouterNoPermitTransferHash` reported, created more than 16 minutes and less than 3 days ago, not Moonbeam-dependent. Complete-presign, blueprint-binding, flow-identity, and Moonbeam-retirement validation still apply. `fundEphemeral` verifies the reported hash against the issued blueprint on-chain (receipt sender, target, calldata, value) before any platform funding, so a bogus or tampered hash fails the ramp with no spend. Late execution is bounded: normally about 16-21 minutes after registration, up to three days when start attempts fail and retry, and always inside the presigned Nabla swap's one-week deadline. The swap carries the on-chain 5% hard minimum output (`AMM_MINIMUM_OUTPUT_HARD_MARGIN`) and is dry-run before broadcast, and the pre/post-swap subsidy caps still apply, so price drift beyond them pauses the ramp instead of paying. Not covered: a user who broadcasts after the deadline, or whose hash is never reported, leaves funds on the ephemeral for manual recovery because nothing records that the transaction exists. | +| **Funded SELL ramp never started** | An EVM SELL user broadcasts the source transaction and reports its hash inside the start window (for example after a slow Ethereum receipt wait), but `/v1/ramp/start` is called after the window or never. The bridged USDC sits on the ephemeral while the ramp stays `initial`, which no other worker advances. | `RampRecoveryWorker` starts the ramp through `RampService.recoverFundedSellRamp`, which skips only the deadline check. Eligibility: `initial`, type SELL, this flow variant, EVM source (AssetHub SELLs are excluded), non-domestic (AlfredPay SELLs are excluded), `squidRouterSwapHash` or `squidRouterNoPermitTransferHash` reported, created more than 16 minutes and less than 3 days ago, not Moonbeam-dependent. Complete-presign, blueprint-binding, flow-identity, and Moonbeam-retirement validation still apply. `fundEphemeral` verifies the reported hash against the issued blueprint on-chain (receipt sender, target, calldata, value) before any platform funding, so a bogus or tampered hash fails the ramp with no spend. Late execution is bounded: normally about 16-21 minutes after registration, up to three days when start attempts fail and retry, and always inside the presigned Nabla swap's one-week deadline. The swap carries the on-chain 5% hard minimum output (`AMM_MINIMUM_OUTPUT_HARD_MARGIN`) and is dry-run before broadcast, and the pre/post-swap subsidy caps still apply, so price drift beyond them pauses the ramp instead of paying. Not covered: a user who broadcasts after the deadline, or whose hash is never reported, leaves funds on the ephemeral for manual recovery because nothing records that the transaction exists. | | **User-wallet funding broadcast after the start deadline** | A widget user leaves a wallet prompt open, or resumes a restored signing session, past `RAMP_START_EXPIRATION_TIME_SECONDS`, then broadcasts the transfer or Squid swap that moves funds to the ephemeral. The API refuses the late update/start, so the hash is never recorded and the funds strand on the ephemeral with only manual recovery. | `apps/frontend/src/machines/actors/sign.actor.ts` refuses each user-wallet broadcast (`squidRouter*`, `assethubToPendulum`) once less than 4 minutes remain before the ramp's `expiresAt`, and tells the user the funds were not sent. EIP-712 permits are not guarded: they move nothing until Vortex executes them after a successful start. Residual: the check runs before each wallet prompt, so a single prompt left open across the deadline can still broadcast. | | **Direct API ramp mutation during planned downtime** | A partner bypasses the UI maintenance state and calls register/update/start while operators expect Vortex services to be paused. | Ramp mutation routes run the backend maintenance guard and return `503` with `Retry-After`, `maintenance_start`, and `maintenance_end` before registration, presigned transaction updates, or phase processing begins. | | **Cross-chain race condition** | XCM transfer submitted but not finalized. Next phase on destination chain reads a zero balance. | Most XCM handlers use `waitForFinalization=true`. Exception: Hydration skips finalization (F-009, deferred). | diff --git a/docs/security-spec/03-ramp-engine/transaction-validation.md b/docs/security-spec/03-ramp-engine/transaction-validation.md index 988ed2a28..249735e87 100644 --- a/docs/security-spec/03-ramp-engine/transaction-validation.md +++ b/docs/security-spec/03-ramp-engine/transaction-validation.md @@ -86,7 +86,7 @@ The two layers together guarantee that the client cannot (a) sneak a malicious p - [x] Onramp-specific validation checks quote amounts and integration-specific fields; Monerium registration derives provider identity and its self-transfer validates the exact permit and `transferFrom` payloads - [x] Polygon `uniswapApprove` and `uniswapSwap` transactions use generic signed-EVM blueprint validation plus exact decoded route validation before persistence and again before broadcast. - [x] Offramp-specific validation (`validateOfframpQuote`, `validateBRLOfframp`) checks quote consistency -- [x] `RAMP_START_EXPIRATION_TIME_SECONDS` enforces a time window between registration and start on the public `updateRamp`/`startRamp` routes — prevents stale presigned transactions from being executed. Two worker-only starts skip this one check and keep every other `startRamp` validation: `recoverPaidAveniaRamp` (provider-confirmed `PAID` Avenia ticket) and `recoverFundedSellRamp` (non-domestic SELL with a reported source-transaction hash, verified on-chain by `fundEphemeral` before any platform spend; see `ramp-phase-flows.md`). +- [x] `RAMP_START_EXPIRATION_TIME_SECONDS` enforces a time window between registration and start on the public `updateRamp`/`startRamp` routes — prevents stale presigned transactions from being executed. Two worker-only starts skip this one check and keep every other `startRamp` validation: `recoverPaidAveniaRamp` (provider-confirmed `PAID` Avenia ticket) and `recoverFundedSellRamp` (non-domestic EVM SELL with a reported source-transaction hash, verified on-chain by `fundEphemeral` before any platform spend; see `ramp-phase-flows.md`). - [x] Default rejection for unrecognized phases — `getTransactionTypeForPhase` throws instead of defaulting to EVM (see F-047) - [ ] **F-055**: Backup presigned transactions (`backupApprove`) use unlimited `maxUint256` ERC-20 approval amount — excessive blast radius if funding key is compromised. - [ ] **F-056**: `sandboxEnabled` bypasses chainId validation in `validateEvmTransaction` and skips entire ramp flow in `initial-phase-handler` — no production guard prevents accidental activation. From 8c3bd4447cd013d20c5c14c32ec84a17d915904a Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 1 Oct 2026 18:48:32 +0200 Subject: [PATCH 6/6] fix(api): count failed recovery attempts in the worker summary Each attempt catches its own error and resolves, so Promise.allSettled marked every failure as fulfilled and the summary always logged zero failures. --- apps/api/src/api/workers/ramp-recovery.worker.test.ts | 6 +++++- apps/api/src/api/workers/ramp-recovery.worker.ts | 3 ++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/api/src/api/workers/ramp-recovery.worker.test.ts b/apps/api/src/api/workers/ramp-recovery.worker.test.ts index c017eb5da..bc0db34f8 100644 --- a/apps/api/src/api/workers/ramp-recovery.worker.test.ts +++ b/apps/api/src/api/workers/ramp-recovery.worker.test.ts @@ -1,6 +1,7 @@ -import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; +import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"; import { EPaymentMethod, Networks, RampDirection } from "@vortexfi/shared"; import { Op } from "sequelize"; +import logger from "../../config/logger"; import { config } from "../../config/vars"; import RampState from "../../models/rampState.model"; import phaseProcessor from "../services/phases/phase-processor"; @@ -114,9 +115,12 @@ describe("RampRecoveryWorker funded SELL start", () => { throw new Error("database unavailable"); }); + const info = spyOn(logger, "info"); await runWorker(); await runWorker(); + expect(info).toHaveBeenCalledWith("Ramp recovery attempt completed. Successful: 0, Failed: 1"); + info.mockRestore(); expect(appendErrorLog).toHaveBeenCalledTimes(2); expect(appendErrorLog.mock.calls[0]?.[0]).toBe("funded-sell-ramp"); expect(appendErrorLog.mock.calls[0]?.[1]).toMatchObject({ error: "database unavailable", phase: "initial" }); diff --git a/apps/api/src/api/workers/ramp-recovery.worker.ts b/apps/api/src/api/workers/ramp-recovery.worker.ts index ad2f782e8..effa648da 100644 --- a/apps/api/src/api/workers/ramp-recovery.worker.ts +++ b/apps/api/src/api/workers/ramp-recovery.worker.ts @@ -140,7 +140,8 @@ class RampRecoveryWorker { const results = await Promise.allSettled(recoveryPromises); // Log summary of results - const successfulRecoveries = results.filter(r => r.status === "fulfilled").length; + // Each attempt catches its own error and resolves with its outcome in `value.status`. + const successfulRecoveries = results.filter(r => r.status === "fulfilled" && r.value.status === "fulfilled").length; const failedRecoveries = results.length - successfulRecoveries; logger.info(`Ramp recovery attempt completed. Successful: ${successfulRecoveries}, Failed: ${failedRecoveries}`); } catch (error) {