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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 17 additions & 10 deletions apps/api/src/api/services/phases/handlers/fund-ephemeral-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,16 +124,23 @@ export class FundEphemeralPhaseHandler extends BasePhaseHandler {
return;
}

const hasSquidApproveBlueprint = state.unsignedTxs.some(tx => tx.phase === "squidRouterApprove");
if (!hasSquidApproveBlueprint) return;

await verifyUserSubmittedTxByHash({
fromNetwork,
hash: state.state.squidRouterApproveHash as `0x${string}` | undefined,
label: "User squidRouter approve",
presignedPhase: "squidRouterApprove",
state
});
const hasSquidSwapBlueprint = state.unsignedTxs.some(tx => tx.phase === "squidRouterSwap");
if (!hasSquidSwapBlueprint) return;

// The approve hash is optional: users whose wallet already holds a sufficient allowance
// for the squid router skip the approve tx entirely and only broadcast the swap. When a
// hash IS reported we still verify it against the blueprint; the swap hash — the tx that
// actually delivers tokens to our ephemeral — is always required.
const approveHash = state.state.squidRouterApproveHash as `0x${string}` | undefined;
if (approveHash) {
await verifyUserSubmittedTxByHash({
fromNetwork,
hash: approveHash,
label: "User squidRouter approve",
presignedPhase: "squidRouterApprove",
state
});
}
await verifyUserSubmittedTxByHash({
fromNetwork,
hash: state.state.squidRouterSwapHash as `0x${string}` | undefined,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,64 @@ describe("BRL offramp cross-chain corridor (USDC on Polygon → Base → pix via
60000
);

it(
"pre-existing allowance: the ramp completes when only the swap hash is reported (no approve hash)",
async () => {
const setup = await setUpRegisteredRamp({ reportHashes: false });
scriptHappyWorld(setup);

// The user's wallet already held a sufficient allowance for the squid
// router, so no approve tx was submitted — only the swap hash arrives.
const rampState = await RampState.findByPk(setup.rampId);
await rampState?.update({
state: { ...rampState?.state, squidRouterSwapHash: setup.swapHash }
});

await phaseProcessor.processRamp(setup.rampId);

const final = await RampState.findByPk(setup.rampId);
expect(final?.currentPhase).toBe("complete");
expect(final?.phaseHistory.map(entry => entry.phase)).toEqual(HAPPY_PATH_PHASES);
expect(final?.processingLock).toEqual({ locked: false, lockedAt: null });
expect(world.evm.erc20Balance(Networks.Base, BRLA_ON_BASE, world.brla.subaccountEvmWallet)).toBe(setup.swapOutputRaw);
},
30000
);

it(
"security: a reported approve hash whose calldata differs from the blueprint still fails the ramp",
async () => {
const setup = await setUpRegisteredRamp({ reportHashes: false });
scriptHappyWorld(setup);

// The approve hash is optional, but when one IS reported it must still
// match the blueprint — relaxing the presence check must not disable
// content verification.
const approveTxData = setup.approveBlueprint.txData as unknown as { to: `0x${string}`; value?: string };
const tamperedHash = world.evm.broadcastUserTransaction(Networks.Polygon, setup.userWallet.address, {
data: "0xdeadbeef",
to: approveTxData.to,
value: BigInt(approveTxData.value ?? "0")
});
const rampState = await RampState.findByPk(setup.rampId);
await rampState?.update({
state: { ...rampState?.state, squidRouterApproveHash: tamperedHash, squidRouterSwapHash: setup.swapHash }
});

await phaseProcessor.processRamp(setup.rampId);

const final = await RampState.findByPk(setup.rampId);
expect(final?.currentPhase).toBe("failed");
expect(final?.phaseHistory.map(entry => entry.phase)).not.toContain("complete");
expect(final?.processingLock).toEqual({ locked: false, lockedAt: null });
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);
},
30000
);

it(
"security regression (F-021 class): a reported swap hash whose calldata differs from the blueprint fails the ramp",
async () => {
Expand Down
4 changes: 2 additions & 2 deletions docs/api/openapi/vortex.openapi.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -635,7 +635,7 @@ export interface paths {
* The signed counterpart of the initial unsignedTxs object must be provided for all ramps, as required by the object.
* For offramps, the `additionalData` field must contain the confirmation hash corresponding to the inital transaction in which the user sends the funds.
* If the originating chain is `Assethub`, then `assetHubToPendulumHash` must be provided.
* If the originating chain is any `EVM` chain, then `squidRouterApproveHash` and `squidRouterSwapHash` must be provided.
* If the originating chain is any `EVM` chain, then `squidRouterSwapHash` must be provided. `squidRouterApproveHash` is only required when an approval transaction was actually submitted; if the wallet already holds a sufficient allowance for the router, it can be omitted.
*
* For onramps, no additional data is required after registering the ramp.
*/
Expand Down Expand Up @@ -1649,7 +1649,7 @@ export interface components {
assetHubToPendulumHash?: string | null;
/** @description Signed message to trigger a Monerium offramp. */
moneriumOfframpSignature: string;
/** @description Transaction hash for Squid Router approval, if applicable. */
/** @description Transaction hash for Squid Router approval. Optional: omit when the wallet already holds a sufficient allowance and no approval transaction was submitted. */
squidRouterApproveHash?: string | null;
/** @description Transaction hash for Squid Router swap, if applicable. */
squidRouterSwapHash?: string | null;
Expand Down
4 changes: 2 additions & 2 deletions docs/api/openapi/vortex.openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -1272,7 +1272,7 @@
"type": "string"
},
"squidRouterApproveHash": {
"description": "Transaction hash for Squid Router approval, if applicable.",
"description": "Transaction hash for Squid Router approval. Optional: omit when the wallet already holds a sufficient allowance and no approval transaction was submitted.",
"type": ["string", "null"]
},
"squidRouterSwapHash": {
Expand Down Expand Up @@ -3625,7 +3625,7 @@
"/v1/ramp/update": {
"post": {
"deprecated": false,
"description": "Submits presigned transactions and additional data to an existing ramp process before starting it. \nThis endpoint can be called many times, and data can be incrementally added to the ramp. \n\nNote: For both pre-signed transactions and the generic `additionalData` object, existing properties will be overriden by new values.\n\n### Required data for ramps.\nThe signed counterpart of the initial unsignedTxs object must be provided for all ramps, as required by the object.\nFor offramps, the `additionalData` field must contain the confirmation hash corresponding to the inital transaction in which the user sends the funds. \nIf the originating chain is `Assethub`, then `assetHubToPendulumHash` must be provided. \nIf the originating chain is any `EVM` chain, then `squidRouterApproveHash` and `squidRouterSwapHash` must be provided. \n\nFor onramps, no additional data is required after registering the ramp.",
"description": "Submits presigned transactions and additional data to an existing ramp process before starting it. \nThis endpoint can be called many times, and data can be incrementally added to the ramp. \n\nNote: For both pre-signed transactions and the generic `additionalData` object, existing properties will be overriden by new values.\n\n### Required data for ramps.\nThe signed counterpart of the initial unsignedTxs object must be provided for all ramps, as required by the object.\nFor offramps, the `additionalData` field must contain the confirmation hash corresponding to the inital transaction in which the user sends the funds. \nIf the originating chain is `Assethub`, then `assetHubToPendulumHash` must be provided. \nIf the originating chain is any `EVM` chain, then `squidRouterSwapHash` must be provided. `squidRouterApproveHash` is only required when an approval transaction was actually submitted; if the wallet already holds a sufficient allowance for the router, it can be omitted. \n\nFor onramps, no additional data is required after registering the ramp.",
"operationId": "updateRamp",
"parameters": [],
"requestBody": {
Expand Down
6 changes: 3 additions & 3 deletions docs/security-spec/03-ramp-engine/transaction-validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ User-wallet phases:
**Layer 2 — Phase handlers verify the user-reported tx hash by reading the on-chain receipt and transaction**, then comparing against the server-issued unsigned payload (`txData.to`, `txData.data`, `txData.value`, and `signer`) plus receipt status. The shared helper is `verifyUserSubmittedTxByHash` in `apps/api/src/api/services/phases/helpers/user-tx-verifier.ts`. It is invoked from:

- `squidrouter-permit-execution-handler.ts` → `waitForUserHash` — covers `squidRouterNoPermit{Approve,Swap,Transfer}` during the permit-execution phase.
- `fund-ephemeral-handler.ts` → `verifyUserSubmittedSquidHashes` — covers SELL standard EVM `squidRouterApprove` + `squidRouterSwap` at the top of `executePhase`, gated on `SELL && from!==AssetHub && !isAlfredpayToken(outputCurrency) && isNetworkEVM(from)`. This closes the historical F-041 gap (SELL squid runtime validation).
- `fund-ephemeral-handler.ts` → `verifyUserSubmittedSquidHashes` — covers SELL standard EVM `squidRouterApprove` + `squidRouterSwap` at the top of `executePhase`, gated on `SELL && from!==AssetHub && !isAlfredpayToken(outputCurrency) && isNetworkEVM(from)`. This closes the historical F-041 gap (SELL squid runtime validation). `squidRouterSwapHash` is mandatory — the phase parks recoverably until it is reported. `squidRouterApproveHash` is optional: a user whose wallet already holds a sufficient router allowance never broadcasts the approve tx, so its absence must not block the ramp. When an approve hash IS reported, it is verified against the blueprint with the same rigor (receipt status, `from`, `to`, calldata, value). Skipping an approve that was actually needed is safe: the swap's `transferFrom` reverts on-chain, so the swap hash verification fails on receipt status.

The two layers together guarantee that the client cannot (a) sneak a malicious presigned tx through validation by labeling it with a user-wallet phase, nor (b) point the backend at an arbitrary on-chain tx hash that does not match the server-issued payload.

Expand All @@ -59,7 +59,7 @@ The two layers together guarantee that the client cannot (a) sneak a malicious p
| **EIP-712 permit exploitation** | Client submits an EIP-712 permit that authorizes an attacker's spender address for unlimited token allowance. | **MITIGATED (F-038)**: Signed typed data is deep-compared against the server-issued unsigned typed data (`domain`, `primaryType`, `types`, `message`) before signature recovery, so spender/token/value/deadline/verifyingContract substitutions are rejected. |
| **Stellar account setup manipulation** | Client omits the server cosigner in SetOptions, or sets a tiny startingBalance, or adds trust for a worthless token. Current validation enforces operation count/order and required fields but does not bind the exact cosigner, startingBalance threshold, or ChangeTrust asset to expected quote/server values. | **OPEN (F-040)**: Validate startingBalance against minimum required, verify SetOptions includes the server cosigner public key, and verify ChangeTrust asset matches the expected ramp asset. |
| **Substrate extrinsic substitution** | Client submits a different Substrate extrinsic (e.g., `balances.transferAll` to an attacker) instead of the expected swap or XCM call. Current validation checks signer and method decodability, but not expected section/method/arguments. | **OPEN (F-042)**: Decode the extrinsic and validate method name, call parameters, amounts, and destination addresses. |
| **Off-ramp SquidRouter bypass** | SELL-direction ramps previously skipped SquidRouter swap/approve validation entirely. Client could submit a swap routing funds to an attacker's EVM address. | **MITIGATED (F-041)**: SELL-direction `squidRouterApprove`/`squidRouterSwap` are now (a) rejected by `validatePresignedTxs` if a presigned tx is submitted for them, and (b) verified by-hash at the top of `FundEphemeralPhaseHandler.executePhase` via `verifyUserSubmittedSquidHashes` against the server-issued `to`/`data`/`value`/`signer`. |
| **Off-ramp SquidRouter bypass** | SELL-direction ramps previously skipped SquidRouter swap/approve validation entirely. Client could submit a swap routing funds to an attacker's EVM address. | **MITIGATED (F-041)**: SELL-direction `squidRouterApprove`/`squidRouterSwap` are now (a) rejected by `validatePresignedTxs` if a presigned tx is submitted for them, and (b) verified by-hash at the top of `FundEphemeralPhaseHandler.executePhase` via `verifyUserSubmittedSquidHashes` against the server-issued `to`/`data`/`value`/`signer`. The swap hash is mandatory; the approve hash is verified only when reported (pre-existing allowances make the approve tx optional — an unapproved swap fails its own on-chain receipt check). |
| **User-wallet phase presigned-tx smuggling** | Client submits an unrelated EVM/Substrate/Stellar presigned tx labeled with a user-wallet phase name (`moneriumOnrampMint`, `squidRouterApprove`/`Swap` for SELL, `squidRouterNoPermit*`). Previously `validatePresignedTxs` `continue`d on these phases, letting the tx through without content validation. | **MITIGATED**: `validatePresignedTxs` now throws `APIError(BAD_REQUEST)` for any presigned tx whose phase is in the user-wallet set. User-wallet phases are verified by on-chain hash + receipt + calldata only. |
| **Transaction data substitution via metadata matching** | Client submits transactions with correct phase/network/nonce/signer metadata but different txData content. | **MITIGATED (F-043)**: `validatePresignedTxs` resolves the matching unsigned transaction by the same identity keys and performs content validation before `areAllTxsIncluded` is used as the final inclusion guard. |
| **EVM contract target or execution-parameter substitution** | Client signs a raw EVM transaction to an attacker-controlled contract, or signs the expected transaction with gas/fee parameters too low to execute reliably. | **MITIGATED (F-050)**: Raw signed EVM transactions are recovered and compared to the server-issued unsigned `to`, `data`, `value`, and `nonce`; gas limit and fee caps must be at least the server-issued values, and contract-creation transactions are rejected. |
Expand All @@ -71,7 +71,7 @@ The two layers together guarantee that the client cannot (a) sneak a malicious p
- [x] **F-038**: EVM typed data (`SignedTypedData` / `SignedTypedDataArray`) is bound to the server-issued unsigned typed data and the recovered signer.
- [EXISTING FINDING] **F-039**: Stellar payment validation checks shape, source, destination presence, positive amount, asset presence, and operation count, but NOT quote-bound amount, destination, or asset identity.
- [EXISTING FINDING] **F-040**: Stellar `createAccount` validation checks operation count/order and required fields, but NOT exact startingBalance threshold, expected SetOptions cosigner, or expected ChangeTrust asset.
- [x] **F-041**: SELL-direction `squidRouterApprove`/`squidRouterSwap` are rejected at `validatePresignedTxs` and verified by on-chain hash + receipt + calldata via `verifyUserSubmittedSquidHashes` at the top of `FundEphemeralPhaseHandler.executePhase`.
- [x] **F-041**: SELL-direction `squidRouterApprove`/`squidRouterSwap` are rejected at `validatePresignedTxs` and verified by on-chain hash + receipt + calldata via `verifyUserSubmittedSquidHashes` at the top of `FundEphemeralPhaseHandler.executePhase`. The swap hash is required; the approve hash is optional (pre-existing allowance) but content-verified whenever reported.
- [EXISTING FINDING] **F-042**: Substrate transaction validation checks signer and decodable method, but NOT expected method, parameters, amounts, or destinations.
- [x] **F-043**: `areAllTxsIncluded` remains metadata-only, but content substitution is blocked earlier by identity-keyed unsigned transaction lookup plus per-format content validation.
- [x] **F-047**: `getTransactionTypeForPhase` throws on unknown phases instead of defaulting to EVM.
Expand Down
Loading