From d4384e580153d526539f84d9896314e63de4d9f1 Mon Sep 17 00:00:00 2001 From: supervaulter Date: Mon, 10 Aug 2026 15:10:36 +0300 Subject: [PATCH 1/2] feat: added relay helper --- src/relay/RelayHelper.sol | 212 ++++++++++++++++++++++++++++++++ src/relay/interfaces/IERC20.sol | 79 ++++++++++++ 2 files changed, 291 insertions(+) create mode 100644 src/relay/RelayHelper.sol create mode 100644 src/relay/interfaces/IERC20.sol diff --git a/src/relay/RelayHelper.sol b/src/relay/RelayHelper.sol new file mode 100644 index 0000000..fad92f2 --- /dev/null +++ b/src/relay/RelayHelper.sol @@ -0,0 +1,212 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.0; + +/// library imports +import "forge-std/Test.sol"; +import {IERC20} from "./interfaces/IERC20.sol"; + +/// @title Relay Helper +/// @notice helps simulate Relay Protocol (relay.link) solver fills +/// @dev Relay differs structurally from Across/deBridge: the origin deposit +/// (RelayDepository.depositErc20/depositNative) carries only a bytes32 order id — NO +/// destination payload. The destination execution (txs[]) is quoted off-chain and executed +/// by the solver atomically via a router multicall (allowFailure = false). Consequently this +/// helper cannot derive the destination calls from origin logs; the caller supplies them as +/// parameters, and the helper (1) verifies a matching deposit event was emitted by the +/// depository and (2) executes the supplied txs[] in order on the destination fork under a +/// synthetic solver, reverting on the first failure to model the atomic batch. +contract RelayHelper is Test { + /// @dev Relay deposit events carry no indexed parameters — decode everything from log.data + bytes32 constant RelayErc20Deposit = keccak256("RelayErc20Deposit(address,address,uint256,bytes32)"); + bytes32 constant RelayNativeDeposit = keccak256("RelayNativeDeposit(address,uint256,bytes32)"); + + /// @notice a destination call in the solver's atomic fill batch (mirrors the quote API txs[]) + struct Call { + address to; + uint256 value; + bytes data; + } + + struct HelpArgs { + address depository; + bytes32 depositId; + address solver; + address outputToken; + uint256 outputAmount; + uint256 dstForkId; + Call[] dstTxs; + Vm.Log[] logs; + } + + ////////////////////////////////////////////////////////////// + // EXTERNAL FUNCTIONS // + ////////////////////////////////////////////////////////////// + + /// @notice simulates a Relay solver fill for a recorded deposit + /// @param depository the RelayDepository on the source chain (event emitter to match) + /// @param depositId the Relay order id the fill corresponds to (bytes32(0) = match any) + /// @param solver the synthetic solver address executing the fill + /// @param outputToken the token the solver delivers on the destination (address(0) = native) + /// @param outputAmount the amount the solver funds itself with before executing dstTxs + /// @param dstForkId the destination chain fork id + /// @param dstTxs the destination calls executed in order, atomically (first failure reverts) + /// @param logs the recorded source-chain logs (vm.getRecordedLogs()) + function help( + address depository, + bytes32 depositId, + address solver, + address outputToken, + uint256 outputAmount, + uint256 dstForkId, + Call[] memory dstTxs, + Vm.Log[] calldata logs + ) external { + _help( + HelpArgs({ + depository: depository, + depositId: depositId, + solver: solver, + outputToken: outputToken, + outputAmount: outputAmount, + dstForkId: dstForkId, + dstTxs: dstTxs, + logs: logs + }) + ); + } + + /// @notice convenience wrapper: deliver funds to `account` then call `target` with `data` + /// @dev models the primary Superform integration path — transfer to the smart account, + /// then call SuperDestinationExecutor.processBridgedExecution + function helpRelayDirect( + address depository, + bytes32 depositId, + address solver, + address outputToken, + uint256 outputAmount, + uint256 dstForkId, + address account, + address target, + bytes memory data, + Vm.Log[] calldata logs + ) external { + Call[] memory txs = new Call[](2); + if (outputToken == address(0)) { + txs[0] = Call({to: account, value: outputAmount, data: ""}); + } else { + txs[0] = + Call({to: outputToken, value: 0, data: abi.encodeWithSelector(IERC20.transfer.selector, account, outputAmount)}); + } + txs[1] = Call({to: target, value: 0, data: data}); + + _help( + HelpArgs({ + depository: depository, + depositId: depositId, + solver: solver, + outputToken: outputToken, + outputAmount: outputAmount, + dstForkId: dstForkId, + dstTxs: txs, + logs: logs + }) + ); + } + + /// @notice convenience wrapper: deliver funds to `adapter` then call adapter's entrypoint + /// @dev models the optional Superform adapter path — transfer to RelayAdapter, then + /// processRelayExecution; for native, value rides on the adapter call itself + function helpRelayViaAdapter( + address depository, + bytes32 depositId, + address solver, + address outputToken, + uint256 outputAmount, + uint256 dstForkId, + address adapter, + bytes memory adapterCalldata, + Vm.Log[] calldata logs + ) external { + Call[] memory txs; + if (outputToken == address(0)) { + txs = new Call[](1); + txs[0] = Call({to: adapter, value: outputAmount, data: adapterCalldata}); + } else { + txs = new Call[](2); + txs[0] = + Call({to: outputToken, value: 0, data: abi.encodeWithSelector(IERC20.transfer.selector, adapter, outputAmount)}); + txs[1] = Call({to: adapter, value: 0, data: adapterCalldata}); + } + + _help( + HelpArgs({ + depository: depository, + depositId: depositId, + solver: solver, + outputToken: outputToken, + outputAmount: outputAmount, + dstForkId: dstForkId, + dstTxs: txs, + logs: logs + }) + ); + } + + ////////////////////////////////////////////////////////////// + // INTERNAL FUNCTIONS // + ////////////////////////////////////////////////////////////// + + function _help(HelpArgs memory args) internal { + // 1. verify a matching deposit event was emitted by the depository on the source chain + require(_depositEventFound(args), "RelayHelper: no matching Relay deposit event"); + + // 2. execute the fill on the destination fork under the synthetic solver + uint256 prevForkId = vm.activeFork(); + vm.selectFork(args.dstForkId); + + // fund the solver with the output it delivers (solvers fill from their own capital) + if (args.outputToken == address(0)) { + vm.deal(args.solver, args.solver.balance + args.outputAmount); + } else { + deal(args.outputToken, args.solver, args.outputAmount); + } + + // execute txs[] in order; revert on first failure — models the router's atomic + // multicall with allowFailure = false (a failed fill unwinds entirely into refund) + vm.startPrank(args.solver); + for (uint256 i; i < args.dstTxs.length; i++) { + (bool success, bytes memory ret) = args.dstTxs[i].to.call{value: args.dstTxs[i].value}(args.dstTxs[i].data); + if (!success) { + // bubble the inner revert reason for debuggability + if (ret.length > 0) { + assembly { + revert(add(ret, 0x20), mload(ret)) + } + } + revert("RelayHelper: destination call failed"); + } + } + vm.stopPrank(); + + vm.selectFork(prevForkId); + } + + /// @dev scans logs for a RelayErc20Deposit/RelayNativeDeposit emitted by the depository, + /// optionally matching a specific deposit id (both events have NO indexed params) + function _depositEventFound(HelpArgs memory args) internal pure returns (bool) { + for (uint256 i; i < args.logs.length; i++) { + if (args.logs[i].emitter != args.depository || args.logs[i].topics.length == 0) { + continue; + } + + if (args.logs[i].topics[0] == RelayErc20Deposit) { + (,,, bytes32 id) = abi.decode(args.logs[i].data, (address, address, uint256, bytes32)); + if (args.depositId == bytes32(0) || id == args.depositId) return true; + } else if (args.logs[i].topics[0] == RelayNativeDeposit) { + (,, bytes32 id) = abi.decode(args.logs[i].data, (address, uint256, bytes32)); + if (args.depositId == bytes32(0) || id == args.depositId) return true; + } + } + return false; + } +} diff --git a/src/relay/interfaces/IERC20.sol b/src/relay/interfaces/IERC20.sol new file mode 100644 index 0000000..7d10195 --- /dev/null +++ b/src/relay/interfaces/IERC20.sol @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts (last updated v5.1.0) (token/ERC20/IERC20.sol) + +pragma solidity ^0.8.20; + +/** + * @dev Interface of the ERC-20 standard as defined in the ERC. + */ +interface IERC20 { + /** + * @dev Emitted when `value` tokens are moved from one account (`from`) to + * another (`to`). + * + * Note that `value` may be zero. + */ + event Transfer(address indexed from, address indexed to, uint256 value); + + /** + * @dev Emitted when the allowance of a `spender` for an `owner` is set by + * a call to {approve}. `value` is the new allowance. + */ + event Approval(address indexed owner, address indexed spender, uint256 value); + + /** + * @dev Returns the value of tokens in existence. + */ + function totalSupply() external view returns (uint256); + + /** + * @dev Returns the value of tokens owned by `account`. + */ + function balanceOf(address account) external view returns (uint256); + + /** + * @dev Moves a `value` amount of tokens from the caller's account to `to`. + * + * Returns a boolean value indicating whether the operation succeeded. + * + * Emits a {Transfer} event. + */ + function transfer(address to, uint256 value) external returns (bool); + + /** + * @dev Returns the remaining number of tokens that `spender` will be + * allowed to spend on behalf of `owner` through {transferFrom}. This is + * zero by default. + * + * This value changes when {approve} or {transferFrom} are called. + */ + function allowance(address owner, address spender) external view returns (uint256); + + /** + * @dev Sets a `value` amount of tokens as the allowance of `spender` over the + * caller's tokens. + * + * Returns a boolean value indicating whether the operation succeeded. + * + * IMPORTANT: Beware that changing an allowance with this method brings the risk + * that someone may use both the old and the new allowance by unfortunate + * transaction ordering. One possible solution to mitigate this race + * condition is to first reduce the spender's allowance to 0 and set the + * desired value afterwards: + * https://github.com/ethereum/EIPs/issues/20#issuecomment-263524729 + * + * Emits an {Approval} event. + */ + function approve(address spender, uint256 value) external returns (bool); + + /** + * @dev Moves a `value` amount of tokens from `from` to `to` using the + * allowance mechanism. `value` is then deducted from the caller's + * allowance. + * + * Returns a boolean value indicating whether the operation succeeded. + * + * Emits a {Transfer} event. + */ + function transferFrom(address from, address to, uint256 value) external returns (bool); +} From c0dad5b48b3b133ff56ff627014495a259756ec4 Mon Sep 17 00:00:00 2001 From: supervaulter Date: Mon, 10 Aug 2026 15:19:53 +0300 Subject: [PATCH 2/2] chore: relay tests --- test/Relay.t.sol | 356 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 356 insertions(+) create mode 100644 test/Relay.t.sol diff --git a/test/Relay.t.sol b/test/Relay.t.sol new file mode 100644 index 0000000..67c0c55 --- /dev/null +++ b/test/Relay.t.sol @@ -0,0 +1,356 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.0; + +import "forge-std/Test.sol"; + +import {RelayHelper} from "src/relay/RelayHelper.sol"; +import {IERC20} from "src/relay/interfaces/IERC20.sol"; + +/// @dev minimal RelayDepository stand-in: RelayHelper only matches the deposit events by +/// emitter + topic, so a mock emitting the canonical event signatures is sufficient +contract MockRelayDepository { + event RelayErc20Deposit(address from, address token, uint256 amount, bytes32 id); + event RelayNativeDeposit(address from, uint256 amount, bytes32 id); + + function depositErc20(address token, uint256 amount, bytes32 id) external { + IERC20(token).transferFrom(msg.sender, address(this), amount); + emit RelayErc20Deposit(msg.sender, token, amount, id); + } + + function depositNative(bytes32 id) external payable { + emit RelayNativeDeposit(msg.sender, msg.value, id); + } +} + +/// @dev destination target standing in for SuperDestinationExecutor.processBridgedExecution +contract Target { + uint256 public value; + address public caller; + + error BOOM(); + + function processBridgedExecution(uint256 _value) external payable { + value = _value; + caller = msg.sender; + } + + function alwaysReverts() external pure { + revert BOOM(); + } +} + +/// @dev destination adapter standing in for RelayAdapter.processRelayExecution +contract MockAdapter { + address public token; + uint256 public value; + uint256 public fundsAtExecution; + + constructor(address _token) { + token = _token; + } + + function processRelayExecution(uint256 _value) external payable { + value = _value; + fundsAtExecution = token == address(0) ? address(this).balance : IERC20(token).balanceOf(address(this)); + } +} + +contract RelayHelperTest is Test { + RelayHelper relayHelper; + MockRelayDepository depository; + Target target; + + uint256 L1_FORK_ID; + uint256 POLYGON_FORK_ID; + + address constant L1_USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; + address constant POLYGON_USDC = 0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359; + + address constant SOLVER = address(0x501e4); + address constant ACCOUNT = address(0xCAFE); + bytes32 constant DEPOSIT_ID = keccak256("relay-order-1"); + + string RPC_ETH_MAINNET = vm.envString("ETH_MAINNET_RPC_URL"); + string RPC_POLYGON_MAINNET = vm.envString("POLYGON_MAINNET_RPC_URL"); + + function setUp() external { + L1_FORK_ID = vm.createSelectFork(RPC_ETH_MAINNET, 21580621); + relayHelper = new RelayHelper(); + depository = new MockRelayDepository(); + + POLYGON_FORK_ID = vm.createSelectFork(RPC_POLYGON_MAINNET, 66450382); + target = new Target(); + + vm.selectFork(L1_FORK_ID); + } + + ////////////////////////////////////////////////////////////// + // DIRECT PATH // + ////////////////////////////////////////////////////////////// + + function testRelayErc20Direct() external { + vm.recordLogs(); + _depositErc20(100e6, DEPOSIT_ID); + Vm.Log[] memory logs = vm.getRecordedLogs(); + + relayHelper.helpRelayDirect( + address(depository), + DEPOSIT_ID, + SOLVER, + POLYGON_USDC, + 99e6, // output < input models the solver fee + POLYGON_FORK_ID, + ACCOUNT, + address(target), + abi.encodeCall(Target.processBridgedExecution, (42)), + logs + ); + + // helper must restore the source fork + assertEq(vm.activeFork(), L1_FORK_ID); + + vm.selectFork(POLYGON_FORK_ID); + assertEq(IERC20(POLYGON_USDC).balanceOf(ACCOUNT), 99e6); + assertEq(target.value(), 42); + assertEq(target.caller(), SOLVER); + } + + function testRelayNativeDirect() external { + vm.recordLogs(); + _depositNative(1 ether, DEPOSIT_ID); + Vm.Log[] memory logs = vm.getRecordedLogs(); + + vm.selectFork(POLYGON_FORK_ID); + uint256 accountBalanceBefore = ACCOUNT.balance; + vm.selectFork(L1_FORK_ID); + + relayHelper.helpRelayDirect( + address(depository), + DEPOSIT_ID, + SOLVER, + address(0), + 0.99 ether, + POLYGON_FORK_ID, + ACCOUNT, + address(target), + abi.encodeCall(Target.processBridgedExecution, (7)), + logs + ); + + vm.selectFork(POLYGON_FORK_ID); + assertEq(ACCOUNT.balance - accountBalanceBefore, 0.99 ether); + assertEq(target.value(), 7); + assertEq(target.caller(), SOLVER); + } + + ////////////////////////////////////////////////////////////// + // ADAPTER PATH // + ////////////////////////////////////////////////////////////// + + function testRelayViaAdapterErc20() external { + vm.selectFork(POLYGON_FORK_ID); + MockAdapter adapter = new MockAdapter(POLYGON_USDC); + vm.selectFork(L1_FORK_ID); + + vm.recordLogs(); + _depositErc20(100e6, DEPOSIT_ID); + Vm.Log[] memory logs = vm.getRecordedLogs(); + + relayHelper.helpRelayViaAdapter( + address(depository), + DEPOSIT_ID, + SOLVER, + POLYGON_USDC, + 99e6, + POLYGON_FORK_ID, + address(adapter), + abi.encodeCall(MockAdapter.processRelayExecution, (42)), + logs + ); + + vm.selectFork(POLYGON_FORK_ID); + assertEq(adapter.value(), 42); + // tokens must arrive BEFORE the adapter entrypoint runs + assertEq(adapter.fundsAtExecution(), 99e6); + assertEq(IERC20(POLYGON_USDC).balanceOf(address(adapter)), 99e6); + } + + function testRelayViaAdapterNative() external { + vm.selectFork(POLYGON_FORK_ID); + MockAdapter adapter = new MockAdapter(address(0)); + vm.selectFork(L1_FORK_ID); + + vm.recordLogs(); + _depositNative(1 ether, DEPOSIT_ID); + Vm.Log[] memory logs = vm.getRecordedLogs(); + + relayHelper.helpRelayViaAdapter( + address(depository), + DEPOSIT_ID, + SOLVER, + address(0), + 0.99 ether, + POLYGON_FORK_ID, + address(adapter), + abi.encodeCall(MockAdapter.processRelayExecution, (7)), + logs + ); + + vm.selectFork(POLYGON_FORK_ID); + assertEq(adapter.value(), 7); + // for native, value rides on the adapter call itself + assertEq(adapter.fundsAtExecution(), 0.99 ether); + assertEq(address(adapter).balance, 0.99 ether); + } + + ////////////////////////////////////////////////////////////// + // RAW help() // + ////////////////////////////////////////////////////////////// + + function testRelayCustomTxs() external { + vm.recordLogs(); + _depositErc20(100e6, DEPOSIT_ID); + Vm.Log[] memory logs = vm.getRecordedLogs(); + + RelayHelper.Call[] memory txs = new RelayHelper.Call[](3); + txs[0] = RelayHelper.Call({ + to: POLYGON_USDC, + value: 0, + data: abi.encodeWithSelector(IERC20.transfer.selector, ACCOUNT, 60e6) + }); + txs[1] = RelayHelper.Call({ + to: POLYGON_USDC, + value: 0, + data: abi.encodeWithSelector(IERC20.transfer.selector, address(target), 39e6) + }); + txs[2] = RelayHelper.Call({to: address(target), value: 0, data: abi.encodeCall(Target.processBridgedExecution, (99))}); + + relayHelper.help(address(depository), DEPOSIT_ID, SOLVER, POLYGON_USDC, 99e6, POLYGON_FORK_ID, txs, logs); + + vm.selectFork(POLYGON_FORK_ID); + assertEq(IERC20(POLYGON_USDC).balanceOf(ACCOUNT), 60e6); + assertEq(IERC20(POLYGON_USDC).balanceOf(address(target)), 39e6); + assertEq(target.value(), 99); + } + + function testRelayMatchesAnyDepositId() external { + vm.recordLogs(); + _depositErc20(100e6, DEPOSIT_ID); + Vm.Log[] memory logs = vm.getRecordedLogs(); + + relayHelper.helpRelayDirect( + address(depository), + bytes32(0), // wildcard + SOLVER, + POLYGON_USDC, + 99e6, + POLYGON_FORK_ID, + ACCOUNT, + address(target), + abi.encodeCall(Target.processBridgedExecution, (42)), + logs + ); + + vm.selectFork(POLYGON_FORK_ID); + assertEq(target.value(), 42); + } + + ////////////////////////////////////////////////////////////// + // FAILURE MODES // + ////////////////////////////////////////////////////////////// + + function testRelayRevertsOnDepositIdMismatch() external { + vm.recordLogs(); + _depositErc20(100e6, DEPOSIT_ID); + Vm.Log[] memory logs = vm.getRecordedLogs(); + + vm.expectRevert("RelayHelper: no matching Relay deposit event"); + relayHelper.helpRelayDirect( + address(depository), + keccak256("some-other-order"), + SOLVER, + POLYGON_USDC, + 99e6, + POLYGON_FORK_ID, + ACCOUNT, + address(target), + abi.encodeCall(Target.processBridgedExecution, (42)), + logs + ); + } + + function testRelayRevertsOnWrongEmitter() external { + MockRelayDepository otherDepository = new MockRelayDepository(); + + vm.recordLogs(); + _depositErc20(100e6, DEPOSIT_ID); + Vm.Log[] memory logs = vm.getRecordedLogs(); + + vm.expectRevert("RelayHelper: no matching Relay deposit event"); + relayHelper.helpRelayDirect( + address(otherDepository), + DEPOSIT_ID, + SOLVER, + POLYGON_USDC, + 99e6, + POLYGON_FORK_ID, + ACCOUNT, + address(target), + abi.encodeCall(Target.processBridgedExecution, (42)), + logs + ); + } + + function testRelayRevertsOnEmptyLogs() external { + Vm.Log[] memory logs = new Vm.Log[](0); + + vm.expectRevert("RelayHelper: no matching Relay deposit event"); + relayHelper.helpRelayDirect( + address(depository), + DEPOSIT_ID, + SOLVER, + POLYGON_USDC, + 99e6, + POLYGON_FORK_ID, + ACCOUNT, + address(target), + abi.encodeCall(Target.processBridgedExecution, (42)), + logs + ); + } + + function testRelayAtomicBatchBubblesInnerRevert() external { + vm.recordLogs(); + _depositErc20(100e6, DEPOSIT_ID); + Vm.Log[] memory logs = vm.getRecordedLogs(); + + vm.expectRevert(Target.BOOM.selector); + relayHelper.helpRelayDirect( + address(depository), + DEPOSIT_ID, + SOLVER, + POLYGON_USDC, + 99e6, + POLYGON_FORK_ID, + ACCOUNT, + address(target), + abi.encodeCall(Target.alwaysReverts, ()), + logs + ); + } + + ////////////////////////////////////////////////////////////// + // HELPERS // + ////////////////////////////////////////////////////////////// + + function _depositErc20(uint256 amount, bytes32 id) internal { + deal(L1_USDC, address(this), amount); + IERC20(L1_USDC).approve(address(depository), amount); + depository.depositErc20(L1_USDC, amount, id); + } + + function _depositNative(uint256 amount, bytes32 id) internal { + vm.deal(address(this), amount); + depository.depositNative{value: amount}(id); + } +}