diff --git a/contracts/src/AgentExecutor.sol b/contracts/src/AgentExecutor.sol index 9197b20c0..1a6ebf2de 100644 --- a/contracts/src/AgentExecutor.sol +++ b/contracts/src/AgentExecutor.sol @@ -5,6 +5,7 @@ pragma solidity 0.8.33; 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. @@ -29,4 +30,33 @@ contract AgentExecutor { revert(); } } + + // Call multiple contracts with Ether values; reverts on the first failure + function callContracts(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) { + revert(); + } + } + } + + // Sweep remaining assets when specified + function sweep(address recipient, address[] calldata tokens) external { + for (uint256 i; i < tokens.length; ++i) { + address token = tokens[i]; + if (token == address(0)) { + uint256 balance = address(this).balance; + if (balance > 0) { + payable(recipient).safeNativeTransfer(balance); + } + } else { + uint256 balance = IERC20(token).balanceOf(address(this)); + if (balance > 0) { + IERC20(token).safeTransfer(recipient, balance); + } + } + } + } } diff --git a/contracts/src/Gateway.sol b/contracts/src/Gateway.sol index d3c51f3e0..6196d8a3d 100644 --- a/contracts/src/Gateway.sol +++ b/contracts/src/Gateway.sol @@ -25,7 +25,7 @@ import { IGatewayV1, IGatewayV2 } from "./Types.sol"; -import {Network} from "./v2/Types.sol"; +import {Network, CallContractsParams} from "./v2/Types.sol"; import {Upgrade} from "./Upgrade.sol"; import {IInitializable} from "./interfaces/IInitializable.sol"; import {IUpgradable} from "./interfaces/IUpgradable.sol"; @@ -500,6 +500,12 @@ contract Gateway is IGatewayBase, IGatewayV1, IGatewayV2, IInitializable, IUpgra catch { success = false; emit IGatewayV2.CommandFailed(nonce, i); + if (command.kind == CommandKind.CallContracts) { + try this.trySweepOnFailure(origin, command.payload) {} + catch { + emit IGatewayV2.SweepAfterCallContractsFailed(nonce, i); + } + } } } return (false, success); @@ -523,11 +529,25 @@ 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.CallContracts) { + HandlersV2.callContracts(origin, AGENT_EXECUTOR, command.payload); } else { revert IGatewayV2.InvalidCommand(); } } + /// @dev Decode CallContractsParams and run sweep when configured; used in CallContracts catch block. + /// Caller wraps in try-catch so malformed payloads or sweep failures don't revert dispatch. + function trySweepOnFailure(bytes32 origin, bytes calldata payload) + external + onlySelf + { + CallContractsParams memory params = abi.decode(payload, (CallContractsParams)); + if (params.sweepRecipient != address(0) && params.tokensToSweep.length > 0) { + HandlersV2.sweepAfterCallContracts(origin, AGENT_EXECUTOR, payload); + } + } + /** * Upgrades */ diff --git a/contracts/src/v2/Handlers.sol b/contracts/src/v2/Handlers.sol index c8ce0669a..3005bfd61 100644 --- a/contracts/src/v2/Handlers.sol +++ b/contracts/src/v2/Handlers.sol @@ -19,7 +19,8 @@ import { UnlockNativeTokenParams, RegisterForeignTokenParams, MintForeignTokenParams, - CallContractParams + CallContractParams, + CallContractsParams } from "./Types.sol"; library HandlersV2 { @@ -71,4 +72,21 @@ library HandlersV2 { abi.encodeCall(AgentExecutor.callContract, (params.target, params.data, params.value)); Functions.invokeOnAgent(agent, executor, call); } + + function callContracts(bytes32 origin, address executor, bytes calldata data) external { + CallContractsParams memory params = abi.decode(data, (CallContractsParams)); + address agent = Functions.ensureAgent(origin); + bytes memory call = abi.encodeCall(AgentExecutor.callContracts, params.calls); + Functions.invokeOnAgent(agent, executor, call); + } + + function sweepAfterCallContracts(bytes32 origin, address executor, bytes calldata data) + external + { + CallContractsParams memory params = abi.decode(data, (CallContractsParams)); + address agent = Functions.ensureAgent(origin); + bytes memory call = + abi.encodeCall(AgentExecutor.sweep, (params.sweepRecipient, params.tokensToSweep)); + Functions.invokeOnAgent(agent, executor, call); + } } diff --git a/contracts/src/v2/IGateway.sol b/contracts/src/v2/IGateway.sol index 829ed3f13..761548e91 100644 --- a/contracts/src/v2/IGateway.sol +++ b/contracts/src/v2/IGateway.sol @@ -38,6 +38,9 @@ interface IGatewayV2 { /// Emitted when a command at `index` within an inbound message identified by `nonce` fails to execute event CommandFailed(uint64 indexed nonce, uint256 index); + /// Emitted when sweep-after-CallContracts fails (e.g. malformed payload or sweep revert) + event SweepAfterCallContractsFailed(uint64 indexed nonce, uint256 index); + /// Emitted when an outbound message has been accepted for delivery to a Polkadot parachain event OutboundMessageAccepted(uint64 nonce, Payload payload); diff --git a/contracts/src/v2/Types.sol b/contracts/src/v2/Types.sol index 95fb43d8e..63b04236f 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 CallContracts = 6; } // Payload for outbound messages destined for Polkadot @@ -185,6 +187,16 @@ struct CallContractParams { uint256 value; } +// Payload for CallContracts command. Reverts on first call failure; optional sweep when calls fail. +struct CallContractsParams { + // Sub-calls to execute (reverts on first failure) + CallContractParams[] calls; + // Recipient for sweep when calls fail; address(0) = no sweep + address sweepRecipient; + // Tokens to sweep full balance to sweepRecipient; address(0) = sweep ETH + address[] tokensToSweep; +} + enum Network { Polkadot } diff --git a/contracts/test/GatewayV2.t.sol b/contracts/test/GatewayV2.t.sol index 63f3a73b0..8d9fa8cb7 100644 --- a/contracts/test/GatewayV2.t.sol +++ b/contracts/test/GatewayV2.t.sol @@ -39,6 +39,7 @@ import { RegisterForeignTokenParams, MintForeignTokenParams, CallContractParams, + CallContractsParams, Payload, Asset, makeNativeAsset, @@ -319,6 +320,56 @@ contract GatewayV2Test is Test { return commands; } + function makeCallContractsCommand(CallContractParams[] memory params) + public + pure + returns (CommandV2[] memory) + { + CallContractsParams memory p = CallContractsParams({ + calls: params, + sweepRecipient: address(0), + tokensToSweep: new address[](0) + }); + bytes memory payload = abi.encode(p); + CommandV2[] memory commands = new CommandV2[](1); + commands[0] = + CommandV2({kind: CommandKind.CallContracts, gas: 500_000, payload: payload}); + return commands; + } + + function makeCallContractsCommandWithInsufficientGas(CallContractParams[] memory params) + public + pure + returns (CommandV2[] memory) + { + CallContractsParams memory p = CallContractsParams({ + calls: params, + sweepRecipient: address(0), + tokensToSweep: new address[](0) + }); + bytes memory payload = abi.encode(p); + CommandV2[] memory commands = new CommandV2[](1); + commands[0] = CommandV2({kind: CommandKind.CallContracts, gas: 1, payload: payload}); + return commands; + } + + function makeCallContractsCommandWithSweep( + CallContractParams[] memory params, + address sweepRecipient, + address[] memory tokensToSweep + ) public pure returns (CommandV2[] memory) { + CallContractsParams memory p = CallContractsParams({ + calls: params, + sweepRecipient: sweepRecipient, + tokensToSweep: tokensToSweep + }); + bytes memory payload = abi.encode(p); + CommandV2[] memory commands = new CommandV2[](1); + commands[0] = + CommandV2({kind: CommandKind.CallContracts, gas: 500_000, payload: payload}); + return commands; + } + /** * Message Verification */ @@ -617,6 +668,183 @@ contract GatewayV2Test is Test { ); } + function testAgentCallContractsSuccess() 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: makeCallContractsCommand(params) + }), + proof, + makeMockProof(), + relayerRewardAddress + ); + } + + function testAgentCallContractsRevertedOnFirstFailure() 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: makeCallContractsCommand(params) + }), + proof, + makeMockProof(), + relayerRewardAddress + ); + } + + function testAgentCallContractsRevertedOnSecondFailure() 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: makeCallContractsCommand(params) + }), + proof, + makeMockProof(), + relayerRewardAddress + ); + } + + function testAgentCallContractsRevertedForInsufficientGas() 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: makeCallContractsCommandWithInsufficientGas(params) + }), + proof, + makeMockProof(), + relayerRewardAddress + ); + } + + function testAgentCallContractsSweepRunsOnFailure() public { + bytes32 topic = keccak256("topic"); + + // First call reverts; sweep is configured to recover agent's ETH + CallContractParams[] memory params = new CallContractParams[](1); + params[0] = CallContractParams({ + target: address(helloWorld), + data: abi.encodeWithSignature("revertUnauthorized()"), + value: 0 + }); + + address sweepRecipient = address(new PayableRecipient()); + address[] memory tokensToSweep = new address[](1); + tokensToSweep[0] = address(0); // sweep ETH + + vm.expectEmit(true, false, false, true); + emit IGatewayV2.CommandFailed(1, 0); + emit IGatewayV2.InboundMessageDispatched(1, topic, false, relayerRewardAddress); + + vm.deal(assetHubAgent, 0.5 ether); + uint256 recipientBalanceBefore = sweepRecipient.balance; + + hoax(relayer, 1 ether); + IGatewayV2(address(gateway)) + .v2_submit( + InboundMessageV2({ + origin: Constants.ASSET_HUB_AGENT_ID, + nonce: 1, + topic: topic, + commands: makeCallContractsCommandWithSweep( + params, + sweepRecipient, + tokensToSweep + ) + }), + proof, + makeMockProof(), + relayerRewardAddress + ); + + assertEq(sweepRecipient.balance, recipientBalanceBefore + 0.5 ether, "sweep should have transferred ETH"); + } + function testCreateAgent() public { bytes32 origin = bytes32(uint256(1)); vm.expectEmit(true, false, false, false); @@ -934,6 +1162,19 @@ contract GatewayV2Test is Test { assertTrue(!ok, "callContract with missing agent should return false"); } + function testCallContractsAgentDoesNotExistReturnsFalse() public { + CallContractParams[] memory params = new CallContractParams[](1); + params[0] = + CallContractParams({target: address(0xdead), data: "", value: uint256(0)}); + bytes memory payload = abi.encode(params); + + CommandV2 memory cmd = + CommandV2({kind: CommandKind.CallContracts, gas: uint64(200_000), payload: payload}); + + bool ok = gatewayLogic.callDispatch(cmd, bytes32(uint256(0x9999))); + assertTrue(!ok, "callContracts with missing agent should return false"); + } + function testInsufficientGasReverts() public { bytes memory payload = ""; // Use an extremely large gas value to trigger InsufficientGasLimit revert in _dispatchCommand