From b9f9132b9ff2a559de25c9b57559febc54dbc0f4 Mon Sep 17 00:00:00 2001 From: Kevin Halliday Date: Thu, 31 Oct 2024 17:33:43 -0400 Subject: [PATCH] feat(contracts/core): start solve inbox Start solve inbox prototype. Introduce single Inbox.request() method. --- contracts/core/.gas-snapshot | 22 +- contracts/core/src/solve/Inbox.sol | 114 +++++++++ contracts/core/src/solve/Solve.sol | 70 ++++++ contracts/core/test/solve/Inbox_request.t.sol | 216 ++++++++++++++++++ contracts/core/test/utils/MockToken.sol | 16 ++ 5 files changed, 430 insertions(+), 8 deletions(-) create mode 100644 contracts/core/src/solve/Inbox.sol create mode 100644 contracts/core/src/solve/Solve.sol create mode 100644 contracts/core/test/solve/Inbox_request.t.sol create mode 100644 contracts/core/test/utils/MockToken.sol diff --git a/contracts/core/.gas-snapshot b/contracts/core/.gas-snapshot index 448b07604..0fa20ce0e 100644 --- a/contracts/core/.gas-snapshot +++ b/contracts/core/.gas-snapshot @@ -3,17 +3,17 @@ Admin_Test:test_pause_unpause_bridge() (gas: 21501844) Admin_Test:test_pause_unpause_xcall() (gas: 26426087) Admin_Test:test_pause_unpause_xsubmit() (gas: 26425838) Admin_Test:test_upgrade() (gas: 30507332) -AllocPredeploys_Test:test_num_allocs() (gas: 1181152549) +AllocPredeploys_Test:test_num_allocs() (gas: 1181198035) AllocPredeploys_Test:test_predeploys() (gas: 1181134337) AllocPredeploys_Test:test_preinstalls() (gas: 1181850775) AllocPredeploys_Test:test_proxies() (gas: 1408777576) -FeeOracleV1_Test:test_bulkSetFeeParams() (gas: 172862) -FeeOracleV1_Test:test_feeFor() (gas: 122551) -FeeOracleV1_Test:test_setBaseGasLimit() (gas: 32208) -FeeOracleV1_Test:test_setGasPrice() (gas: 40996) -FeeOracleV1_Test:test_setManager() (gas: 45845) -FeeOracleV1_Test:test_setProtocolFee() (gas: 31442) -FeeOracleV1_Test:test_setToNativeRate() (gas: 41049) +FeeOracleV1_Test:test_bulkSetFeeParams() (gas: 173154) +FeeOracleV1_Test:test_feeFor() (gas: 122830) +FeeOracleV1_Test:test_setBaseGasLimit() (gas: 32375) +FeeOracleV1_Test:test_setGasPrice() (gas: 41034) +FeeOracleV1_Test:test_setManager() (gas: 45904) +FeeOracleV1_Test:test_setProtocolFee() (gas: 31610) +FeeOracleV1_Test:test_setToNativeRate() (gas: 41132) FeeOracleV2_Test:test_bulkSetFeeParams() (gas: 119117) FeeOracleV2_Test:test_feeFor() (gas: 103301) FeeOracleV2_Test:test_setBaseGasLimit() (gas: 32009) @@ -22,6 +22,12 @@ FeeOracleV2_Test:test_setExecGasPrice() (gas: 44247) FeeOracleV2_Test:test_setManager() (gas: 45775) FeeOracleV2_Test:test_setProtocolFee() (gas: 32226) FeeOracleV2_Test:test_setToNativeRate() (gas: 43640) +Inbox_request_Test:test_request_multiToken() (gas: 547583) +Inbox_request_Test:test_request_nativeMultiToken() (gas: 605757) +Inbox_request_Test:test_request_reverts() (gas: 891165) +Inbox_request_Test:test_request_singleNative() (gas: 364015) +Inbox_request_Test:test_request_singleToken() (gas: 426691) +Inbox_request_Test:test_request_two() (gas: 671465) InitializableHelper_Test:test_disableInitalizers() (gas: 181686) InitializableHelper_Test:test_getInitialized() (gas: 178023) OmniBridgeL1_Test:test_bridge() (gas: 233678) diff --git a/contracts/core/src/solve/Inbox.sol b/contracts/core/src/solve/Inbox.sol new file mode 100644 index 000000000..a805a0bc3 --- /dev/null +++ b/contracts/core/src/solve/Inbox.sol @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity =0.8.24; + +import { ReentrancyGuardUpgradeable } from "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardUpgradeable.sol"; +import { SafeERC20, IERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { Solve } from "./Solve.sol"; + +/** + * @title Inbox + * @notice Entrypoint and alt-mempoool for user solve requests. + */ +contract Inbox is ReentrancyGuardUpgradeable { + using SafeERC20 for IERC20; + + error NoDeposits(); + error InvalidCall(); + error InvalidDeposit(); + error ZeroDeposit(); + + /** + * @notice Emitted when a request is created. + * @param id ID of the request. + * @param from Address of the user who created the request. + * @param call Details of the call to be executed on another chain. + * @param deposits Array of deposits backing the request. + */ + event Requested(bytes32 indexed id, address indexed from, Solve.Call call, Solve.Deposit[] deposits); + + /** + * @dev uint repr of last assigned request ID. + */ + uint256 internal _lastId; + + /** + * @notice Map ID to request. + */ + mapping(bytes32 id => Solve.Request) internal _requests; + + function initialize() public initializer { + __ReentrancyGuard_init(); + } + + /** + * @notice Returns the request with the given ID. + */ + function getRequest(bytes32 id) external view returns (Solve.Request memory) { + return _requests[id]; + } + + /** + * @notice Open a request to execute a call on another chain, backed by deposits. + * Token deposits are transferred from msg.sender to this inbox. + * @param call Details of the call to be executed on another chain. + * @param deposits Array of deposits backing the request. + */ + function request(Solve.Call calldata call, Solve.TokenDeposit[] calldata deposits) + external + payable + nonReentrant + returns (bytes32 id) + { + if (call.target == address(0)) revert InvalidCall(); + if (call.destChainId == 0) revert InvalidCall(); + if (call.data.length == 0) revert InvalidCall(); + if (deposits.length == 0 && msg.value == 0) revert NoDeposits(); + + Solve.Request storage req = _openRequest(msg.sender, call, deposits); + + emit Requested(req.id, req.from, req.call, req.deposits); + + return req.id; + } + + /** + * @dev Open a new request in storage at `id`. + * Transfer token deposits from msg.sender to this inbox. + * Duplicate token addresses are allowed. + */ + function _openRequest(address from, Solve.Call calldata call, Solve.TokenDeposit[] calldata deposits) + internal + returns (Solve.Request storage req) + { + bytes32 id = _nextId(); + + req = _requests[id]; + req.id = id; + req.updatedAt = uint40(block.timestamp); + req.from = from; + req.call = call; + + if (msg.value > 0) { + req.deposits.push(Solve.Deposit({ isNative: true, token: address(0), amount: msg.value })); + } + + for (uint256 i = 0; i < deposits.length; i++) { + if (deposits[i].amount == 0) revert InvalidDeposit(); + if (deposits[i].token == address(0)) revert InvalidDeposit(); + + req.deposits.push(Solve.Deposit({ isNative: false, token: deposits[i].token, amount: deposits[i].amount })); + + // NOTE: all external methods must be nonReentrant + // This allows us to transfer while opening the request - saving some gas. + IERC20(deposits[i].token).safeTransferFrom(msg.sender, address(this), deposits[i].amount); + } + } + + /** + * @dev Increment and return _lastId. + */ + function _nextId() internal returns (bytes32) { + _lastId++; + return bytes32(_lastId); + } +} diff --git a/contracts/core/src/solve/Solve.sol b/contracts/core/src/solve/Solve.sol new file mode 100644 index 000000000..641358a8a --- /dev/null +++ b/contracts/core/src/solve/Solve.sol @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity =0.8.24; + +library Solve { + enum Status { + Open, + Accepted, + Cancelled, + Rejected, + Fulfilled, + Paid + } + + /** + * @notice A request to execute a call on another chain, backed by a deposit. + * @param id ID for the request, globally unique per inbox. + * @param updatedAt Timestamp request status was last updated. + * @param from Address of the user who created the request. + * @param fulfilledBy Address of the solver that fulfilled the request. + * @param status Request status (open, accepted, cancelled, rejected, fulfilled, paid). + * @param call Details of the call to be executed on another chain. + * @param deposits Array of deposits backing the request. + */ + struct Request { + bytes32 id; + uint40 updatedAt; + Status status; + address from; + address fulfilledBy; + Call call; + Deposit[] deposits; + } + + /** + * @notice Details of a call to be executed on another chain. + * @param destChainId ID of the destination chain. + * @param value Amount of native currency to send with the call. + * @param target Address of the target contract on the destination chain. + * @param data Encoded data to be sent with the call. + */ + struct Call { + uint64 destChainId; + address target; + uint256 value; + bytes data; + } + + /** + * @notice Details of a deposit backing a request. + * @param isNative Whether the deposit is in native currency. + * @param token Address of the token, address(0) if native. + * @param amount Deposit amount. + */ + struct Deposit { + bool isNative; + address token; + uint256 amount; + } + + /** + * @notice Details of a token deposit backing a request. + * @dev Not stored, only used in opening a request. + * @param token Address of the token. + * @param amount Deposit amount. + */ + struct TokenDeposit { + address token; + uint256 amount; + } +} diff --git a/contracts/core/test/solve/Inbox_request.t.sol b/contracts/core/test/solve/Inbox_request.t.sol new file mode 100644 index 000000000..6116ed91d --- /dev/null +++ b/contracts/core/test/solve/Inbox_request.t.sol @@ -0,0 +1,216 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity =0.8.24; + +import { MockToken } from "test/utils/MockToken.sol"; +import { Inbox } from "src/solve/Inbox.sol"; +import { Solve } from "src/solve/Solve.sol"; +import { Test } from "forge-std/Test.sol"; + +/** + * @title Inbox_request_Test + * @notice Test suite for solver Inbox.request(...) + * @dev TODO: add fuzz / invariant tests + */ +contract Inbox_request_Test is Test { + Inbox inbox; + + MockToken token1; + MockToken token2; + + address user = makeAddr("user"); + + modifier prankUser() { + vm.startPrank(user); + _; + vm.stopPrank(); + } + + function setUp() public { + inbox = new Inbox(); + token1 = new MockToken(); + token2 = new MockToken(); + } + + /// @dev Test all revert conditions for Inbox.request(...) + function test_request_reverts() public prankUser { + Solve.Call memory call = Solve.Call({ destChainId: 0, value: 0, target: address(0), data: bytes("") }); + Solve.TokenDeposit[] memory deposits = new Solve.TokenDeposit[](0); + + // needs call.target + vm.expectRevert(Inbox.InvalidCall.selector); + inbox.request(call, deposits); + call.target = address(1); + + // needs destChainId + vm.expectRevert(Inbox.InvalidCall.selector); + inbox.request(call, deposits); + call.destChainId = 1; + + // needs data + vm.expectRevert(Inbox.InvalidCall.selector); + inbox.request(call, deposits); + call.data = bytes("data"); + + // needs deposits + vm.expectRevert(Inbox.NoDeposits.selector); + inbox.request(call, deposits); + deposits = new Solve.TokenDeposit[](1); + + // needs non-zero amount + vm.expectRevert(Inbox.InvalidDeposit.selector); + inbox.request(call, deposits); + deposits[0].amount = 1 ether; + + // needs non-zero token + vm.expectRevert(Inbox.InvalidDeposit.selector); + inbox.request(call, deposits); + deposits[0].token = address(token1); + + // needs balalnce & allowance. we do not test ERC20 errors here + vm.expectRevert(); + inbox.request(call, deposits); + mintAndApprove(deposits); + + // success + inbox.request(call, deposits); + } + + /// @dev Test a single token deposit + function test_request_singleToken() public prankUser { + Solve.Call memory call = randCall(); + Solve.TokenDeposit[] memory deposits = new Solve.TokenDeposit[](1); + deposits[0] = Solve.TokenDeposit({ token: address(token1), amount: 1 ether }); + + mintAndApprove(deposits); + + bytes32 id = inbox.request(call, deposits); + assertEq(token1.balanceOf(address(inbox)), deposits[0].amount, "token1.balanceOf(inbox)"); + assertEq(token1.balanceOf(user), 0, "token1.balanceOf(user)"); + + assertNewRequest({ id: id, from: user, call: call, deposits: deposits, nativeDeposit: 0 }); + } + + /// @dev Test multiple token deposits + function test_request_multiToken() public prankUser { + Solve.Call memory call = randCall(); + Solve.TokenDeposit[] memory deposits = new Solve.TokenDeposit[](2); + + deposits[0] = Solve.TokenDeposit({ token: address(token1), amount: 1 ether }); + deposits[1] = Solve.TokenDeposit({ token: address(token2), amount: 2 ether }); + + mintAndApprove(deposits); + + bytes32 id = inbox.request(call, deposits); + assertEq(token1.balanceOf(address(inbox)), deposits[0].amount, "token1.balanceOf(inbox)"); + assertEq(token2.balanceOf(address(inbox)), deposits[1].amount, "token2.balanceOf(inbox)"); + assertEq(token1.balanceOf(user), 0, "token1.balanceOf(user)"); + assertEq(token2.balanceOf(user), 0, "token2.balanceOf(user)"); + + assertNewRequest({ id: id, from: user, call: call, deposits: deposits, nativeDeposit: 0 }); + } + + /// @dev Test a single native deposit + function test_request_singleNative() public prankUser { + Solve.Call memory call = randCall(); + Solve.TokenDeposit[] memory deposits = new Solve.TokenDeposit[](0); + + vm.deal(user, 1 ether); + + bytes32 id = inbox.request{ value: 1 ether }(call, deposits); + assertEq(address(inbox).balance, 1 ether, "inbox.balance"); + + assertNewRequest({ id: id, from: user, call: call, deposits: deposits, nativeDeposit: 1 ether }); + } + + /// @dev Test multiple native deposits + function test_request_nativeMultiToken() public prankUser { + Solve.Call memory call = randCall(); + Solve.TokenDeposit[] memory deposits = new Solve.TokenDeposit[](2); + + deposits[0] = Solve.TokenDeposit({ token: address(token1), amount: 1 ether }); + deposits[1] = Solve.TokenDeposit({ token: address(token2), amount: 2 ether }); + + vm.deal(user, 3 ether); + mintAndApprove(deposits); + + bytes32 id = inbox.request{ value: 3 ether }(call, deposits); + + assertEq(address(inbox).balance, 3 ether, "inbox.balance"); + assertEq(token1.balanceOf(address(inbox)), deposits[0].amount, "token1.balanceOf(inbox)"); + assertEq(token2.balanceOf(address(inbox)), deposits[1].amount, "token2.balanceOf(inbox)"); + assertEq(token1.balanceOf(user), 0, "token1.balanceOf(user)"); + assertEq(token2.balanceOf(user), 0, "token2.balanceOf(user)"); + + assertNewRequest({ id: id, from: user, call: call, deposits: deposits, nativeDeposit: 3 ether }); + } + + /// @dev Test opening two requests + function test_request_two() public prankUser { + Solve.Call memory call = randCall(); + Solve.TokenDeposit[] memory deposits = new Solve.TokenDeposit[](0); + + vm.deal(user, 3 ether); + + bytes32 id1 = inbox.request{ value: 1 ether }(call, deposits); + bytes32 id2 = inbox.request{ value: 2 ether }(call, deposits); + + assertEq(address(inbox).balance, 3 ether, "address(inbox).balance"); + assertNewRequest({ id: id1, from: user, call: call, deposits: deposits, nativeDeposit: 1 ether }); + assertNewRequest({ id: id2, from: user, call: call, deposits: deposits, nativeDeposit: 2 ether }); + } + + /// @dev Test that inbox has the correct state after a request + function assertNewRequest( + bytes32 id, + address from, + Solve.Call memory call, + Solve.TokenDeposit[] memory deposits, + uint256 nativeDeposit + ) internal view { + Solve.Request memory req = inbox.getRequest(id); + + assertTrue(req.status == Solve.Status.Open, "_assertNewRequest : req.status"); + + assertEq(req.id, id, "_assertNewRequest : req.id"); + assertEq(req.from, from, "_assertNewRequest : req.from"); + assertEq(req.updatedAt, block.timestamp, "_assertNewRequest : req.updatedAt"); // assumes no vm.warp() + assertEq(req.call.target, call.target, "_assertNewRequest : req.call.target"); + assertEq(req.call.destChainId, call.destChainId, "_assertNewRequest : req.call.destChainId"); + assertEq(req.call.value, call.value, "_assertNewRequest : req.call.value"); + assertEq(req.call.data, call.data, "_assertNewRequest : req.call.data"); + + uint256 numDeposits = nativeDeposit > 0 ? deposits.length + 1 : deposits.length; + assertEq(req.deposits.length, numDeposits, "_assertNewRequest : req.deposits.length"); + + // if nativeDeposit, should be first + if (nativeDeposit > 0) { + assertEq(req.deposits[0].token, address(0), "_assertNewRequest : req.deposits[0].token"); + assertEq(req.deposits[0].amount, nativeDeposit, "_assertNewRequest : req.deposits[0].amount"); + assertEq(req.deposits[0].isNative, true, "_assertNewRequest : req.deposits[0].isNative"); + } + + uint256 start = nativeDeposit > 0 ? 1 : 0; + for (uint256 i = start; i < numDeposits; i++) { + assertEq(req.deposits[i].isNative, false, "_assertNewRequest : req.deposits[i].isNative"); + assertEq(req.deposits[i].token, deposits[i - start].token, "_assertNewRequest : req.deposits[i].token"); + assertEq(req.deposits[i].amount, deposits[i - start].amount, "_assertNewRequest : req.deposits[i].amount"); + } + } + + function randCall() internal returns (Solve.Call memory) { + uint256 rand = vm.randomUint(1, 1000); + return Solve.Call({ + destChainId: uint64(rand), + value: rand * 1 ether, + target: address(uint160(rand)), + data: abi.encode("data", rand) + }); + } + + function mintAndApprove(Solve.TokenDeposit[] memory deposits) internal { + for (uint256 i = 0; i < deposits.length; i++) { + MockToken(deposits[i].token).approve(address(inbox), deposits[i].amount); + MockToken(deposits[i].token).mint(user, deposits[i].amount); + } + } +} diff --git a/contracts/core/test/utils/MockToken.sol b/contracts/core/test/utils/MockToken.sol new file mode 100644 index 000000000..61b17ee07 --- /dev/null +++ b/contracts/core/test/utils/MockToken.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity =0.8.24; + +import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +/** + * @title MockToken + * @notice ERC20 with public mints. + */ +contract MockToken is ERC20 { + constructor() ERC20("MockToken", "MTK") { } + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } +}