diff --git a/scripts/deploy/checkReceiverDeployment.ts b/scripts/deploy/checkReceiverDeployment.ts new file mode 100644 index 0000000..edf426b --- /dev/null +++ b/scripts/deploy/checkReceiverDeployment.ts @@ -0,0 +1,122 @@ +/** + * 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 { + 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 ───────────────────────────────────────────────────── + + 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 0e05ca9..71001d7 100644 --- a/scripts/deploy/create3.ts +++ b/scripts/deploy/create3.ts @@ -6,11 +6,25 @@ 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), /** CREATE3 salt label used by `deployAcrossERC20AmountManipulator.ts`. */ export const ACROSS_MANIPULATOR_CREATE3_SALT_TEXT = 'AcrossERC20AmountManipulator' + '1'; @@ -167,3 +181,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..7d38011 --- /dev/null +++ b/scripts/deploy/deployReceiverAndExecutor.ts @@ -0,0 +1,184 @@ +/** + * 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) { + 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}`, + ); + } 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..4c22b2d --- /dev/null +++ b/src/BungeeReceiver.sol @@ -0,0 +1,228 @@ +// 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. 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` +/// 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 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; + /// @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; + } + + // ========================================================================= + // Errors + // ========================================================================= + + error InvalidSigner(); + error InvalidNonce(); + error InvalidExecution(); + error ZeroAddress(); + error QuoteAlreadyExecuted(); + + // ========================================================================= + // Events + // ========================================================================= + + /// @param quoteId Correlation ID of the executed quote. + /// @param success Whether the IBungeeExecutor.executeData call succeeded. + event DestPayloadExecuted(bytes32 indexed quoteId, bool success); + + event SolverSignerUpdated(address indexed newSigner); + + // ========================================================================= + // State + // ========================================================================= + + address public SOLVER_SIGNER; + address public immutable CALLDATA_EXECUTOR; + + mapping(uint256 => bool) public nonceUsed; + mapping(bytes32 => bool) public quoteIdExecuted; + + // ========================================================================= + // 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) { + if (_solverSigner == address(0) || _calldataExecutor == address(0)) { + revert ZeroAddress(); + } + _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 { + if (_solverSigner == address(0)) { + revert ZeroAddress(); + } + 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` plus `executionCallData` is covered by the + * SOLVER_SIGNER signature — modifying any field invalidates the signature. + * + * `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 executionCallData, + bytes calldata signature + ) external { + if (payload.target == address(0)) { + revert InvalidExecution(); + } + + _verifySignature( + keccak256(abi.encode(block.chainid, address(this), payload, keccak256(executionCallData))), + signature + ); + + _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); + + bool success = _callExecutor(payload.quoteId, payload.bridgedToken, payload.outputAmount, payload.target, payload.gasLimit, executionCallData); + + emit DestPayloadExecuted(payload.quoteId, success); + } + + /** + * @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 Encodes and dispatches IBungeeExecutor.executeData via CalldataExecutor. + * CalldataExecutor uses excessivelySafeCall so a revert in the target does NOT revert here; + * `success` is emitted in the event for the backend to detect and handle. + */ + function _callExecutor( + bytes32 quoteId, + address token, + uint256 amount, + address target, + uint256 gasLimit, + bytes calldata executionCallData + ) internal returns (bool success) { + bytes memory executeDataCalldata = abi.encodeCall( + IBungeeExecutor.executeData, + (quoteId, amount, token, executionCallData) + ); + + success = ICalldataExecutor(CALLDATA_EXECUTOR).executeCalldata(target, executeDataCalldata, gasLimit); + } + + /** + * @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; 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 { + 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) + } + } +} diff --git a/src/CalldataExecutor.sol b/src/CalldataExecutor.sol new file mode 100644 index 0000000..e1fd0c0 --- /dev/null +++ b/src/CalldataExecutor.sol @@ -0,0 +1,41 @@ +// 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(); + error ZeroAddress(); + + 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) { + if (_bungeeReceiver == address(0)) { + revert ZeroAddress(); + } + BUNGEE_RECEIVER = _bungeeReceiver; + } + + /// @inheritdoc ICalldataExecutor + function executeCalldata(address to, bytes calldata 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/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 new file mode 100644 index 0000000..495710f --- /dev/null +++ b/src/interfaces/IBungeeExecutor.sol @@ -0,0 +1,19 @@ +// 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 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 amount, + address token, + bytes calldata callData + ) external payable; +} diff --git a/src/interfaces/ICalldataExecutor.sol b/src/interfaces/ICalldataExecutor.sol new file mode 100644 index 0000000..20c77d7 --- /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 calldata 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..16321a6 --- /dev/null +++ b/src/lib/ExcessivelySafeCall.sol @@ -0,0 +1,37 @@ +// 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 calldata _calldata + ) internal returns (bool success, bytes memory returnData) { + uint256 _toCopy; + returnData = new bytes(_maxCopy); + 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) + returndatacopy(add(returnData, 0x20), 0, _toCopy) + } + } +} 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 {} +}