diff --git a/contracts/src/AgentExecutor.sol b/contracts/src/AgentExecutor.sol index 4bc3a9da9a..ec6ef620d9 100644 --- a/contracts/src/AgentExecutor.sol +++ b/contracts/src/AgentExecutor.sol @@ -5,6 +5,7 @@ pragma solidity 0.8.34; import {IERC20} from "./interfaces/IERC20.sol"; import {SafeTokenTransfer, SafeNativeTransfer} from "./utils/SafeTransfer.sol"; import {Call} from "./utils/Call.sol"; +import {CallContractParams} from "./v2/Types.sol"; /// @title Code which will run within an `Agent` using `delegatecall`. /// @dev This is a singleton contract, meaning that all agents will execute the same code. @@ -26,7 +27,18 @@ contract AgentExecutor { function callContract(address target, bytes memory data, uint256 value) external { bool success = Call.safeCall(target, data, value); if (!success) { - revert(); + Call.bubbleRevert(); + } + } + + // Call multiple contracts with Ether values; reverts on the first failure + function multiCall(CallContractParams[] calldata params) external { + uint256 len = params.length; + for (uint256 i; i < len; ++i) { + bool success = Call.safeCall(params[i].target, params[i].data, params[i].value); + if (!success) { + Call.bubbleRevert(); + } } } } diff --git a/contracts/src/Gateway.sol b/contracts/src/Gateway.sol index 7bed79195a..b93c3c658e 100644 --- a/contracts/src/Gateway.sol +++ b/contracts/src/Gateway.sol @@ -514,6 +514,8 @@ contract Gateway is IGatewayBase, IGatewayV1, IGatewayV2, IInitializable, IUpgra HandlersV2.mintForeignToken(command.payload); } else if (command.kind == CommandKind.CallContract) { HandlersV2.callContract(origin, AGENT_EXECUTOR, command.payload); + } else if (command.kind == CommandKind.MultiCall) { + HandlersV2.multiCall(origin, AGENT_EXECUTOR, command.payload); } else { revert IGatewayV2.InvalidCommand(); } diff --git a/contracts/src/utils/Call.sol b/contracts/src/utils/Call.sol index f007fa8ec5..d85821019d 100644 --- a/contracts/src/utils/Call.sol +++ b/contracts/src/utils/Call.sol @@ -33,6 +33,18 @@ library Call { * @param target Address to call * @param data Calldata to pass to the call */ + /** + * @notice Bubble up the revert reason from the most recent external call + * @dev Must be called immediately after the failed call so that returndata is still available + */ + function bubbleRevert() internal pure { + /// @solidity memory-safe-assembly + assembly { + returndatacopy(0, 0, returndatasize()) + revert(0, returndatasize()) + } + } + function safeCall(address target, bytes memory data, uint256 value) internal returns (bool) { bool success; assembly { diff --git a/contracts/src/v2/Handlers.sol b/contracts/src/v2/Handlers.sol index 74b70a56d4..5d16aa4aca 100644 --- a/contracts/src/v2/Handlers.sol +++ b/contracts/src/v2/Handlers.sol @@ -18,7 +18,8 @@ import { UnlockNativeTokenParams, RegisterForeignTokenParams, MintForeignTokenParams, - CallContractParams + CallContractParams, + MultiCallParams } from "./Types.sol"; library HandlersV2 { @@ -70,4 +71,11 @@ library HandlersV2 { abi.encodeCall(AgentExecutor.callContract, (params.target, params.data, params.value)); Functions.invokeOnAgent(agent, executor, call); } + + function multiCall(bytes32 origin, address executor, bytes calldata data) external { + MultiCallParams memory params = abi.decode(data, (MultiCallParams)); + address agent = Functions.ensureAgent(origin); + bytes memory call = abi.encodeCall(AgentExecutor.multiCall, params.calls); + Functions.invokeOnAgent(agent, executor, call); + } } diff --git a/contracts/src/v2/Types.sol b/contracts/src/v2/Types.sol index b323575b14..1f96465531 100644 --- a/contracts/src/v2/Types.sol +++ b/contracts/src/v2/Types.sol @@ -36,6 +36,8 @@ library CommandKind { uint8 constant MintForeignToken = 4; // Call an arbitrary solidity contract uint8 constant CallContract = 5; + // Call multiple arbitrary solidity contracts + uint8 constant MultiCall = 6; } // Payload for outbound messages destined for Polkadot @@ -185,6 +187,12 @@ struct CallContractParams { uint256 value; } +// Payload for MultiCall command. Reverts on first call failure. +struct MultiCallParams { + // Sub-calls to execute (reverts on first failure) + CallContractParams[] calls; +} + enum Network { Polkadot } diff --git a/contracts/test/GatewayV2.t.sol b/contracts/test/GatewayV2.t.sol index e037c1a197..4e39869da4 100644 --- a/contracts/test/GatewayV2.t.sol +++ b/contracts/test/GatewayV2.t.sol @@ -25,6 +25,7 @@ import { RegisterForeignTokenParams, MintForeignTokenParams, CallContractParams, + MultiCallParams, Payload, Asset, makeNativeAsset, @@ -297,6 +298,35 @@ contract GatewayV2Test is Test { return commands; } + function makeMultiCallCommand(CallContractParams[] memory params) + public + pure + returns (CommandV2[] memory) + { + MultiCallParams memory p = MultiCallParams({ + calls: params + }); + bytes memory payload = abi.encode(p); + CommandV2[] memory commands = new CommandV2[](1); + commands[0] = + CommandV2({kind: CommandKind.MultiCall, gas: 500_000, payload: payload}); + return commands; + } + + function makeMultiCallCommandWithInsufficientGas(CallContractParams[] memory params) + public + pure + returns (CommandV2[] memory) + { + MultiCallParams memory p = MultiCallParams({ + calls: params + }); + bytes memory payload = abi.encode(p); + CommandV2[] memory commands = new CommandV2[](1); + commands[0] = CommandV2({kind: CommandKind.MultiCall, gas: 1, payload: payload}); + return commands; + } + /** * Message Verification */ @@ -595,6 +625,140 @@ contract GatewayV2Test is Test { ); } + function testAgentMultiCallSuccess() public { + bytes32 topic = keccak256("topic"); + + CallContractParams[] memory params = new CallContractParams[](2); + params[0] = CallContractParams({ + target: address(helloWorld), + data: abi.encodeWithSignature("sayHello(string)", "First"), + value: 0.05 ether + }); + params[1] = CallContractParams({ + target: address(helloWorld), + data: abi.encodeWithSignature("sayHello(string)", "Second"), + value: 0.05 ether + }); + + vm.expectEmit(true, false, false, true); + emit IGatewayV2.InboundMessageDispatched(1, topic, true, relayerRewardAddress); + + vm.deal(assetHubAgent, 1 ether); + hoax(relayer, 1 ether); + IGatewayV2(address(gateway)) + .v2_submit( + InboundMessageV2({ + origin: Constants.ASSET_HUB_AGENT_ID, + nonce: 1, + topic: topic, + commands: makeMultiCallCommand(params) + }), + proof, + makeMockProof(), + relayerRewardAddress + ); + } + + function testAgentMultiCallRevertedOnFirstFailure() public { + bytes32 topic = keccak256("topic"); + + CallContractParams[] memory params = new CallContractParams[](2); + params[0] = CallContractParams({ + target: address(helloWorld), + data: abi.encodeWithSignature("revertUnauthorized()"), + value: 0 + }); + params[1] = CallContractParams({ + target: address(helloWorld), + data: abi.encodeWithSignature("sayHello(string)", "Second"), + value: 0 + }); + + vm.expectEmit(true, false, false, true); + emit IGatewayV2.CommandFailed(1, 0); + emit IGatewayV2.InboundMessageDispatched(1, topic, false, relayerRewardAddress); + + vm.deal(assetHubAgent, 1 ether); + hoax(relayer, 1 ether); + IGatewayV2(address(gateway)) + .v2_submit( + InboundMessageV2({ + origin: Constants.ASSET_HUB_AGENT_ID, + nonce: 1, + topic: topic, + commands: makeMultiCallCommand(params) + }), + proof, + makeMockProof(), + relayerRewardAddress + ); + } + + function testAgentMultiCallRevertedOnSecondFailure() public { + bytes32 topic = keccak256("topic"); + + CallContractParams[] memory params = new CallContractParams[](2); + params[0] = CallContractParams({ + target: address(helloWorld), + data: abi.encodeWithSignature("sayHello(string)", "First"), + value: 0 + }); + params[1] = CallContractParams({ + target: address(helloWorld), + data: abi.encodeWithSignature("revertUnauthorized()"), + value: 0 + }); + + vm.expectEmit(true, false, false, true); + emit IGatewayV2.CommandFailed(1, 0); + emit IGatewayV2.InboundMessageDispatched(1, topic, false, relayerRewardAddress); + + vm.deal(assetHubAgent, 1 ether); + hoax(relayer, 1 ether); + IGatewayV2(address(gateway)) + .v2_submit( + InboundMessageV2({ + origin: Constants.ASSET_HUB_AGENT_ID, + nonce: 1, + topic: topic, + commands: makeMultiCallCommand(params) + }), + proof, + makeMockProof(), + relayerRewardAddress + ); + } + + function testAgentMultiCallRevertedForInsufficientGas() public { + bytes32 topic = keccak256("topic"); + + CallContractParams[] memory params = new CallContractParams[](1); + params[0] = CallContractParams({ + target: address(helloWorld), + data: abi.encodeWithSignature("sayHello(string)", "World"), + value: 0.1 ether + }); + + vm.expectEmit(true, false, false, true); + emit IGatewayV2.CommandFailed(1, 0); + emit IGatewayV2.InboundMessageDispatched(1, topic, false, relayerRewardAddress); + + vm.deal(assetHubAgent, 1 ether); + hoax(relayer, 1 ether); + IGatewayV2(address(gateway)) + .v2_submit( + InboundMessageV2({ + origin: Constants.ASSET_HUB_AGENT_ID, + nonce: 1, + topic: topic, + commands: makeMultiCallCommandWithInsufficientGas(params) + }), + proof, + makeMockProof(), + relayerRewardAddress + ); + } + function testCreateAgent() public { bytes32 origin = bytes32(uint256(1)); vm.expectEmit(true, false, false, false); @@ -912,6 +1076,20 @@ contract GatewayV2Test is Test { assertTrue(!ok, "callContract with missing agent should return false"); } + function testMultiCallAgentDoesNotExistReturnsFalse() public { + CallContractParams[] memory params = new CallContractParams[](1); + params[0] = + CallContractParams({target: address(0xdead), data: "", value: uint256(0)}); + MultiCallParams memory p = MultiCallParams({calls: params}); + bytes memory payload = abi.encode(p); + + CommandV2 memory cmd = + CommandV2({kind: CommandKind.MultiCall, gas: uint64(200_000), payload: payload}); + + bool ok = gatewayLogic.callDispatch(cmd, bytes32(uint256(0x9999))); + assertTrue(!ok, "multiCall with missing agent should return false"); + } + function testInsufficientGasReverts() public { bytes memory payload = ""; // Use an extremely large gas value to trigger InsufficientGasLimit revert in _dispatchCommand @@ -1172,4 +1350,145 @@ contract GatewayV2Test is Test { // message should be recorded as dispatched assertTrue(gw.v2_isDispatched(msgv.nonce)); } + + function testAgentExecutorCallContractBubblesRevertReason() public { + bytes memory data = abi.encodeWithSignature("revertUnauthorized()"); + + vm.expectRevert(abi.encodeWithSignature("Unauthorized()")); + executor.callContract(address(helloWorld), data, 0); + } + + function testAgentExecutorMultiCallBubblesRevertReason() public { + CallContractParams[] memory params = new CallContractParams[](2); + params[0] = CallContractParams({ + target: address(helloWorld), + data: abi.encodeWithSignature("sayHello(string)", "Success"), + value: 0 + }); + params[1] = CallContractParams({ + target: address(helloWorld), + data: abi.encodeWithSignature("revertUnauthorized()"), + value: 0 + }); + + vm.expectRevert(abi.encodeWithSignature("Unauthorized()")); + executor.multiCall(params); + } + + function testAgentMultiCallTransferApproveSendSuccess() public { + bytes32 topic = keccak256("topic"); + address recipient = makeAddr("recipient"); + + // Set up adapter + MockAdapter adapter = new MockAdapter(address(weth), recipient); + + // Fund agent with 1 WETH + vm.deal(assetHubAgent, 1 ether); + hoax(assetHubAgent); + weth.deposit{value: 1 ether}(); + + // Check initial balances + assertEq(weth.balanceOf(assetHubAgent), 1 ether); + assertEq(weth.balanceOf(recipient), 0); + + CallContractParams[] memory params = new CallContractParams[](2); + params[0] = CallContractParams({ + target: address(weth), + data: abi.encodeWithSignature("approve(address,uint256)", address(adapter), 1 ether), + value: 0 + }); + params[1] = CallContractParams({ + target: address(adapter), + data: abi.encodeWithSignature("swapAndSend(uint256)", 1 ether), + value: 0 + }); + + vm.expectEmit(true, false, false, true); + emit IGatewayV2.InboundMessageDispatched(1, topic, true, relayerRewardAddress); + + hoax(relayer, 1 ether); + IGatewayV2(address(gateway)) + .v2_submit( + InboundMessageV2({ + origin: Constants.ASSET_HUB_AGENT_ID, + nonce: 1, + topic: topic, + commands: makeMultiCallCommand(params) + }), + proof, + makeMockProof(), + relayerRewardAddress + ); + + // Check final balances + assertEq(weth.balanceOf(assetHubAgent), 0); + assertEq(weth.balanceOf(recipient), 1 ether); + } + + function testAgentMultiCallTransferApproveSendFailureRevertsAll() public { + bytes32 topic = keccak256("topic"); + address recipient = makeAddr("recipient"); + + // Set up adapter + MockAdapter adapter = new MockAdapter(address(weth), recipient); + + // Fund agent with 1 WETH + vm.deal(assetHubAgent, 1 ether); + hoax(assetHubAgent); + weth.deposit{value: 1 ether}(); + + // Check initial balances + assertEq(weth.balanceOf(assetHubAgent), 1 ether); + assertEq(weth.balanceOf(recipient), 0); + + CallContractParams[] memory params = new CallContractParams[](2); + params[0] = CallContractParams({ + target: address(weth), + data: abi.encodeWithSignature("approve(address,uint256)", address(adapter), 1 ether), + value: 0 + }); + params[1] = CallContractParams({ + target: address(adapter), + data: abi.encodeWithSignature("swapAndSend(uint256)", 2 ether), // fails because agent only has 1 ether + value: 0 + }); + + vm.expectEmit(true, false, false, true); + emit IGatewayV2.CommandFailed(1, 0); // Gateway reports command failed + emit IGatewayV2.InboundMessageDispatched(1, topic, false, relayerRewardAddress); + + hoax(relayer, 1 ether); + IGatewayV2(address(gateway)) + .v2_submit( + InboundMessageV2({ + origin: Constants.ASSET_HUB_AGENT_ID, + nonce: 1, + topic: topic, + commands: makeMultiCallCommand(params) + }), + proof, + makeMockProof(), + relayerRewardAddress + ); + + // Check final balances are unchanged + assertEq(weth.balanceOf(assetHubAgent), 1 ether); + assertEq(weth.balanceOf(recipient), 0); + // Approval should be reverted back to 0 + assertEq(weth.allowance(assetHubAgent, address(adapter)), 0); + } +} + +contract MockAdapter { + address public immutable token; + address public immutable recipient; + + constructor(address _token, address _recipient) { + token = _token; + recipient = _recipient; + } + + function swapAndSend(uint256 amount) external { + require(IERC20(token).transferFrom(msg.sender, recipient, amount), "transfer failed"); + } }