From ccca148008f0421ccb0588bbf63d950c51981749 Mon Sep 17 00:00:00 2001 From: akash Date: Thu, 28 May 2026 19:05:22 +0530 Subject: [PATCH 1/4] feat: added calldata executor and bridge receiver --- scripts/deploy/checkReceiverDeployment.ts | 111 ++++++ scripts/deploy/create3.ts | 72 ++++ scripts/deploy/deployReceiverAndExecutor.ts | 175 +++++++++ src/BungeeReceiver.sol | 399 ++++++++++++++++++++ src/CalldataExecutor.sol | 37 ++ src/interfaces/IBungeeExecutor.sol | 20 + src/interfaces/ICalldataExecutor.sol | 14 + src/lib/ExcessivelySafeCall.sol | 42 +++ 8 files changed, 870 insertions(+) create mode 100644 scripts/deploy/checkReceiverDeployment.ts create mode 100644 scripts/deploy/deployReceiverAndExecutor.ts create mode 100644 src/BungeeReceiver.sol create mode 100644 src/CalldataExecutor.sol create mode 100644 src/interfaces/IBungeeExecutor.sol create mode 100644 src/interfaces/ICalldataExecutor.sol create mode 100644 src/lib/ExcessivelySafeCall.sol diff --git a/scripts/deploy/checkReceiverDeployment.ts b/scripts/deploy/checkReceiverDeployment.ts new file mode 100644 index 0000000..e4a49f7 --- /dev/null +++ b/scripts/deploy/checkReceiverDeployment.ts @@ -0,0 +1,111 @@ +/** + * Checks that BungeeReceiver and CalldataExecutor are deployed on the current network. + * Addresses are computed deterministically from the deployer address and CREATE3 salts — + * no hardcoded address constants needed. + * + * Usage: + * npx hardhat run scripts/deploy/checkReceiverDeployment.ts --network + * + * Required env vars: + * DEPLOYER_ADDRESS — address used to deploy the contracts (determines CREATE3 addresses) + * + * Optional env vars: + * OWNER_ADDRESS — if set, assert BungeeReceiver owner() matches this address + */ + +import hre from 'hardhat'; +import { ethers } from 'hardhat'; +import { Contract } from 'ethers'; +import { + CREATE_X_FACTORY, + Create3ABI, + BUNGEE_RECEIVER_CREATE3_SALT, + CALLDATA_EXECUTOR_CREATE3_SALT, + getBungeeReceiverDeploymentStatus, + getCalldataExecutorDeploymentStatus, +} from './create3'; + +async function main() { + const networkName = hre.network.name; + const { chainId } = await ethers.provider.getNetwork(); + + const deployerAddress = process.env.DEPLOYER_ADDRESS?.trim(); + if (!deployerAddress) { + console.error('DEPLOYER_ADDRESS env var is required'); + process.exit(1); + } + + const create3Factory = new Contract( + CREATE_X_FACTORY, + Create3ABI, + ethers.provider, + ); + + const receiverAddress = (await create3Factory.computeCreate3Address( + BUNGEE_RECEIVER_CREATE3_SALT, + deployerAddress, + )) as string; + + const executorAddress = (await create3Factory.computeCreate3Address( + CALLDATA_EXECUTOR_CREATE3_SALT, + deployerAddress, + )) as string; + + let hasError = false; + + // ── Check CalldataExecutor ─────────────────────────────────────────────────── + + const executorStatus = await getCalldataExecutorDeploymentStatus({ + provider: ethers.provider, + address: executorAddress, + }); + + if (!executorStatus.deployed) { + console.error( + `CalldataExecutor NOT deployed on ${networkName} (chainId=${chainId}) at ${executorAddress}`, + ); + hasError = true; + } else { + console.log( + `CalldataExecutor deployed on ${networkName} (chainId=${chainId}) at ${executorAddress}, BUNGEE_RECEIVER=${executorStatus.bungeeReceiver}`, + ); + } + + // ── Check BungeeReceiver ───────────────────────────────────────────────────── + + const receiverStatus = await getBungeeReceiverDeploymentStatus({ + provider: ethers.provider, + address: receiverAddress, + }); + + if (!receiverStatus.deployed) { + console.error( + `BungeeReceiver NOT deployed on ${networkName} (chainId=${chainId}) at ${receiverAddress}`, + ); + hasError = true; + } else { + const expectedOwner = process.env.OWNER_ADDRESS?.trim(); + if ( + expectedOwner && + receiverStatus.owner?.toLowerCase() !== expectedOwner.toLowerCase() + ) { + console.error( + `BungeeReceiver owner mismatch on ${networkName} (chainId=${chainId}): expected ${expectedOwner}, got ${receiverStatus.owner}`, + ); + hasError = true; + } else { + console.log( + `BungeeReceiver deployed on ${networkName} (chainId=${chainId}) at ${receiverAddress}, owner=${receiverStatus.owner}`, + ); + } + } + + if (hasError) { + process.exit(1); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/deploy/create3.ts b/scripts/deploy/create3.ts index 5ece942..f3e4883 100644 --- a/scripts/deploy/create3.ts +++ b/scripts/deploy/create3.ts @@ -6,11 +6,27 @@ export const CREATE_X_FACTORY = '0xba5Ed099633D3B313e4D5F7bdc1305d3c28ba5Ed'; /** CREATE3 salt label used by `deployOpenRouter.ts`. */ export const OPEN_ROUTER_CREATE3_SALT_TEXT = 'OpenRouter' + '000'; +/** CREATE3 salt label used by `deployReceiverAndExecutor.ts`. */ +export const BUNGEE_RECEIVER_CREATE3_SALT_TEXT = 'BungeeReceiver' + '000'; + +/** CREATE3 salt label used by `deployReceiverAndExecutor.ts`. */ +export const CALLDATA_EXECUTOR_CREATE3_SALT_TEXT = 'CalldataExecutor' + '000'; + /** Keccak256 salt for deterministic OpenRouter CREATE3 deployments. */ export const OPEN_ROUTER_CREATE3_SALT = keccak256( toUtf8Bytes(OPEN_ROUTER_CREATE3_SALT_TEXT), ); +/** Keccak256 salt for deterministic BungeeReceiver CREATE3 deployments. */ +export const BUNGEE_RECEIVER_CREATE3_SALT = keccak256( + toUtf8Bytes(BUNGEE_RECEIVER_CREATE3_SALT_TEXT), +); + +/** Keccak256 salt for deterministic CalldataExecutor CREATE3 deployments. */ +export const CALLDATA_EXECUTOR_CREATE3_SALT = keccak256( + toUtf8Bytes(CALLDATA_EXECUTOR_CREATE3_SALT_TEXT), +); + const ADDR_HEX_RE = /^0x[a-fA-F0-9]{40}$/; /** Observed OpenRouter CREATE3 address for salt `OpenRouter000` via canonical CreateX. */ @@ -100,3 +116,59 @@ export function decodeCreate3DeploymentFromTxReceipt(params: { return '0x' + eventData.slice(26); } + +/** + * Checks whether BungeeReceiver bytecode is present at the given address. + * When deployed, reads `owner()` to confirm the contract responds. + */ +export async function getBungeeReceiverDeploymentStatus(params: { + provider: Provider; + address: string; +}): Promise<{ address: string; deployed: boolean; owner?: string }> { + const { provider, address } = params; + const bytecode = await provider.getCode(address); + + if (!hasContractBytecode(bytecode)) { + return { address, deployed: false }; + } + + try { + const contract = new Contract( + address, + ['function owner() view returns (address)'], + provider, + ); + const owner = (await contract.owner()) as string; + return { address, deployed: true, owner }; + } catch { + return { address, deployed: false }; + } +} + +/** + * Checks whether CalldataExecutor bytecode is present at the given address. + * When deployed, reads `BUNGEE_RECEIVER()` to confirm the contract responds. + */ +export async function getCalldataExecutorDeploymentStatus(params: { + provider: Provider; + address: string; +}): Promise<{ address: string; deployed: boolean; bungeeReceiver?: string }> { + const { provider, address } = params; + const bytecode = await provider.getCode(address); + + if (!hasContractBytecode(bytecode)) { + return { address, deployed: false }; + } + + try { + const contract = new Contract( + address, + ['function BUNGEE_RECEIVER() view returns (address)'], + provider, + ); + const bungeeReceiver = (await contract.BUNGEE_RECEIVER()) as string; + return { address, deployed: true, bungeeReceiver }; + } catch { + return { address, deployed: false }; + } +} diff --git a/scripts/deploy/deployReceiverAndExecutor.ts b/scripts/deploy/deployReceiverAndExecutor.ts new file mode 100644 index 0000000..58570a2 --- /dev/null +++ b/scripts/deploy/deployReceiverAndExecutor.ts @@ -0,0 +1,175 @@ +/** + * Deploys CalldataExecutor and BungeeReceiver via CreateX CREATE3. + * + * Both contracts reference each other in their constructors (CalldataExecutor is wired with the + * receiver's address; BungeeReceiver is wired with the executor's address). We resolve this by + * pre-computing both CREATE3 addresses from the factory before deploying either contract, then + * deploying in order: CalldataExecutor first (using pre-computed receiver address), then BungeeReceiver. + * + * Usage: + * npx hardhat run scripts/deploy/deployReceiverAndExecutor.ts --network + * + * Required env vars: + * DEPLOYER_PRIVATE_KEY — deployer wallet private key + * + * Optional env vars: + * OWNER_ADDRESS — owner of BungeeReceiver (defaults to deployer) + * SOLVER_SIGNER_ADDRESS — initial SOLVER_SIGNER on BungeeReceiver (defaults to deployer) + */ + +import hre from 'hardhat'; +import { ethers } from 'hardhat'; +import { + CREATE_X_FACTORY, + Create3ABI, + BUNGEE_RECEIVER_CREATE3_SALT, + CALLDATA_EXECUTOR_CREATE3_SALT, + decodeCreate3DeploymentFromTxReceipt, + getBungeeReceiverDeploymentStatus, + getCalldataExecutorDeploymentStatus, +} from './create3'; + +async function main() { + const [deployer] = await ethers.getSigners(); + const networkName = hre.network.name; + const owner = process.env.OWNER_ADDRESS?.trim() || deployer.address; + const solverSigner = + process.env.SOLVER_SIGNER_ADDRESS?.trim() || deployer.address; + + console.log('Deployer: ', deployer.address); + console.log('Owner: ', owner); + console.log('SolverSigner: ', solverSigner); + console.log('Network: ', networkName); + console.log(''); + + const create3Factory = new ethers.Contract( + CREATE_X_FACTORY, + Create3ABI, + deployer, + ); + + // Pre-compute both CREATE3 addresses before deploying anything. + // CREATE3 address is deterministic: f(salt, deployer) — no bytecode dependency. + const receiverAddress = (await create3Factory.computeCreate3Address( + BUNGEE_RECEIVER_CREATE3_SALT, + deployer.address, + )) as string; + + const executorAddress = (await create3Factory.computeCreate3Address( + CALLDATA_EXECUTOR_CREATE3_SALT, + deployer.address, + )) as string; + + console.log('Pre-computed BungeeReceiver address: ', receiverAddress); + console.log('Pre-computed CalldataExecutor address: ', executorAddress); + console.log(''); + + // ── Deploy CalldataExecutor (wired with pre-computed receiver address) ────── + + const executorStatus = await getCalldataExecutorDeploymentStatus({ + provider: ethers.provider, + address: executorAddress, + }); + + if (executorStatus.deployed) { + console.log( + `CalldataExecutor already deployed at ${executorAddress}, BUNGEE_RECEIVER=${executorStatus.bungeeReceiver}`, + ); + } else { + const executorFactory = await ethers.getContractFactory('CalldataExecutor'); + const executorDeployTx = + await executorFactory.getDeployTransaction(receiverAddress); + + console.log('Deploying CalldataExecutor via CREATE3...'); + const executorDeployment = await create3Factory.deployCreate3( + CALLDATA_EXECUTOR_CREATE3_SALT, + executorDeployTx.data, + ); + console.log('CREATE3 deployment tx:', executorDeployment.hash); + + const executorReceipt = await executorDeployment.wait(); + const deployedExecutorAddress = decodeCreate3DeploymentFromTxReceipt({ + receipt: executorReceipt, + }); + if (!deployedExecutorAddress) { + throw new Error('CalldataExecutor address not found in CREATE3 receipt'); + } + console.log('CalldataExecutor deployed to:', deployedExecutorAddress); + } + + // ── Deploy BungeeReceiver (wired with actual executor address) ─────────────── + + const receiverStatus = await getBungeeReceiverDeploymentStatus({ + provider: ethers.provider, + address: receiverAddress, + }); + + if (receiverStatus.deployed) { + console.log( + `BungeeReceiver already deployed at ${receiverAddress}, owner=${receiverStatus.owner}`, + ); + } else { + const receiverFactory = await ethers.getContractFactory('BungeeReceiver'); + const receiverDeployTx = await receiverFactory.getDeployTransaction( + owner, + solverSigner, + executorAddress, + ); + + console.log('Deploying BungeeReceiver via CREATE3...'); + const receiverDeployment = await create3Factory.deployCreate3( + BUNGEE_RECEIVER_CREATE3_SALT, + receiverDeployTx.data, + ); + console.log('CREATE3 deployment tx:', receiverDeployment.hash); + + const receiverReceipt = await receiverDeployment.wait(); + const deployedReceiverAddress = decodeCreate3DeploymentFromTxReceipt({ + receipt: receiverReceipt, + }); + if (!deployedReceiverAddress) { + throw new Error('BungeeReceiver address not found in CREATE3 receipt'); + } + console.log('BungeeReceiver deployed to:', deployedReceiverAddress); + } + + console.log('\n=== Deployment Summary ==='); + console.log(`CalldataExecutor: ${executorAddress}`); + console.log(`BungeeReceiver: ${receiverAddress}`); + + const chainId = (await ethers.provider.getNetwork()).chainId; + if (chainId !== 31337n) { + await new Promise((resolve) => setTimeout(resolve, 5000)); + + try { + await hre.run('verify:verify', { + address: executorAddress, + constructorArguments: [receiverAddress], + }); + console.log('CalldataExecutor verified on block explorer'); + } catch (err) { + console.warn( + 'CalldataExecutor verification failed (deployment succeeded):', + err instanceof Error ? err.message : err, + ); + } + + try { + await hre.run('verify:verify', { + address: receiverAddress, + constructorArguments: [owner, solverSigner, executorAddress], + }); + console.log('BungeeReceiver verified on block explorer'); + } catch (err) { + console.warn( + 'BungeeReceiver verification failed (deployment succeeded):', + err instanceof Error ? err.message : err, + ); + } + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/BungeeReceiver.sol b/src/BungeeReceiver.sol new file mode 100644 index 0000000..15a91ea --- /dev/null +++ b/src/BungeeReceiver.sol @@ -0,0 +1,399 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity 0.8.34; + +import {SafeTransferLib} from "solady/src/utils/SafeTransferLib.sol"; + +import {AccessControl} from "./common/utils/AccessControl.sol"; +import {CurrencyLib} from "./common/lib/CurrencyLib.sol"; +import {RescueFundsLib} from "./common/lib/RescueFundsLib.sol"; +import {AuthenticationLib} from "./common/lib/AuthenticationLib.sol"; +import {RESCUE_ROLE} from "./common/AccessRoles.sol"; +import {ICalldataExecutor} from "./interfaces/ICalldataExecutor.sol"; +import {IBungeeExecutor} from "./interfaces/IBungeeExecutor.sol"; + +/// @title BungeeReceiver +/// @notice Destination-side contract for the OpenRouter system. +/// +/// Flow per execution: +/// 1. Bridge transfers output token(s) to this contract. +/// 2. Backend reads the tx receipt to get the exact received amount. +/// 3. Backend constructs and signs a `DestPayload`, then calls `executeDestPayload`. +/// 4. Contract optionally runs preActions (e.g. approve a swap router). +/// 5. Contract optionally swaps the bridged token to the desired output token, +/// measuring the actual output via balance delta. +/// 6. Output token + amount are transferred to the IBungeeExecutor target (or directly +/// to a recipient address when no protocol execution is needed). +/// 7. If a target is set, `CalldataExecutor.executeCalldata` is called, which invokes +/// `IBungeeExecutor.executeData` on the target via excessivelySafeCall. +/// +/// Security: all execution paths are gated behind an ECDSA signature from `SOLVER_SIGNER` +/// combined with a one-time nonce. Funds never leave the contract without a valid sig. +/// Arbitrary calldata execution is delegated to `CalldataExecutor` (a separate stateless +/// proxy) so that a misbehaving destination cannot affect funds still held here. +contract BungeeReceiver is AccessControl { + using SafeTransferLib for address; + + // ========================================================================= + // Structs + // ========================================================================= + + /// @dev Generic action used in `preActions` (e.g. set approvals for the swap router). + struct Action { + address target; + uint256 value; + bytes data; + } + + /// @dev Token delivered by the bridge to this contract. + struct BridgedFunds { + address token; + } + + /// @dev Optional destination swap config. Set `target == address(0)` to skip the swap. + struct DstSwapData { + address target; + /// @dev Spender to approve with max allowance before calling the swap router. + /// Set to address(0) to skip approval (e.g. native input or already approved). + address approvalSpender; + address outputToken; + /// @dev Minimum acceptable swap output; reverts if balance delta falls below this. + uint256 minOutput; + /// @dev msg.value forwarded to the swap router call (for native-token input swaps). + uint256 value; + } + + /// @dev Fee collection config. Set `feeType == NO_FEE` or `amount == 0` to skip. + struct DstFeeData { + FeeType feeType; + /// @dev Token the fee is collected in. + /// For POST_SWAP fees this should equal `DstSwapData.outputToken`. + address token; + address collector; + uint256 amount; + } + + /// @dev Final execution config: either invoke an IBungeeExecutor or forward directly to a recipient. + struct DstExecutionData { + /// @dev IBungeeExecutor target. Set to address(0) for a plain token transfer to `recipient`. + address target; + /// @dev Recipient used when `target == address(0)`. + address recipient; + /// @dev Explicit output amount for the no-swap path (net of fee). Unused when a swap is present. + uint256 outputAmount; + /// @dev Gas limit forwarded to CalldataExecutor → IBungeeExecutor call. + uint256 gasLimit; + } + + enum FeeType { + NO_FEE, + PRE_SWAP, + POST_SWAP + } + + /// @dev Single calldata struct covering every field that the SOLVER_SIGNER signs over. + /// Bundling into one struct reduces `executeDestPayload`'s stack depth: a `bytes` or array + /// field inside a calldata struct is accessed via the struct's single calldata pointer + /// (1 stack slot) rather than a separate offset+length pair (2 slots each). + struct DestPayload { + uint256 nonce; + bytes32 quoteId; + BridgedFunds funds; + Action[] preActions; + DstFeeData feeData; + DstSwapData swapData; + bytes swapCallData; + DstExecutionData execution; + bytes executionCallData; + } + + // ========================================================================= + // Errors + // ========================================================================= + + error InvalidSigner(); + error InvalidNonce(); + error InvalidExecution(); + error PreActionFailed(uint256 index); + error SwapFailed(); + error SwapOutputInsufficient(); + + // ========================================================================= + // Events + // ========================================================================= + + /// @param quoteId Correlation ID of the executed quote. + /// @param success Whether the IBungeeExecutor.executeData call succeeded. + event DestPayloadExecuted(bytes32 indexed quoteId, bool success); + + /// @param quoteId Correlation ID of the executed quote. + /// @param recipient Address that received the funds directly (no IBungeeExecutor involved). + event FundsForwarded(bytes32 indexed quoteId, address indexed recipient, address token, uint256 amount); + + event SolverSignerUpdated(address indexed newSigner); + + // ========================================================================= + // State + // ========================================================================= + + address public SOLVER_SIGNER; + address public immutable CALLDATA_EXECUTOR; + + mapping(uint256 => bool) public nonceUsed; + + // ========================================================================= + // Constructor + // ========================================================================= + + /** + * @param _owner Initial owner; also granted RESCUE_ROLE. + * @param _solverSigner Address whose ECDSA signatures authorise `executeDestPayload` calls. + * @param _calldataExecutor Address of the CalldataExecutor satellite contract. + */ + constructor(address _owner, address _solverSigner, address _calldataExecutor) AccessControl(_owner) { + _grantRole(RESCUE_ROLE, _owner); + SOLVER_SIGNER = _solverSigner; + CALLDATA_EXECUTOR = _calldataExecutor; + } + + receive() external payable {} + fallback() external payable {} + + // ========================================================================= + // External functions + // ========================================================================= + + /** + * @notice Update the signer address used to authorise `executeDestPayload` calls. + * @param _solverSigner New signer address. + */ + function setSolverSigner(address _solverSigner) external onlyOwner { + SOLVER_SIGNER = _solverSigner; + emit SolverSignerUpdated(_solverSigner); + } + + /** + * @notice Execute destination payload after bridge funds have landed in this contract. + * + * @dev The backend constructs this call after reading the exact received amount from the bridge + * tx receipt. Every field of `payload` is covered by the SOLVER_SIGNER signature — + * modifying any field invalidates the signature. + * + * @param payload All signed execution parameters bundled into a single calldata struct. + * @param signature SOLVER_SIGNER personal-sign signature over `payload`. + */ + function executeDestPayload(DestPayload calldata payload, bytes calldata signature) external { + // At least one of target or recipient must be set. + if (payload.execution.target == address(0) && payload.execution.recipient == address(0)) { + revert InvalidExecution(); + } + + _verifySignature( + keccak256(abi.encode(block.chainid, address(this), payload)), + signature + ); + + _useNonce(payload.nonce); + + // Run pre-actions (e.g. set ERC-20 approval for the swap router). + for (uint256 i; i < payload.preActions.length;) { + if (!_performAction(payload.preActions[i])) { + revert PreActionFailed(i); + } + unchecked { ++i; } + } + + address outputToken; + uint256 outputAmount; + + if (payload.swapData.target != address(0)) { + (outputToken, outputAmount) = _executeSwap( + payload.funds.token, payload.swapData, payload.swapCallData, payload.feeData + ); + } else { + (outputToken, outputAmount) = _applyNoSwapFee( + payload.funds.token, payload.execution.outputAmount, payload.feeData + ); + } + + // Funds leave the receiver here — all checks complete before this point (CEI). + address dest = payload.execution.target != address(0) + ? payload.execution.target + : payload.execution.recipient; + CurrencyLib.transfer(outputToken, dest, outputAmount); + + if (payload.execution.target != address(0)) { + _callExecutor(payload.quoteId, outputToken, outputAmount, payload.execution, payload.executionCallData); + } else { + emit FundsForwarded(payload.quoteId, payload.execution.recipient, outputToken, outputAmount); + } + } + + /** + * @notice Rescue funds locked in the contract by mistake. + * @param token ERC-20 address or 0xEeee...EEeE for native ETH. + * @param rescueTo Recipient of the rescued funds. + * @param amount Amount to rescue. + */ + function rescueFunds(address token, address rescueTo, uint256 amount) external onlyRole(RESCUE_ROLE) { + RescueFundsLib.rescueFunds(token, rescueTo, amount); + } + + // ========================================================================= + // Internal functions + // ========================================================================= + + /** + * @dev Handles the swap path: optional pre-swap fee → approval → swap → balance delta → optional post-swap fee. + * @return outputToken Token coming out of the swap (swapData.outputToken). + * @return outputAmount Net swap output after any post-swap fee. + */ + function _executeSwap( + address inputToken, + DstSwapData calldata swapData, + bytes calldata swapCallData, + DstFeeData calldata feeData + ) internal returns (address outputToken, uint256 outputAmount) { + // Collect pre-swap fee from the bridged input token. + if (feeData.feeType == FeeType.PRE_SWAP && feeData.amount != 0) { + CurrencyLib.transfer(feeData.token, feeData.collector, feeData.amount); + } + + // Approve the swap router to spend input token (max allowance, USDT-safe retry pattern). + if (swapData.approvalSpender != address(0) && inputToken != CurrencyLib.NATIVE_TOKEN_ADDRESS) { + SafeTransferLib.safeApproveWithRetry(inputToken, swapData.approvalSpender, type(uint256).max); + } + + // Measure swap output via balance delta — avoids relying on swap router return values. + outputToken = swapData.outputToken; + uint256 balanceBefore = CurrencyLib.balanceOf(outputToken, address(this)); + + if (!_execSwapCalldata(swapData.target, swapData.value, swapCallData)) { + revert SwapFailed(); + } + + outputAmount = CurrencyLib.balanceOf(outputToken, address(this)) - balanceBefore; + + if (outputAmount < swapData.minOutput) { + revert SwapOutputInsufficient(); + } + + // Collect post-swap fee from the swap output. + // Assumes feeData.token == outputToken; enforced by the signed payload. + if (feeData.feeType == FeeType.POST_SWAP && feeData.amount != 0) { + CurrencyLib.transfer(feeData.token, feeData.collector, feeData.amount); + unchecked { outputAmount -= feeData.amount; } + } + } + + /** + * @dev Handles the no-swap path: apply fee if set, return bridged token + explicit output amount. + * The backend derives `outputAmount` from the bridge tx receipt; the contract trusts the + * signed value. If the bridge sent less, the subsequent `CurrencyLib.transfer` will revert. + */ + function _applyNoSwapFee( + address bridgedToken, + uint256 outputAmount, + DstFeeData calldata feeData + ) internal returns (address, uint256) { + // PRE_SWAP and POST_SWAP both mean "collect fee" when there is no swap. + if (feeData.feeType != FeeType.NO_FEE && feeData.amount != 0) { + CurrencyLib.transfer(feeData.token, feeData.collector, feeData.amount); + } + return (bridgedToken, outputAmount); + } + + /** + * @dev Builds IBungeeExecutor.executeData calldata and dispatches it via CalldataExecutor. + * CalldataExecutor uses excessivelySafeCall so a revert in the target does NOT revert here; + * instead, `success = false` is emitted in the event for the backend to detect and handle. + */ + function _callExecutor( + bytes32 quoteId, + address outputToken, + uint256 outputAmount, + DstExecutionData calldata execution, + bytes calldata executionCallData + ) internal { + uint256[] memory amounts = new uint256[](1); + amounts[0] = outputAmount; + address[] memory tokens = new address[](1); + tokens[0] = outputToken; + + bytes memory executeDataCalldata = abi.encodeCall( + IBungeeExecutor.executeData, + (quoteId, amounts, tokens, executionCallData) + ); + + bool success = ICalldataExecutor(CALLDATA_EXECUTOR).executeCalldata( + execution.target, + executeDataCalldata, + execution.gasLimit + ); + + emit DestPayloadExecuted(quoteId, success); + } + + /** + * @dev Recovers the signer from a personal-sign signature and reverts if it does not match SOLVER_SIGNER. + */ + function _verifySignature(bytes32 messageHash, bytes calldata signature) internal view { + if (SOLVER_SIGNER != AuthenticationLib.authenticate(messageHash, signature)) { + revert InvalidSigner(); + } + } + + /** + * @dev Marks `nonce` as used in a gas-efficient assembly block; reverts with InvalidNonce() if already used. + * Uses the same pattern as StakedRouterReceiver for consistency. + */ + function _useNonce(uint256 nonce) internal { + assembly { + mstore(0, nonce) + mstore(0x20, nonceUsed.slot) + let dataSlot := keccak256(0, 0x40) + + if and(sload(dataSlot), 0xff) { + mstore(0x00, 0x756688fe) // InvalidNonce() + revert(0x1c, 0x04) + } + + sstore(dataSlot, 0x01) + } + } + + /** + * @dev Executes a single pre-action from calldata. + * Assembly reads the Action struct fields directly from calldata to avoid a memory copy. + * Does NOT revert on call failure — caller must check the return value. + * + * Action calldata layout (ABI-encoded struct): + * action + 0 : address target (32 bytes, right-aligned) + * action + 32 : uint256 value + * action + 64 : uint256 offset to `data` field (relative to struct start = 96) + * action + 96 : uint256 data.length + * action + 128: data bytes + */ + function _performAction(Action calldata action) internal returns (bool success) { + assembly ("memory-safe") { + let dataLength := calldataload(add(action, 96)) + let ptr := mload(0x40) + // data starts at: action + 32 + data_offset = action + 32 + 96 = action + 128 + calldatacopy(ptr, add(add(action, 32), calldataload(add(action, 64))), dataLength) + mstore(0x40, and(add(add(ptr, dataLength), 0x1f), not(0x1f))) + success := call(gas(), calldataload(action), calldataload(add(action, 32)), ptr, dataLength, 0, 0) + } + } + + /** + * @dev Executes the swap call by copying calldata directly to memory. + * More gas-efficient than a memory-based approach: avoids the Solidity `bytes memory` overhead. + */ + function _execSwapCalldata(address target, uint256 value, bytes calldata data) internal returns (bool success) { + assembly ("memory-safe") { + let ptr := mload(0x40) + calldatacopy(ptr, data.offset, data.length) + mstore(0x40, and(add(add(ptr, data.length), 0x1f), not(0x1f))) + success := call(gas(), target, value, ptr, data.length, 0, 0) + } + } +} diff --git a/src/CalldataExecutor.sol b/src/CalldataExecutor.sol new file mode 100644 index 0000000..b896119 --- /dev/null +++ b/src/CalldataExecutor.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity 0.8.34; + +import {ICalldataExecutor} from "./interfaces/ICalldataExecutor.sol"; +import {ExcessivelySafeCall} from "./lib/ExcessivelySafeCall.sol"; + +/// @title CalldataExecutor +/// @notice Satellite contract that executes arbitrary calldata on behalf of BungeeReceiver. +/// Separating execution from BungeeReceiver (the fund-holding contract) limits the attack +/// surface: a compromised or misbehaving destination target can only affect this stateless +/// proxy, not the contract holding bridged funds. +/// +/// Uses `excessivelySafeCall` with MAX_COPY_BYTES = 0 to prevent return-data bomb attacks +/// from untrusted IBungeeExecutor implementations. +contract CalldataExecutor is ICalldataExecutor { + using ExcessivelySafeCall for address; + + error OnlyBungeeReceiver(); + + address public immutable BUNGEE_RECEIVER; + + /// @dev No returndata is copied — success/failure is all we need from the IBungeeExecutor call. + uint16 public constant MAX_COPY_BYTES = 0; + + constructor(address _bungeeReceiver) { + BUNGEE_RECEIVER = _bungeeReceiver; + } + + /// @inheritdoc ICalldataExecutor + function executeCalldata(address to, bytes memory encodedData, uint256 msgGasLimit) external returns (bool success) { + if (msg.sender != BUNGEE_RECEIVER) { + revert OnlyBungeeReceiver(); + } + + (success,) = to.excessivelySafeCall(msgGasLimit, MAX_COPY_BYTES, encodedData); + } +} diff --git a/src/interfaces/IBungeeExecutor.sol b/src/interfaces/IBungeeExecutor.sol new file mode 100644 index 0000000..f77056f --- /dev/null +++ b/src/interfaces/IBungeeExecutor.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity 0.8.34; + +/// @title IBungeeExecutor +/// @notice Interface for contracts that receive bridge output funds and execute destination calldata. +/// Implementors must have a simple `receive() external payable {}` when accepting native ETH. +interface IBungeeExecutor { + /// @notice Called by BungeeReceiver after funds have been transferred to this contract. + /// @param quoteId Correlation ID linking this execution to the original source-chain quote. + /// @param amounts Token amounts transferred to this contract (one per token in `tokens`). + /// @param tokens Token addresses corresponding to each entry in `amounts` + /// (use 0xEeee...EEeE sentinel for native ETH). + /// @param callData Protocol-specific calldata encoding the final action (e.g. Aave deposit params). + function executeData( + bytes32 quoteId, + uint256[] calldata amounts, + address[] calldata tokens, + bytes calldata callData + ) external payable; +} diff --git a/src/interfaces/ICalldataExecutor.sol b/src/interfaces/ICalldataExecutor.sol new file mode 100644 index 0000000..e994576 --- /dev/null +++ b/src/interfaces/ICalldataExecutor.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity 0.8.34; + +/// @title ICalldataExecutor +/// @notice Interface for the CalldataExecutor satellite contract. +/// Kept separate from BungeeReceiver to reduce the attack surface of the fund-holding contract. +interface ICalldataExecutor { + /// @notice Executes encoded calldata on a destination contract using a gas-capped safe call. + /// @param to The target contract address (must implement IBungeeExecutor). + /// @param encodedData ABI-encoded calldata forwarded to `to`. + /// @param msgGasLimit Gas forwarded to the call; prevents unbounded gas consumption. + /// @return success Whether the call succeeded. + function executeCalldata(address to, bytes memory encodedData, uint256 msgGasLimit) external returns (bool success); +} diff --git a/src/lib/ExcessivelySafeCall.sol b/src/lib/ExcessivelySafeCall.sol new file mode 100644 index 0000000..dfcd535 --- /dev/null +++ b/src/lib/ExcessivelySafeCall.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity 0.8.34; + +// Ported from https://github.com/nomad-xyz/ExcessivelySafeCall (modified to remove msg.value transfers). +// Used by CalldataExecutor to cap returndata copy size and prevent return-data bomb attacks from +// untrusted destination contracts. +library ExcessivelySafeCall { + uint256 constant LOW_28_MASK = 0x00000000ffffffffffffffffffffffffffffffffffffffffffffffffffffffff; + + /// @notice Low-level call that caps the number of returndata bytes copied to memory. + /// Prevents a malicious callee from causing OOG by returning a huge returndata payload. + /// @param _target The address to call. + /// @param _gas Gas forwarded to the callee. + /// @param _maxCopy Maximum bytes of returndata to copy into memory. + /// @param _calldata Calldata forwarded to the callee. + /// @return success Whether the call succeeded. + /// @return returnData Up to `_maxCopy` bytes of returndata. + function excessivelySafeCall( + address _target, + uint256 _gas, + uint16 _maxCopy, + bytes memory _calldata + ) internal returns (bool success, bytes memory returnData) { + uint256 _toCopy; + returnData = new bytes(_maxCopy); + assembly { + success := call( + _gas, + _target, + 0, + add(_calldata, 0x20), + mload(_calldata), + 0, + 0 + ) + _toCopy := returndatasize() + if gt(_toCopy, _maxCopy) { _toCopy := _maxCopy } + mstore(returnData, _toCopy) + returndatacopy(add(returnData, 0x20), 0, _toCopy) + } + } +} From 968594fd539b4cdaa2c5ace999ae2ae247c7c402 Mon Sep 17 00:00:00 2001 From: akash Date: Fri, 29 May 2026 20:12:42 +0530 Subject: [PATCH 2/4] feat: bungee receiver cleanup --- src/BungeeReceiver.sol | 279 +++--------------- src/OpenRouter.sol | 4 +- src/interfaces/IBungeeExecutor.sol | 11 +- test/combined/BungeeReceiverDestPayload.t.sol | 202 +++++++++++++ 4 files changed, 256 insertions(+), 240 deletions(-) create mode 100644 test/combined/BungeeReceiverDestPayload.t.sol diff --git a/src/BungeeReceiver.sol b/src/BungeeReceiver.sol index 15a91ea..a842446 100644 --- a/src/BungeeReceiver.sol +++ b/src/BungeeReceiver.sol @@ -18,12 +18,8 @@ import {IBungeeExecutor} from "./interfaces/IBungeeExecutor.sol"; /// 1. Bridge transfers output token(s) to this contract. /// 2. Backend reads the tx receipt to get the exact received amount. /// 3. Backend constructs and signs a `DestPayload`, then calls `executeDestPayload`. -/// 4. Contract optionally runs preActions (e.g. approve a swap router). -/// 5. Contract optionally swaps the bridged token to the desired output token, -/// measuring the actual output via balance delta. -/// 6. Output token + amount are transferred to the IBungeeExecutor target (or directly -/// to a recipient address when no protocol execution is needed). -/// 7. If a target is set, `CalldataExecutor.executeCalldata` is called, which invokes +/// 4. Output token + amount are transferred to the IBungeeExecutor target. +/// 5. `CalldataExecutor.executeCalldata` is called, which invokes /// `IBungeeExecutor.executeData` on the target via excessivelySafeCall. /// /// Security: all execution paths are gated behind an ECDSA signature from `SOLVER_SIGNER` @@ -37,73 +33,22 @@ contract BungeeReceiver is AccessControl { // Structs // ========================================================================= - /// @dev Generic action used in `preActions` (e.g. set approvals for the swap router). - struct Action { - address target; - uint256 value; - bytes data; - } - - /// @dev Token delivered by the bridge to this contract. - struct BridgedFunds { - address token; - } - - /// @dev Optional destination swap config. Set `target == address(0)` to skip the swap. - struct DstSwapData { - address target; - /// @dev Spender to approve with max allowance before calling the swap router. - /// Set to address(0) to skip approval (e.g. native input or already approved). - address approvalSpender; - address outputToken; - /// @dev Minimum acceptable swap output; reverts if balance delta falls below this. - uint256 minOutput; - /// @dev msg.value forwarded to the swap router call (for native-token input swaps). - uint256 value; - } - - /// @dev Fee collection config. Set `feeType == NO_FEE` or `amount == 0` to skip. - struct DstFeeData { - FeeType feeType; - /// @dev Token the fee is collected in. - /// For POST_SWAP fees this should equal `DstSwapData.outputToken`. - address token; - address collector; - uint256 amount; - } - - /// @dev Final execution config: either invoke an IBungeeExecutor or forward directly to a recipient. - struct DstExecutionData { - /// @dev IBungeeExecutor target. Set to address(0) for a plain token transfer to `recipient`. - address target; - /// @dev Recipient used when `target == address(0)`. - address recipient; - /// @dev Explicit output amount for the no-swap path (net of fee). Unused when a swap is present. - uint256 outputAmount; - /// @dev Gas limit forwarded to CalldataExecutor → IBungeeExecutor call. - uint256 gasLimit; - } - - enum FeeType { - NO_FEE, - PRE_SWAP, - POST_SWAP - } - - /// @dev Single calldata struct covering every field that the SOLVER_SIGNER signs over. - /// Bundling into one struct reduces `executeDestPayload`'s stack depth: a `bytes` or array - /// field inside a calldata struct is accessed via the struct's single calldata pointer - /// (1 stack slot) rather than a separate offset+length pair (2 slots each). + /// @dev All fields covered by the SOLVER_SIGNER signature. + /// Kept as fixed-size fields only — `executionCallData` is passed as a separate + /// parameter and included in the signature hash via keccak256, avoiding the ABI + /// overhead (offset word + length prefix) that a `bytes` field inside a struct would add. struct DestPayload { uint256 nonce; + /// @dev Caller-defined correlation ID for cross-chain tracking. bytes32 quoteId; - BridgedFunds funds; - Action[] preActions; - DstFeeData feeData; - DstSwapData swapData; - bytes swapCallData; - DstExecutionData execution; - bytes executionCallData; + /// @dev Token delivered by the bridge to this contract. + address bridgedToken; + /// @dev IBungeeExecutor target — always required; funds are sent here then executeData is called. + address target; + /// @dev Net token amount to forward. Backend derives this from the bridge tx receipt. + uint256 outputAmount; + /// @dev Gas forwarded to the CalldataExecutor → IBungeeExecutor call. + uint256 gasLimit; } // ========================================================================= @@ -113,9 +58,6 @@ contract BungeeReceiver is AccessControl { error InvalidSigner(); error InvalidNonce(); error InvalidExecution(); - error PreActionFailed(uint256 index); - error SwapFailed(); - error SwapOutputInsufficient(); // ========================================================================= // Events @@ -125,10 +67,6 @@ contract BungeeReceiver is AccessControl { /// @param success Whether the IBungeeExecutor.executeData call succeeded. event DestPayloadExecuted(bytes32 indexed quoteId, bool success); - /// @param quoteId Correlation ID of the executed quote. - /// @param recipient Address that received the funds directly (no IBungeeExecutor involved). - event FundsForwarded(bytes32 indexed quoteId, address indexed recipient, address token, uint256 amount); - event SolverSignerUpdated(address indexed newSigner); // ========================================================================= @@ -175,57 +113,39 @@ contract BungeeReceiver is AccessControl { * @notice Execute destination payload after bridge funds have landed in this contract. * * @dev The backend constructs this call after reading the exact received amount from the bridge - * tx receipt. Every field of `payload` is covered by the SOLVER_SIGNER signature — - * modifying any field invalidates the signature. + * tx receipt. Every field of `payload` plus `executionCallData` is covered by the + * SOLVER_SIGNER signature — modifying any field invalidates the signature. * - * @param payload All signed execution parameters bundled into a single calldata struct. - * @param signature SOLVER_SIGNER personal-sign signature over `payload`. + * `executionCallData` is passed separately (not embedded in the struct) so that + * `abi.encode(payload)` remains a flat fixed-size blob; the bytes field is committed + * to via its keccak256 hash in the signature preimage instead. + * + * @param payload All signed execution parameters. + * @param executionCallData Protocol-specific calldata forwarded to IBungeeExecutor.executeData. + * @param signature SOLVER_SIGNER personal-sign signature. */ - function executeDestPayload(DestPayload calldata payload, bytes calldata signature) external { - // At least one of target or recipient must be set. - if (payload.execution.target == address(0) && payload.execution.recipient == address(0)) { + function executeDestPayload( + DestPayload calldata payload, + bytes calldata executionCallData, + bytes calldata signature + ) external { + if (payload.target == address(0)) { revert InvalidExecution(); } _verifySignature( - keccak256(abi.encode(block.chainid, address(this), payload)), + keccak256(abi.encode(block.chainid, address(this), payload, keccak256(executionCallData))), signature ); _useNonce(payload.nonce); - // Run pre-actions (e.g. set ERC-20 approval for the swap router). - for (uint256 i; i < payload.preActions.length;) { - if (!_performAction(payload.preActions[i])) { - revert PreActionFailed(i); - } - unchecked { ++i; } - } - - address outputToken; - uint256 outputAmount; - - if (payload.swapData.target != address(0)) { - (outputToken, outputAmount) = _executeSwap( - payload.funds.token, payload.swapData, payload.swapCallData, payload.feeData - ); - } else { - (outputToken, outputAmount) = _applyNoSwapFee( - payload.funds.token, payload.execution.outputAmount, payload.feeData - ); - } + // Transfer bridged funds to the IBungeeExecutor target before calling it (CEI). + CurrencyLib.transfer(payload.bridgedToken, payload.target, payload.outputAmount); - // Funds leave the receiver here — all checks complete before this point (CEI). - address dest = payload.execution.target != address(0) - ? payload.execution.target - : payload.execution.recipient; - CurrencyLib.transfer(outputToken, dest, outputAmount); + bool success = _callExecutor(payload.quoteId, payload.bridgedToken, payload.outputAmount, payload.target, payload.gasLimit, executionCallData); - if (payload.execution.target != address(0)) { - _callExecutor(payload.quoteId, outputToken, outputAmount, payload.execution, payload.executionCallData); - } else { - emit FundsForwarded(payload.quoteId, payload.execution.recipient, outputToken, outputAmount); - } + emit DestPayloadExecuted(payload.quoteId, success); } /** @@ -243,94 +163,24 @@ contract BungeeReceiver is AccessControl { // ========================================================================= /** - * @dev Handles the swap path: optional pre-swap fee → approval → swap → balance delta → optional post-swap fee. - * @return outputToken Token coming out of the swap (swapData.outputToken). - * @return outputAmount Net swap output after any post-swap fee. - */ - function _executeSwap( - address inputToken, - DstSwapData calldata swapData, - bytes calldata swapCallData, - DstFeeData calldata feeData - ) internal returns (address outputToken, uint256 outputAmount) { - // Collect pre-swap fee from the bridged input token. - if (feeData.feeType == FeeType.PRE_SWAP && feeData.amount != 0) { - CurrencyLib.transfer(feeData.token, feeData.collector, feeData.amount); - } - - // Approve the swap router to spend input token (max allowance, USDT-safe retry pattern). - if (swapData.approvalSpender != address(0) && inputToken != CurrencyLib.NATIVE_TOKEN_ADDRESS) { - SafeTransferLib.safeApproveWithRetry(inputToken, swapData.approvalSpender, type(uint256).max); - } - - // Measure swap output via balance delta — avoids relying on swap router return values. - outputToken = swapData.outputToken; - uint256 balanceBefore = CurrencyLib.balanceOf(outputToken, address(this)); - - if (!_execSwapCalldata(swapData.target, swapData.value, swapCallData)) { - revert SwapFailed(); - } - - outputAmount = CurrencyLib.balanceOf(outputToken, address(this)) - balanceBefore; - - if (outputAmount < swapData.minOutput) { - revert SwapOutputInsufficient(); - } - - // Collect post-swap fee from the swap output. - // Assumes feeData.token == outputToken; enforced by the signed payload. - if (feeData.feeType == FeeType.POST_SWAP && feeData.amount != 0) { - CurrencyLib.transfer(feeData.token, feeData.collector, feeData.amount); - unchecked { outputAmount -= feeData.amount; } - } - } - - /** - * @dev Handles the no-swap path: apply fee if set, return bridged token + explicit output amount. - * The backend derives `outputAmount` from the bridge tx receipt; the contract trusts the - * signed value. If the bridge sent less, the subsequent `CurrencyLib.transfer` will revert. - */ - function _applyNoSwapFee( - address bridgedToken, - uint256 outputAmount, - DstFeeData calldata feeData - ) internal returns (address, uint256) { - // PRE_SWAP and POST_SWAP both mean "collect fee" when there is no swap. - if (feeData.feeType != FeeType.NO_FEE && feeData.amount != 0) { - CurrencyLib.transfer(feeData.token, feeData.collector, feeData.amount); - } - return (bridgedToken, outputAmount); - } - - /** - * @dev Builds IBungeeExecutor.executeData calldata and dispatches it via CalldataExecutor. + * @dev Encodes and dispatches IBungeeExecutor.executeData via CalldataExecutor. * CalldataExecutor uses excessivelySafeCall so a revert in the target does NOT revert here; - * instead, `success = false` is emitted in the event for the backend to detect and handle. + * `success` is emitted in the event for the backend to detect and handle. */ function _callExecutor( bytes32 quoteId, - address outputToken, - uint256 outputAmount, - DstExecutionData calldata execution, + address token, + uint256 amount, + address target, + uint256 gasLimit, bytes calldata executionCallData - ) internal { - uint256[] memory amounts = new uint256[](1); - amounts[0] = outputAmount; - address[] memory tokens = new address[](1); - tokens[0] = outputToken; - + ) internal returns (bool success) { bytes memory executeDataCalldata = abi.encodeCall( IBungeeExecutor.executeData, - (quoteId, amounts, tokens, executionCallData) - ); - - bool success = ICalldataExecutor(CALLDATA_EXECUTOR).executeCalldata( - execution.target, - executeDataCalldata, - execution.gasLimit + (quoteId, amount, token, executionCallData) ); - emit DestPayloadExecuted(quoteId, success); + success = ICalldataExecutor(CALLDATA_EXECUTOR).executeCalldata(target, executeDataCalldata, gasLimit); } /** @@ -343,8 +193,9 @@ contract BungeeReceiver is AccessControl { } /** - * @dev Marks `nonce` as used in a gas-efficient assembly block; reverts with InvalidNonce() if already used. - * Uses the same pattern as StakedRouterReceiver for consistency. + * @dev Marks `nonce` as used; reverts with InvalidNonce() if already consumed. + * Uses assembly for direct mapping slot computation, avoiding the Solidity keccak256 + * call overhead and a redundant SLOAD for the bool-to-uint cast. */ function _useNonce(uint256 nonce) internal { assembly { @@ -360,40 +211,4 @@ contract BungeeReceiver is AccessControl { sstore(dataSlot, 0x01) } } - - /** - * @dev Executes a single pre-action from calldata. - * Assembly reads the Action struct fields directly from calldata to avoid a memory copy. - * Does NOT revert on call failure — caller must check the return value. - * - * Action calldata layout (ABI-encoded struct): - * action + 0 : address target (32 bytes, right-aligned) - * action + 32 : uint256 value - * action + 64 : uint256 offset to `data` field (relative to struct start = 96) - * action + 96 : uint256 data.length - * action + 128: data bytes - */ - function _performAction(Action calldata action) internal returns (bool success) { - assembly ("memory-safe") { - let dataLength := calldataload(add(action, 96)) - let ptr := mload(0x40) - // data starts at: action + 32 + data_offset = action + 32 + 96 = action + 128 - calldatacopy(ptr, add(add(action, 32), calldataload(add(action, 64))), dataLength) - mstore(0x40, and(add(add(ptr, dataLength), 0x1f), not(0x1f))) - success := call(gas(), calldataload(action), calldataload(add(action, 32)), ptr, dataLength, 0, 0) - } - } - - /** - * @dev Executes the swap call by copying calldata directly to memory. - * More gas-efficient than a memory-based approach: avoids the Solidity `bytes memory` overhead. - */ - function _execSwapCalldata(address target, uint256 value, bytes calldata data) internal returns (bool success) { - assembly ("memory-safe") { - let ptr := mload(0x40) - calldatacopy(ptr, data.offset, data.length) - mstore(0x40, and(add(add(ptr, data.length), 0x1f), not(0x1f))) - success := call(gas(), target, value, ptr, data.length, 0, 0) - } - } } diff --git a/src/OpenRouter.sol b/src/OpenRouter.sol index d9aa58d..043c2d9 100644 --- a/src/OpenRouter.sol +++ b/src/OpenRouter.sol @@ -61,8 +61,8 @@ contract OpenRouter is AccessControl, AllowanceHolderContext { /// `callType | (storeResult ? 1 << 8 : 0) | (uint160(target) << 16)`. /// /// Bit layout (least significant bits first): - /// bits 255..160 : reserved (0) - /// bits 159..16 : target address (uint160, left-aligned in this field) + /// bits 255..176 : reserved (0) + /// bits 175..16 : target address (uint160, shifted left by 16 — occupies 160 bits) /// bit 8 : storeResult — when set, returndata is saved to `results[i]` /// even on success so later actions can splice from it /// bits 7..3 : reserved (0) diff --git a/src/interfaces/IBungeeExecutor.sol b/src/interfaces/IBungeeExecutor.sol index f77056f..495710f 100644 --- a/src/interfaces/IBungeeExecutor.sol +++ b/src/interfaces/IBungeeExecutor.sol @@ -6,15 +6,14 @@ pragma solidity 0.8.34; /// Implementors must have a simple `receive() external payable {}` when accepting native ETH. interface IBungeeExecutor { /// @notice Called by BungeeReceiver after funds have been transferred to this contract. - /// @param quoteId Correlation ID linking this execution to the original source-chain quote. - /// @param amounts Token amounts transferred to this contract (one per token in `tokens`). - /// @param tokens Token addresses corresponding to each entry in `amounts` - /// (use 0xEeee...EEeE sentinel for native ETH). + /// @param quoteId Correlation ID linking this execution to the original source-chain quote. + /// @param amount Token amount transferred to this contract. + /// @param token Token address (use 0xEeee...EEeE sentinel for native ETH). /// @param callData Protocol-specific calldata encoding the final action (e.g. Aave deposit params). function executeData( bytes32 quoteId, - uint256[] calldata amounts, - address[] calldata tokens, + uint256 amount, + address token, bytes calldata callData ) external payable; } diff --git a/test/combined/BungeeReceiverDestPayload.t.sol b/test/combined/BungeeReceiverDestPayload.t.sol new file mode 100644 index 0000000..2e70986 --- /dev/null +++ b/test/combined/BungeeReceiverDestPayload.t.sol @@ -0,0 +1,202 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.34; + +import {Test} from "forge-std/Test.sol"; +import {ERC20} from "solady/src/tokens/ERC20.sol"; + +import {BungeeReceiver} from "../../src/BungeeReceiver.sol"; +import {CalldataExecutor} from "../../src/CalldataExecutor.sol"; +import {AuthenticationLib} from "../../src/common/lib/AuthenticationLib.sol"; +import {IBungeeExecutor} from "../../src/interfaces/IBungeeExecutor.sol"; + +/// @dev End-to-end test for the simplified destination payload path: +/// bridge deposit → signed executeDestPayload → transfer to executor → executeData. +contract BungeeReceiverDestPayloadTest is Test { + uint256 internal constant SOLVER_PRIVATE_KEY = 0xA11CE; + uint256 internal constant OUTPUT_AMOUNT = 100 ether; + uint256 internal constant GAS_LIMIT = 500_000; + + address internal solverSigner; + BungeeReceiver internal receiver; + CalldataExecutor internal calldataExecutor; + MockBungeeExecutor internal executor; + MockERC20 internal bridgedToken; + + bytes32 internal constant QUOTE_ID = keccak256("quote-1"); + bytes internal executionCallData = hex"deadbeef"; + + function setUp() public { + solverSigner = vm.addr(SOLVER_PRIVATE_KEY); + + uint64 deployNonce = vm.getNonce(address(this)); + address predictedReceiver = vm.computeCreateAddress(address(this), deployNonce + 1); + + calldataExecutor = new CalldataExecutor(predictedReceiver); + receiver = new BungeeReceiver(address(this), solverSigner, address(calldataExecutor)); + + assertEq(address(receiver), predictedReceiver); + + executor = new MockBungeeExecutor(); + bridgedToken = new MockERC20("Bridged Token", "BRG"); + + vm.label(address(receiver), "bungeeReceiver"); + vm.label(address(calldataExecutor), "calldataExecutor"); + vm.label(address(executor), "bungeeExecutor"); + vm.label(address(bridgedToken), "bridgedToken"); + vm.label(solverSigner, "solverSigner"); + } + + function test_executeDestPayload_transfersFundsAndCallsExecutor() public { + _depositBridgedFunds(); + + BungeeReceiver.DestPayload memory payload = _buildPayload(1); + bytes memory signature = _signPayload(payload, executionCallData); + + vm.expectEmit(true, false, false, true, address(receiver)); + emit BungeeReceiver.DestPayloadExecuted(QUOTE_ID, true); + + receiver.executeDestPayload(payload, executionCallData, signature); + + assertEq(bridgedToken.balanceOf(address(receiver)), 0); + assertEq(bridgedToken.balanceOf(address(executor)), OUTPUT_AMOUNT); + assertEq(executor.lastQuoteId(), QUOTE_ID); + assertEq(executor.lastAmount(), OUTPUT_AMOUNT); + assertEq(executor.lastToken(), address(bridgedToken)); + assertEq(executor.lastCallData(), executionCallData); + assertTrue(receiver.nonceUsed(1)); + } + + function test_executeDestPayload_revertsWhenTargetIsZero() public { + BungeeReceiver.DestPayload memory payload = _buildPayload(1); + payload.target = address(0); + bytes memory signature = _signPayload(payload, executionCallData); + + vm.expectRevert(BungeeReceiver.InvalidExecution.selector); + receiver.executeDestPayload(payload, executionCallData, signature); + } + + function test_executeDestPayload_revertsOnInvalidSigner() public { + _depositBridgedFunds(); + + BungeeReceiver.DestPayload memory payload = _buildPayload(1); + bytes32 messageHash = _messageHash(payload, executionCallData); + bytes32 ethSignedMessageHash = AuthenticationLib.getEthSignedMessageHash(messageHash); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(SOLVER_PRIVATE_KEY + 1, ethSignedMessageHash); + bytes memory badSignature = abi.encodePacked(r, s, v); + + vm.expectRevert(BungeeReceiver.InvalidSigner.selector); + receiver.executeDestPayload(payload, executionCallData, badSignature); + } + + function test_executeDestPayload_revertsOnNonceReuse() public { + _depositBridgedFunds(); + + BungeeReceiver.DestPayload memory payload = _buildPayload(1); + bytes memory signature = _signPayload(payload, executionCallData); + + receiver.executeDestPayload(payload, executionCallData, signature); + + vm.expectRevert(BungeeReceiver.InvalidNonce.selector); + receiver.executeDestPayload(payload, executionCallData, signature); + } + + function test_executeDestPayload_emitsFailureWhenExecutorReverts() public { + _depositBridgedFunds(); + + executor.setShouldRevert(true); + + BungeeReceiver.DestPayload memory payload = _buildPayload(2); + bytes memory signature = _signPayload(payload, executionCallData); + + vm.expectEmit(true, false, false, true, address(receiver)); + emit BungeeReceiver.DestPayloadExecuted(QUOTE_ID, false); + + receiver.executeDestPayload(payload, executionCallData, signature); + + assertEq(bridgedToken.balanceOf(address(executor)), OUTPUT_AMOUNT); + assertFalse(executor.wasCalled()); + } + + function _depositBridgedFunds() internal { + bridgedToken.mint(address(receiver), OUTPUT_AMOUNT); + } + + function _buildPayload(uint256 nonce) internal view returns (BungeeReceiver.DestPayload memory payload) { + payload = BungeeReceiver.DestPayload({ + nonce: nonce, + quoteId: QUOTE_ID, + bridgedToken: address(bridgedToken), + target: address(executor), + outputAmount: OUTPUT_AMOUNT, + gasLimit: GAS_LIMIT + }); + } + + function _messageHash(BungeeReceiver.DestPayload memory payload, bytes memory callData) + internal + view + returns (bytes32) + { + return keccak256(abi.encode(block.chainid, address(receiver), payload, keccak256(callData))); + } + + function _signPayload(BungeeReceiver.DestPayload memory payload, bytes memory callData) + internal + view + returns (bytes memory signature) + { + bytes32 messageHash = _messageHash(payload, callData); + bytes32 ethSignedMessageHash = AuthenticationLib.getEthSignedMessageHash(messageHash); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(SOLVER_PRIVATE_KEY, ethSignedMessageHash); + signature = abi.encodePacked(r, s, v); + } +} + +contract MockERC20 is ERC20 { + string private _name; + string private _symbol; + + constructor(string memory name_, string memory symbol_) { + _name = name_; + _symbol = symbol_; + } + + function name() public view override returns (string memory) { + return _name; + } + + function symbol() public view override returns (string memory) { + return _symbol; + } + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } +} + +contract MockBungeeExecutor is IBungeeExecutor { + bytes32 public lastQuoteId; + uint256 public lastAmount; + address public lastToken; + bytes public lastCallData; + bool public wasCalled; + bool public shouldRevert; + + function setShouldRevert(bool value) external { + shouldRevert = value; + } + + function executeData(bytes32 quoteId, uint256 amount, address token, bytes calldata callData) external payable { + if (shouldRevert) { + revert("executor revert"); + } + + wasCalled = true; + lastQuoteId = quoteId; + lastAmount = amount; + lastToken = token; + lastCallData = callData; + } + + receive() external payable {} +} From e754e97207ea96020d68a0b65a75e588fb89f61a Mon Sep 17 00:00:00 2001 From: akash Date: Mon, 1 Jun 2026 12:00:18 +0530 Subject: [PATCH 3/4] fix: comments --- scripts/deploy/checkReceiverDeployment.ts | 17 ++++++++++++++--- scripts/deploy/deployReceiverAndExecutor.ts | 9 +++++++++ src/BungeeReceiver.sol | 7 +++++++ src/CalldataExecutor.sol | 6 +++++- src/interfaces/ICalldataExecutor.sol | 2 +- src/lib/ExcessivelySafeCall.sol | 17 ++++++----------- 6 files changed, 42 insertions(+), 16 deletions(-) diff --git a/scripts/deploy/checkReceiverDeployment.ts b/scripts/deploy/checkReceiverDeployment.ts index e4a49f7..edf426b 100644 --- a/scripts/deploy/checkReceiverDeployment.ts +++ b/scripts/deploy/checkReceiverDeployment.ts @@ -66,9 +66,20 @@ async function main() { ); hasError = true; } else { - console.log( - `CalldataExecutor deployed on ${networkName} (chainId=${chainId}) at ${executorAddress}, BUNGEE_RECEIVER=${executorStatus.bungeeReceiver}`, - ); + if ( + executorStatus.bungeeReceiver?.toLowerCase() !== + receiverAddress.toLowerCase() + ) { + console.error( + `CalldataExecutor BUNGEE_RECEIVER mismatch on ${networkName} (chainId=${chainId}): ` + + `executor=${executorAddress}, expected receiver=${receiverAddress}, got ${executorStatus.bungeeReceiver}`, + ); + hasError = true; + } else { + console.log( + `CalldataExecutor deployed on ${networkName} (chainId=${chainId}) at ${executorAddress}, BUNGEE_RECEIVER=${executorStatus.bungeeReceiver}`, + ); + } } // ── Check BungeeReceiver ───────────────────────────────────────────────────── diff --git a/scripts/deploy/deployReceiverAndExecutor.ts b/scripts/deploy/deployReceiverAndExecutor.ts index 58570a2..7d38011 100644 --- a/scripts/deploy/deployReceiverAndExecutor.ts +++ b/scripts/deploy/deployReceiverAndExecutor.ts @@ -72,6 +72,15 @@ async function main() { }); if (executorStatus.deployed) { + if ( + executorStatus.bungeeReceiver?.toLowerCase() !== + receiverAddress.toLowerCase() + ) { + throw new Error( + `CalldataExecutor wiring mismatch at ${executorAddress}: ` + + `BUNGEE_RECEIVER=${executorStatus.bungeeReceiver}, expected ${receiverAddress}`, + ); + } console.log( `CalldataExecutor already deployed at ${executorAddress}, BUNGEE_RECEIVER=${executorStatus.bungeeReceiver}`, ); diff --git a/src/BungeeReceiver.sol b/src/BungeeReceiver.sol index a842446..9887782 100644 --- a/src/BungeeReceiver.sol +++ b/src/BungeeReceiver.sol @@ -58,6 +58,7 @@ contract BungeeReceiver is AccessControl { error InvalidSigner(); error InvalidNonce(); error InvalidExecution(); + error ZeroAddress(); // ========================================================================= // Events @@ -88,6 +89,9 @@ contract BungeeReceiver is AccessControl { * @param _calldataExecutor Address of the CalldataExecutor satellite contract. */ constructor(address _owner, address _solverSigner, address _calldataExecutor) AccessControl(_owner) { + if (_solverSigner == address(0) || _calldataExecutor == address(0)) { + revert ZeroAddress(); + } _grantRole(RESCUE_ROLE, _owner); SOLVER_SIGNER = _solverSigner; CALLDATA_EXECUTOR = _calldataExecutor; @@ -105,6 +109,9 @@ contract BungeeReceiver is AccessControl { * @param _solverSigner New signer address. */ function setSolverSigner(address _solverSigner) external onlyOwner { + if (_solverSigner == address(0)) { + revert ZeroAddress(); + } SOLVER_SIGNER = _solverSigner; emit SolverSignerUpdated(_solverSigner); } diff --git a/src/CalldataExecutor.sol b/src/CalldataExecutor.sol index b896119..e1fd0c0 100644 --- a/src/CalldataExecutor.sol +++ b/src/CalldataExecutor.sol @@ -16,6 +16,7 @@ contract CalldataExecutor is ICalldataExecutor { using ExcessivelySafeCall for address; error OnlyBungeeReceiver(); + error ZeroAddress(); address public immutable BUNGEE_RECEIVER; @@ -23,11 +24,14 @@ contract CalldataExecutor is ICalldataExecutor { uint16 public constant MAX_COPY_BYTES = 0; constructor(address _bungeeReceiver) { + if (_bungeeReceiver == address(0)) { + revert ZeroAddress(); + } BUNGEE_RECEIVER = _bungeeReceiver; } /// @inheritdoc ICalldataExecutor - function executeCalldata(address to, bytes memory encodedData, uint256 msgGasLimit) external returns (bool success) { + function executeCalldata(address to, bytes calldata encodedData, uint256 msgGasLimit) external returns (bool success) { if (msg.sender != BUNGEE_RECEIVER) { revert OnlyBungeeReceiver(); } diff --git a/src/interfaces/ICalldataExecutor.sol b/src/interfaces/ICalldataExecutor.sol index e994576..20c77d7 100644 --- a/src/interfaces/ICalldataExecutor.sol +++ b/src/interfaces/ICalldataExecutor.sol @@ -10,5 +10,5 @@ interface ICalldataExecutor { /// @param encodedData ABI-encoded calldata forwarded to `to`. /// @param msgGasLimit Gas forwarded to the call; prevents unbounded gas consumption. /// @return success Whether the call succeeded. - function executeCalldata(address to, bytes memory encodedData, uint256 msgGasLimit) external returns (bool success); + function executeCalldata(address to, bytes calldata encodedData, uint256 msgGasLimit) external returns (bool success); } diff --git a/src/lib/ExcessivelySafeCall.sol b/src/lib/ExcessivelySafeCall.sol index dfcd535..16321a6 100644 --- a/src/lib/ExcessivelySafeCall.sol +++ b/src/lib/ExcessivelySafeCall.sol @@ -19,20 +19,15 @@ library ExcessivelySafeCall { address _target, uint256 _gas, uint16 _maxCopy, - bytes memory _calldata + bytes calldata _calldata ) internal returns (bool success, bytes memory returnData) { uint256 _toCopy; returnData = new bytes(_maxCopy); - assembly { - success := call( - _gas, - _target, - 0, - add(_calldata, 0x20), - mload(_calldata), - 0, - 0 - ) + assembly ("memory-safe") { + let ptr := mload(0x40) + calldatacopy(ptr, _calldata.offset, _calldata.length) + mstore(0x40, and(add(add(ptr, _calldata.length), 0x1f), not(0x1f))) + success := call(_gas, _target, 0, ptr, _calldata.length, 0, 0) _toCopy := returndatasize() if gt(_toCopy, _maxCopy) { _toCopy := _maxCopy } mstore(returnData, _toCopy) From b11369e24775329bcd19aede3e5adce178f73110 Mon Sep 17 00:00:00 2001 From: akash Date: Mon, 1 Jun 2026 16:37:47 +0530 Subject: [PATCH 4/4] fix: added quoteId check to avoid double spends --- src/BungeeReceiver.sol | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/BungeeReceiver.sol b/src/BungeeReceiver.sol index 9887782..4c22b2d 100644 --- a/src/BungeeReceiver.sol +++ b/src/BungeeReceiver.sol @@ -59,6 +59,7 @@ contract BungeeReceiver is AccessControl { error InvalidNonce(); error InvalidExecution(); error ZeroAddress(); + error QuoteAlreadyExecuted(); // ========================================================================= // Events @@ -78,6 +79,7 @@ contract BungeeReceiver is AccessControl { address public immutable CALLDATA_EXECUTOR; mapping(uint256 => bool) public nonceUsed; + mapping(bytes32 => bool) public quoteIdExecuted; // ========================================================================= // Constructor @@ -147,6 +149,11 @@ contract BungeeReceiver is AccessControl { _useNonce(payload.nonce); + if (quoteIdExecuted[payload.quoteId]) { + revert QuoteAlreadyExecuted(); + } + quoteIdExecuted[payload.quoteId] = true; + // Transfer bridged funds to the IBungeeExecutor target before calling it (CEI). CurrencyLib.transfer(payload.bridgedToken, payload.target, payload.outputAmount);