diff --git a/apps/api/src/api/services/ramp/ramp.service.get-ramp-status.test.ts b/apps/api/src/api/services/ramp/ramp.service.get-ramp-status.test.ts index 8348e830d..14d31f2f6 100644 --- a/apps/api/src/api/services/ramp/ramp.service.get-ramp-status.test.ts +++ b/apps/api/src/api/services/ramp/ramp.service.get-ramp-status.test.ts @@ -1,5 +1,5 @@ import { afterAll, describe, expect, it, mock } from "bun:test"; -import { EPaymentMethod, FiatToken, Networks, RampDirection, RampPhase } from "@vortexfi/shared"; +import { EPaymentMethod, FiatToken, Networks, RampDirection, RampPhase, UnsignedTx } from "@vortexfi/shared"; import { config } from "../../../config/vars"; import QuoteTicket from "../../../models/quoteTicket.model"; import RampState from "../../../models/rampState.model"; @@ -94,6 +94,25 @@ function makeRampState(onHold: boolean, currentPhase: RampPhase = "brlaOnrampMin }); } +const EVM_EPHEMERAL = "0x3333333333333333333333333333333333333333"; +const ephemeralTx: UnsignedTx = { meta: {}, network: Networks.Base, nonce: 0, phase: "distributeFees", signer: EVM_EPHEMERAL, txData: "0x" }; +const userWalletTx: UnsignedTx = { + meta: {}, + network: Networks.Base, + nonce: 0, + phase: "squidRouterPermitExecute", + signer: "0x4444444444444444444444444444444444444444", + txData: "0x" +}; + +function makeSellRampState() { + const rampState = makeRampState(false, "initial"); + rampState.type = RampDirection.SELL; + rampState.unsignedTxs = [ephemeralTx, userWalletTx]; + rampState.state = makeStateMetadata({ evmEphemeralAddress: EVM_EPHEMERAL, presignChecksPass: false }); + return rampState; +} + function makeStateMetadata(overrides: Partial): StateMetadata { return { assethubToPendulumHash: "", @@ -189,4 +208,33 @@ describe("RampService.getRampStatus", () => { expect(status?.currentPhase).toBe("fundEphemeral"); }); + + it("withholds SELL user-wallet txs while ephemeral presigned txs are missing", async () => { + const service = new TestRampService(makeSellRampState()); + + const status = await service.getRampStatus("ramp-1", true); + + expect(status?.unsignedTxs).toEqual([ephemeralTx]); + }); + + it("releases SELL user-wallet txs once the ephemeral presigned txs validate", async () => { + const service = new TestRampService(makeSellRampState()); + Object.assign(service, { ephemeralPresignChecksPass: mock(async () => true) }); + + const status = await service.getRampStatus("ramp-1", true); + + expect(status?.unsignedTxs).toEqual([ephemeralTx, userWalletTx]); + }); + + it("skips presign validation for BUY ramps, which the gate does not filter", async () => { + const rampState = makeRampState(false); + rampState.state = makeStateMetadata({ presignChecksPass: false }); + const service = new TestRampService(rampState); + const ephemeralPresignChecksPass = mock(async () => false); + Object.assign(service, { ephemeralPresignChecksPass }); + + await service.getRampStatus("ramp-1", true); + + expect(ephemeralPresignChecksPass).not.toHaveBeenCalled(); + }); }); diff --git a/apps/api/src/api/services/ramp/ramp.service.ts b/apps/api/src/api/services/ramp/ramp.service.ts index 946107709..fc5968e62 100644 --- a/apps/api/src/api/services/ramp/ramp.service.ts +++ b/apps/api/src/api/services/ramp/ramp.service.ts @@ -805,7 +805,14 @@ export class RampService extends BaseRampService { vortexFeeFiat: fiatFees.vortex, vortexFeeUsd: usdFees.vortex, walletAddress: rampState.state.destinationAddress || rampState.state.walletAddress, - ...(showUnsignedTxs && { unsignedTxs: rampState.unsignedTxs }) + ...(showUnsignedTxs && { + unsignedTxs: filterUnsignedTxsForResponse( + rampState, + rampState.type !== RampDirection.SELL || + rampState.state.presignChecksPass || + (await this.ephemeralPresignChecksPass(rampState)) + ) + }) }; return response; diff --git a/docs/security-spec/03-ramp-engine/transaction-validation.md b/docs/security-spec/03-ramp-engine/transaction-validation.md index 398ffd9e7..558327f88 100644 --- a/docs/security-spec/03-ramp-engine/transaction-validation.md +++ b/docs/security-spec/03-ramp-engine/transaction-validation.md @@ -14,7 +14,7 @@ The signed-transaction validation logic lives in `apps/api/src/api/services/tran Two mechanisms control what the client sees and when: -1. **Partitioning + filtering**: `ramp.service.ts` calls `partitionUnsignedTxs(rampState)` to split presigned txs into ephemeral-signed (server-cosigned) and user-signed buckets. For SELL ramps, `filterUnsignedTxsForResponse(rampState, ephemeralPresignChecksPass)` then withholds the user-wallet txs from the SDK response until the server has validated all ephemeral presigned signatures. This prevents the SDK / client from starting the user's source-of-funds transfer before every ephemeral tx the backend needs has been presigned and verified. `ephemeralPresignChecksPass` validates with `{ requireUserTypedData: false }`: it requires every ephemeral-signed transaction but not the user's own typed data (for example the SELL `squidRouterPermitExecute` permit), because that typed data is among the user-wallet transactions the gate releases for signing. +1. **Partitioning + filtering**: `ramp.service.ts` calls `partitionUnsignedTxs(rampState)` to split presigned txs into ephemeral-signed (server-cosigned) and user-signed buckets. For SELL ramps, `filterUnsignedTxsForResponse(rampState, ephemeralPresignChecksPass)` then withholds the user-wallet txs from every response that returns `unsignedTxs` (register, update, and `GET /v1/ramp/{id}?showUnsignedTxs=true`) until the server has validated all ephemeral presigned signatures. This prevents the SDK / client from starting the user's source-of-funds transfer before every ephemeral tx the backend needs has been presigned and verified. `ephemeralPresignChecksPass` validates with `{ requireUserTypedData: false }`: it requires every ephemeral-signed transaction but not the user's own typed data (for example the SELL `squidRouterPermitExecute` permit), because that typed data is among the user-wallet transactions the gate releases for signing. 2. **Deposit-QR gating**: For BRL on-ramp, `state.depositQrCode` is only released to the client after `ephemeralPresignChecksPass === true`. This guarantees the user cannot make a PIX payment before the server has confirmed the ephemeral signature chain is valid (i.e., before all presigned txs needed to settle the deposit have been verified). ### User-Submitted Transaction Phases @@ -92,7 +92,7 @@ The two layers together guarantee that the client cannot (a) sneak a malicious p - [ ] **F-056**: `sandboxEnabled` bypasses chainId validation in `validateEvmTransaction` and skips entire ramp flow in `initial-phase-handler` — no production guard prevents accidental activation. - [x] **F-057**: `destinationTransfer` decodes native transfers and ERC-20 `transfer` calldata and verifies the recipient matches `state.destinationAddress` before broadcasting. - [ ] **F-058**: No per-presigned-transaction TTL after ramp starts — `getPresignedTransaction` performs no age check, presigned txs remain valid indefinitely through recovery retries. -- [x] Presigned-tx partitioning via `partitionUnsignedTxs` + `filterUnsignedTxsForResponse`. **PASS** — SELL user-wallet txs withheld from the SDK response until `ephemeralPresignChecksPass` flips true. +- [x] Presigned-tx partitioning via `partitionUnsignedTxs` + `filterUnsignedTxsForResponse`. **PASS** — SELL user-wallet txs withheld from the register, update, and `showUnsignedTxs` status responses until `ephemeralPresignChecksPass` flips true. - [x] Deposit QR code (BRL onramp) gated on `ephemeralPresignChecksPass`. **PASS** — verified in `meta-state-types.ts`. - [x] Signed presigned transaction matching accepts the production signer's bounded fee multiplier while still binding EVM raw transactions to the unsigned server-built `to`/`data`/`value`/`nonce`/gas envelope, and typed-data payloads to the unsigned typed-data content with signatures stripped for comparison. - [x] **No-permit fallback receipt validation hardened**: `waitForUserHash` verifies receipt `from`, receipt `to`, and transaction `input` against the expected user address and presigned EVM transaction payload before advancing.