Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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(presignChecksPass: boolean) {
const rampState = makeRampState(false, "initial");
rampState.type = RampDirection.SELL;
rampState.unsignedTxs = [ephemeralTx, userWalletTx];
rampState.state = makeStateMetadata({ evmEphemeralAddress: EVM_EPHEMERAL, presignChecksPass });
return rampState;
}

function makeStateMetadata(overrides: Partial<StateMetadata>): StateMetadata {
return {
assethubToPendulumHash: "",
Expand Down Expand Up @@ -189,4 +208,20 @@ 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(false));

const status = await service.getRampStatus("ramp-1", true);

expect(status?.unsignedTxs).toEqual([ephemeralTx]);
});

it("releases SELL user-wallet txs once presign checks have passed", async () => {
const service = new TestRampService(makeSellRampState(true));

const status = await service.getRampStatus("ramp-1", true);

expect(status?.unsignedTxs).toEqual([ephemeralTx, userWalletTx]);
});
Comment thread
Copilot marked this conversation as resolved.
Outdated
});
7 changes: 6 additions & 1 deletion apps/api/src/api/services/ramp/ramp.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -805,7 +805,12 @@ 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.state.presignChecksPass || (await this.ephemeralPresignChecksPass(rampState))
)
Comment thread
Copilot marked this conversation as resolved.
})
};

return response;
Expand Down
4 changes: 2 additions & 2 deletions docs/security-spec/03-ramp-engine/transaction-validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
Loading