From 6a68f5982d79b20d913378f6029b0af4c196088d Mon Sep 17 00:00:00 2001 From: ron Date: Sun, 18 Jan 2026 02:14:36 +0000 Subject: [PATCH 01/28] Multiple calls support --- contracts/src/AgentExecutor.sol | 16 ++++++++++++++-- .../src/l2-integration/SnowbridgeL1Adaptor.sol | 11 ++++++++++- contracts/src/v2/Handlers.sol | 3 +-- 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/contracts/src/AgentExecutor.sol b/contracts/src/AgentExecutor.sol index a58ae94cf..4b1c2c565 100644 --- a/contracts/src/AgentExecutor.sol +++ b/contracts/src/AgentExecutor.sol @@ -5,6 +5,7 @@ pragma solidity 0.8.28; 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. @@ -23,10 +24,21 @@ contract AgentExecutor { } // Call contract with Ether value - function callContract(address target, bytes memory data, uint256 value) external { - bool success = Call.safeCall(target, data, value); + function callContract(CallContractParams calldata param) external { + bool success = Call.safeCall(param.target, param.data, param.value); if (!success) { 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(); + } + } + } } diff --git a/contracts/src/l2-integration/SnowbridgeL1Adaptor.sol b/contracts/src/l2-integration/SnowbridgeL1Adaptor.sol index ddac5f225..81a102910 100644 --- a/contracts/src/l2-integration/SnowbridgeL1Adaptor.sol +++ b/contracts/src/l2-integration/SnowbridgeL1Adaptor.sol @@ -33,6 +33,9 @@ contract SnowbridgeL1Adaptor { function depositToken(DepositParams calldata params, address recipient, bytes32 topic) public { require(params.inputToken != address(0), "Input token cannot be zero address"); checkInputs(params, recipient); + + // Pull tokens from the caller to avoid relying on pre-funding and then approve SpokePool + IERC20(params.inputToken).safeTransferFrom(msg.sender, address(this), params.inputAmount); IERC20(params.inputToken).forceApprove(address(SPOKE_POOL), params.inputAmount); SPOKE_POOL.deposit( @@ -49,7 +52,13 @@ contract SnowbridgeL1Adaptor { 0, // exclusivityDeadline, zero means no exclusivity "" // empty message ); - // Emit event with the depositId of the deposit + + // Forward any remaining balance of the input token back to the recipient to avoid trapping funds + uint256 remaining = IERC20(params.inputToken).balanceOf(address(this)); + if (remaining > 0) { + IERC20(params.inputToken).safeTransfer(recipient, remaining); + } + uint256 depositId = SPOKE_POOL.numberOfDeposits() - 1; emit DepositCallInvoked(topic, depositId); } diff --git a/contracts/src/v2/Handlers.sol b/contracts/src/v2/Handlers.sol index 797517667..2ebd275c6 100644 --- a/contracts/src/v2/Handlers.sol +++ b/contracts/src/v2/Handlers.sol @@ -67,8 +67,7 @@ library HandlersV2 { function callContract(bytes32 origin, address executor, bytes calldata data) external { CallContractParams memory params = abi.decode(data, (CallContractParams)); address agent = Functions.ensureAgent(origin); - bytes memory call = - abi.encodeCall(AgentExecutor.callContract, (params.target, params.data, params.value)); + bytes memory call = abi.encodeCall(AgentExecutor.callContract, (params)); Functions.invokeOnAgent(agent, executor, call); } } From 457e275cd7d4232b268ce148de9bc01930f37c3b Mon Sep 17 00:00:00 2001 From: ron Date: Sun, 18 Jan 2026 04:27:54 +0000 Subject: [PATCH 02/28] Support multiple calls --- contracts/src/Gateway.sol | 21 ++- .../l2-integration/SnowbridgeL1Adaptor.sol | 8 ++ contracts/src/v2/Handlers.sol | 7 + contracts/src/v2/Types.sol | 2 + contracts/test/GatewayV2.t.sol | 125 +++++++++++++++++- contracts/test/mocks/HelloWorld.sol | 13 ++ 6 files changed, 164 insertions(+), 12 deletions(-) diff --git a/contracts/src/Gateway.sol b/contracts/src/Gateway.sol index aeeb41cee..1cc6f9239 100644 --- a/contracts/src/Gateway.sol +++ b/contracts/src/Gateway.sol @@ -209,9 +209,9 @@ contract Gateway is IGatewayBase, IGatewayV1, IGatewayV2, IInitializable, IUpgra success = false; } } else if (message.command == CommandV1.MintForeignToken) { - try Gateway(this).v1_handleMintForeignToken{gas: maxDispatchGas}( - message.channelID, message.params - ) {} catch { + try Gateway(this) + .v1_handleMintForeignToken{gas: maxDispatchGas}(message.channelID, message.params) {} + catch { success = false; } } else { @@ -510,15 +510,17 @@ contract Gateway is IGatewayBase, IGatewayV1, IGatewayV2, IInitializable, IUpgra HandlersV2.callContract(origin, AGENT_EXECUTOR, data); } + // Call multiple arbitrary contract functions + function v2_handleCallContracts(bytes32 origin, bytes calldata data) external onlySelf { + HandlersV2.callContracts(origin, AGENT_EXECUTOR, data); + } + /** * APIv2 Internal functions */ // Internal helper to dispatch a single command - function _dispatchCommand(CommandV2 calldata command, bytes32 origin) - internal - returns (bool) - { + function _dispatchCommand(CommandV2 calldata command, bytes32 origin) internal returns (bool) { // check that there is enough gas available to forward to the command handler if (gasleft() * 63 / 64 < command.gas + DISPATCH_OVERHEAD_GAS_V2) { revert IGatewayV2.InsufficientGasLimit(); @@ -554,6 +556,11 @@ contract Gateway is IGatewayBase, IGatewayV1, IGatewayV2, IInitializable, IUpgra catch { return false; } + } else if (command.kind == CommandKind.CallContracts) { + try Gateway(this).v2_handleCallContracts{gas: command.gas}(origin, command.payload) {} + catch { + return false; + } } else { // Unknown command return false; diff --git a/contracts/src/l2-integration/SnowbridgeL1Adaptor.sol b/contracts/src/l2-integration/SnowbridgeL1Adaptor.sol index 81a102910..ad48e6d5f 100644 --- a/contracts/src/l2-integration/SnowbridgeL1Adaptor.sol +++ b/contracts/src/l2-integration/SnowbridgeL1Adaptor.sol @@ -94,6 +94,14 @@ contract SnowbridgeL1Adaptor { 0, // exclusivityDeadline, zero means no exclusivity message ); + + // Forward any remaining balance of native Ether back to the recipient to avoid trapping funds + uint256 remaining = address(this).balance; + if (remaining > 0) { + (bool success,) = payable(recipient).call{value: remaining}(""); + require(success, "Failed to transfer remaining ether to recipient"); + } + uint256 depositId = SPOKE_POOL.numberOfDeposits() - 1; emit DepositCallInvoked(topic, depositId); } diff --git a/contracts/src/v2/Handlers.sol b/contracts/src/v2/Handlers.sol index 2ebd275c6..27f5cd87d 100644 --- a/contracts/src/v2/Handlers.sol +++ b/contracts/src/v2/Handlers.sol @@ -70,4 +70,11 @@ library HandlersV2 { bytes memory call = abi.encodeCall(AgentExecutor.callContract, (params)); Functions.invokeOnAgent(agent, executor, call); } + + function callContracts(bytes32 origin, address executor, bytes calldata data) external { + address agent = Functions.ensureAgent(origin); + CallContractParams[] memory params = abi.decode(data, (CallContractParams[])); + bytes memory call = abi.encodeCall(AgentExecutor.callContracts, (params)); + Functions.invokeOnAgent(agent, executor, call); + } } diff --git a/contracts/src/v2/Types.sol b/contracts/src/v2/Types.sol index 08c3b4a5e..bbb8123dc 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 diff --git a/contracts/test/GatewayV2.t.sol b/contracts/test/GatewayV2.t.sol index 2fcf55c30..1739c727e 100644 --- a/contracts/test/GatewayV2.t.sol +++ b/contracts/test/GatewayV2.t.sol @@ -319,6 +319,25 @@ contract GatewayV2Test is Test { return commands; } + function makeCallContractsCommand(uint256 value1, uint256 value2) + public + view + returns (CommandV2[] memory) + { + bytes memory data1 = abi.encodeWithSignature("sayHello(string)", "World"); + bytes memory data2 = abi.encodeWithSignature("sayHello(string)", "Snowbridge"); + + CallContractParams[] memory params = new CallContractParams[](2); + params[0] = CallContractParams({target: address(helloWorld), data: data1, value: value1}); + params[1] = CallContractParams({target: address(helloWorld), data: data2, value: value2}); + + bytes memory payload = abi.encode(params); + + CommandV2[] memory commands = new CommandV2[](1); + commands[0] = CommandV2({kind: CommandKind.CallContracts, gas: 500_000, payload: payload}); + return commands; + } + /** * Message Verification */ @@ -465,8 +484,7 @@ contract GatewayV2Test is Test { claimer: "", value: 0.5 ether, executionFee: 0.1 ether, - relayerFee: 0.4 ether - }) + relayerFee: 0.4 ether}) ); hoax(user1); @@ -496,9 +514,8 @@ contract GatewayV2Test is Test { vm.expectRevert(); hoax(user1); - IGatewayV2(payable(address(gateway))).v2_sendMessage{value: 1 ether}( - "", assets, "", 0.1 ether, 0.4 ether - ); + IGatewayV2(payable(address(gateway))) + .v2_sendMessage{value: 1 ether}("", assets, "", 0.1 ether, 0.4 ether); assertEq(feeToken.balanceOf(assetHubAgent), 0); } @@ -618,6 +635,29 @@ contract GatewayV2Test is Test { ); } + function testAgentCallContractsSuccess() public { + bytes32 topic = keccak256("topic"); + + vm.expectEmit(true, false, false, true); + emit IGatewayV2.InboundMessageDispatched(1, topic, true, relayerRewardAddress); + + // fund the agent to forward value in both calls + vm.deal(assetHubAgent, 0.1 ether); + hoax(relayer, 1 ether); + IGatewayV2(address(gateway)) + .v2_submit( + InboundMessageV2({ + origin: Constants.ASSET_HUB_AGENT_ID, + nonce: 1, + topic: topic, + commands: makeCallContractsCommand(0.05 ether, 0.05 ether) + }), + proof, + makeMockProof(), + relayerRewardAddress + ); + } + function testCreateAgent() public { bytes32 origin = bytes32(uint256(1)); vm.expectEmit(true, false, false, false); @@ -1184,4 +1224,79 @@ contract GatewayV2Test is Test { // message should be recorded as dispatched assertTrue(gw.v2_isDispatched(msgv.nonce)); } + + function testUnlockTokenThenCallContracts() public { + bytes32 topic = keccak256("topic"); + + // Set up an ERC20 token (WETH) for the agent to work with + address token = address(new WETH9()); + MockGateway(address(gateway)).prank_registerNativeToken(token); + + uint128 tokenAmount = 1 ether; + vm.deal(assetHubAgent, tokenAmount); + hoax(assetHubAgent); + WETH9(payable(token)).deposit{value: tokenAmount}(); + + // Verify agent has the WETH + assertEq(IERC20(token).balanceOf(assetHubAgent), tokenAmount); + + // Create two commands: + // 1. UnlockNativeToken: Unlock asset to the AssetHub agent + // 2. CallContracts: Two internal calls - approve HelloWorld, then transfer tokens using consumeToken + + // Command 1: UnlockNativeToken - unlock to assetHubAgent (no-op in this case since agent already has tokens) + UnlockNativeTokenParams memory unlockParams = + UnlockNativeTokenParams({token: token, recipient: assetHubAgent, amount: tokenAmount}); + + // Command 2: CallContracts - two internal calls: + // - First: approve HelloWorld to spend tokens from the agent + // - Second: agent calls consumeToken to transfer tokens to HelloWorld + bytes memory approveData = + abi.encodeWithSignature("approve(address,uint256)", address(helloWorld), tokenAmount); + bytes memory consumeData = + abi.encodeWithSignature("consumeToken(address,uint256)", token, tokenAmount); + + CallContractParams[] memory callParams = new CallContractParams[](2); + callParams[0] = CallContractParams({target: token, data: approveData, value: 0}); + callParams[1] = + CallContractParams({target: address(helloWorld), data: consumeData, value: 0}); + + CommandV2[] memory commands = new CommandV2[](2); + commands[0] = CommandV2({ + kind: CommandKind.UnlockNativeToken, gas: 500_000, payload: abi.encode(unlockParams) + }); + commands[1] = CommandV2({ + kind: CommandKind.CallContracts, gas: 500_000, payload: abi.encode(callParams) + }); + + // Fund agent with balance for gas + vm.deal(assetHubAgent, 1 ether); + + // Expect both commands to succeed + 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: commands + }), + proof, + makeMockProof(), + relayerRewardAddress + ); + + // Verify the token flow: agent -> HelloWorld + assertEq(IERC20(token).balanceOf(assetHubAgent), 0, "Agent should have no tokens left"); + assertEq( + IERC20(token).balanceOf(address(helloWorld)), + tokenAmount, + "HelloWorld should have received all tokens" + ); + } } + diff --git a/contracts/test/mocks/HelloWorld.sol b/contracts/test/mocks/HelloWorld.sol index 84b7a7660..6e64dbc01 100644 --- a/contracts/test/mocks/HelloWorld.sol +++ b/contracts/test/mocks/HelloWorld.sol @@ -1,8 +1,11 @@ // SPDX-License-Identifier: Apache-2.0 pragma solidity 0.8.28; +import {IERC20} from "../../src/interfaces/IERC20.sol"; + contract HelloWorld { event SaidHello(string indexed message); + event TokenConsumed(address indexed token, address indexed from, uint256 amount); error Unauthorized(); @@ -20,4 +23,14 @@ contract HelloWorld { return(1, 3000000) } } + + /// @dev Consume an approved ERC20 token from the caller + /// @param token The ERC20 token address + /// @param amount The amount to transfer from msg.sender to this contract + function consumeToken(address token, uint256 amount) public { + require( + IERC20(token).transferFrom(msg.sender, address(this), amount), "transferFrom failed" + ); + emit TokenConsumed(token, msg.sender, amount); + } } From a6c84edf2f3040d9ca406ee54413e2298ee46626 Mon Sep 17 00:00:00 2001 From: ron Date: Sun, 18 Jan 2026 04:52:45 +0000 Subject: [PATCH 03/28] Add test --- contracts/test/GatewayV2.t.sol | 87 ++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/contracts/test/GatewayV2.t.sol b/contracts/test/GatewayV2.t.sol index 1739c727e..55b800d29 100644 --- a/contracts/test/GatewayV2.t.sol +++ b/contracts/test/GatewayV2.t.sol @@ -1298,5 +1298,92 @@ contract GatewayV2Test is Test { "HelloWorld should have received all tokens" ); } + + function testUnlockTokenThenCallContractsWithRevert() public { + bytes32 topic = keccak256("topic"); + + // Set up an ERC20 token (WETH) for the agent to work with + address token = address(new WETH9()); + MockGateway(address(gateway)).prank_registerNativeToken(token); + + uint128 tokenAmount = 1 ether; + vm.deal(assetHubAgent, tokenAmount); + hoax(assetHubAgent); + WETH9(payable(token)).deposit{value: tokenAmount}(); + + // Verify agent has the WETH + assertEq(IERC20(token).balanceOf(assetHubAgent), tokenAmount); + + // Create two commands: + // 1. UnlockNativeToken: Unlock asset to the AssetHub agent + // 2. CallContracts: Three internal calls - approve, transfer tokens, then revert + + // Command 1: UnlockNativeToken - unlock to assetHubAgent + UnlockNativeTokenParams memory unlockParams = + UnlockNativeTokenParams({token: token, recipient: assetHubAgent, amount: tokenAmount}); + + // Command 2: CallContracts - three internal calls: + // - First: approve HelloWorld to spend tokens from the agent + // - Second: agent calls consumeToken to transfer tokens to HelloWorld + // - Third: call revertUnauthorized which will fail + bytes memory approveData = + abi.encodeWithSignature("approve(address,uint256)", address(helloWorld), tokenAmount); + bytes memory consumeData = + abi.encodeWithSignature("consumeToken(address,uint256)", token, tokenAmount); + bytes memory revertData = abi.encodeWithSignature("revertUnauthorized()"); + + CallContractParams[] memory callParams = new CallContractParams[](3); + callParams[0] = CallContractParams({target: token, data: approveData, value: 0}); + callParams[1] = + CallContractParams({target: address(helloWorld), data: consumeData, value: 0}); + callParams[2] = + CallContractParams({target: address(helloWorld), data: revertData, value: 0}); + + CommandV2[] memory commands = new CommandV2[](2); + commands[0] = CommandV2({ + kind: CommandKind.UnlockNativeToken, gas: 500_000, payload: abi.encode(unlockParams) + }); + commands[1] = CommandV2({ + kind: CommandKind.CallContracts, gas: 500_000, payload: abi.encode(callParams) + }); + + // Fund agent with balance for gas + vm.deal(assetHubAgent, 1 ether); + + // Expect the CallContracts command to fail + vm.expectEmit(true, false, false, true); + emit IGatewayV2.CommandFailed(1, 1); // nonce 1, command index 1 + + // Expect InboundMessageDispatched to be emitted with success=false + vm.expectEmit(true, false, false, true); + 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: commands + }), + proof, + makeMockProof(), + relayerRewardAddress + ); + + // Verify atomicity: since the third call failed, the first two calls should be reverted + // The agent should still have all tokens (no transfer occurred) + assertEq( + IERC20(token).balanceOf(assetHubAgent), + tokenAmount, + "Agent should still have all tokens due to revert" + ); + assertEq( + IERC20(token).balanceOf(address(helloWorld)), + 0, + "HelloWorld should have no tokens due to revert" + ); + } } From d19ad8eaf6898bd1974d080b13864c412500f3cd Mon Sep 17 00:00:00 2001 From: ron Date: Sun, 18 Jan 2026 11:45:11 +0000 Subject: [PATCH 04/28] Revamp test --- contracts/test/GatewayV2.t.sol | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/contracts/test/GatewayV2.t.sol b/contracts/test/GatewayV2.t.sol index 55b800d29..78d186bb2 100644 --- a/contracts/test/GatewayV2.t.sol +++ b/contracts/test/GatewayV2.t.sol @@ -1307,6 +1307,7 @@ contract GatewayV2Test is Test { MockGateway(address(gateway)).prank_registerNativeToken(token); uint128 tokenAmount = 1 ether; + uint128 halfAmount = tokenAmount / 2; vm.deal(assetHubAgent, tokenAmount); hoax(assetHubAgent); WETH9(payable(token)).deposit{value: tokenAmount}(); @@ -1315,21 +1316,21 @@ contract GatewayV2Test is Test { assertEq(IERC20(token).balanceOf(assetHubAgent), tokenAmount); // Create two commands: - // 1. UnlockNativeToken: Unlock asset to the AssetHub agent + // 1. UnlockNativeToken: Unlock asset to the relayer // 2. CallContracts: Three internal calls - approve, transfer tokens, then revert - // Command 1: UnlockNativeToken - unlock to assetHubAgent + // Command 1: UnlockNativeToken - unlock to relayer UnlockNativeTokenParams memory unlockParams = - UnlockNativeTokenParams({token: token, recipient: assetHubAgent, amount: tokenAmount}); + UnlockNativeTokenParams({token: token, recipient: relayer, amount: halfAmount}); // Command 2: CallContracts - three internal calls: // - First: approve HelloWorld to spend tokens from the agent // - Second: agent calls consumeToken to transfer tokens to HelloWorld // - Third: call revertUnauthorized which will fail bytes memory approveData = - abi.encodeWithSignature("approve(address,uint256)", address(helloWorld), tokenAmount); + abi.encodeWithSignature("approve(address,uint256)", address(helloWorld), halfAmount); bytes memory consumeData = - abi.encodeWithSignature("consumeToken(address,uint256)", token, tokenAmount); + abi.encodeWithSignature("consumeToken(address,uint256)", token, halfAmount); bytes memory revertData = abi.encodeWithSignature("revertUnauthorized()"); CallContractParams[] memory callParams = new CallContractParams[](3); @@ -1347,9 +1348,6 @@ contract GatewayV2Test is Test { kind: CommandKind.CallContracts, gas: 500_000, payload: abi.encode(callParams) }); - // Fund agent with balance for gas - vm.deal(assetHubAgent, 1 ether); - // Expect the CallContracts command to fail vm.expectEmit(true, false, false, true); emit IGatewayV2.CommandFailed(1, 1); // nonce 1, command index 1 @@ -1372,11 +1370,18 @@ contract GatewayV2Test is Test { relayerRewardAddress ); + // Verify relayer received the unlocked tokens + assertEq( + IERC20(token).balanceOf(relayer), + halfAmount, + "Relayer should have received unlocked tokens" + ); + // Verify atomicity: since the third call failed, the first two calls should be reverted // The agent should still have all tokens (no transfer occurred) assertEq( IERC20(token).balanceOf(assetHubAgent), - tokenAmount, + halfAmount, "Agent should still have all tokens due to revert" ); assertEq( From 71cbe1667f3c2f3f0c48025d07d9078f0847cffc Mon Sep 17 00:00:00 2001 From: ron Date: Sun, 18 Jan 2026 14:03:26 +0000 Subject: [PATCH 05/28] Improve L1 adapter --- .../deploy/DeploySnowbridgeL1Adaptor.s.sol | 5 +- .../across/test/TestSnowbridgeL1Adaptor.s.sol | 2 +- .../TestSnowbridgeL1AdaptorNativeEther.s.sol | 2 +- .../l2-integration/SnowbridgeL1Adaptor.sol | 62 +++++++------------ 4 files changed, 27 insertions(+), 44 deletions(-) diff --git a/contracts/scripts/l2-integration/across/deploy/DeploySnowbridgeL1Adaptor.s.sol b/contracts/scripts/l2-integration/across/deploy/DeploySnowbridgeL1Adaptor.s.sol index 207315979..ec5d5d1ef 100644 --- a/contracts/scripts/l2-integration/across/deploy/DeploySnowbridgeL1Adaptor.s.sol +++ b/contracts/scripts/l2-integration/across/deploy/DeploySnowbridgeL1Adaptor.s.sol @@ -42,9 +42,8 @@ contract DeploySnowbridgeL1Adaptor is Script { revert("Unsupported L1 network"); } - snowbridgeL1Adaptor = new SnowbridgeL1Adaptor( - SPOKE_POOL_ADDRESS, BASE_MULTI_CALL_HANDLER_ADDRESS, WETH9_ADDRESS, BASE_WETH9_ADDRESS - ); + snowbridgeL1Adaptor = + new SnowbridgeL1Adaptor(SPOKE_POOL_ADDRESS, WETH9_ADDRESS, BASE_WETH9_ADDRESS); console.log("Snowbridge L1 Adaptor deployed at:", address(snowbridgeL1Adaptor)); return; } diff --git a/contracts/scripts/l2-integration/across/test/TestSnowbridgeL1Adaptor.s.sol b/contracts/scripts/l2-integration/across/test/TestSnowbridgeL1Adaptor.s.sol index 3e78d8bbc..1490d225d 100644 --- a/contracts/scripts/l2-integration/across/test/TestSnowbridgeL1Adaptor.s.sol +++ b/contracts/scripts/l2-integration/across/test/TestSnowbridgeL1Adaptor.s.sol @@ -62,7 +62,7 @@ contract TestSnowbridgeL1Adaptor is Script { fillDeadlineBuffer: TIME_BUFFER }); - IERC20(params.inputToken).transfer(l1SnowbridgeAdaptor, params.inputAmount); + IERC20(params.inputToken).approve(l1SnowbridgeAdaptor, params.inputAmount); SnowbridgeL1Adaptor(l1SnowbridgeAdaptor) .depositToken(params, recipient, keccak256("TestERC20Deposit")); diff --git a/contracts/scripts/l2-integration/across/test/TestSnowbridgeL1AdaptorNativeEther.s.sol b/contracts/scripts/l2-integration/across/test/TestSnowbridgeL1AdaptorNativeEther.s.sol index 5a5e9fc1b..6a8c8ca62 100644 --- a/contracts/scripts/l2-integration/across/test/TestSnowbridgeL1AdaptorNativeEther.s.sol +++ b/contracts/scripts/l2-integration/across/test/TestSnowbridgeL1AdaptorNativeEther.s.sol @@ -23,7 +23,7 @@ contract TestSnowbridgeL1AdaptorNativeEther is Script { address payable l1SnowbridgeAdaptor = payable(vm.envAddress("L1_SNOWBRIDGE_ADAPTOR_ADDRESS")); - address recipient = vm.envAddress("RECIPIENT_ADDRESS"); + address payable recipient = payable(vm.envAddress("RECIPIENT_ADDRESS")); uint256 BASE_CHAIN_ID; uint32 TIME_BUFFER; diff --git a/contracts/src/l2-integration/SnowbridgeL1Adaptor.sol b/contracts/src/l2-integration/SnowbridgeL1Adaptor.sol index ad48e6d5f..49e0d1ec9 100644 --- a/contracts/src/l2-integration/SnowbridgeL1Adaptor.sol +++ b/contracts/src/l2-integration/SnowbridgeL1Adaptor.sol @@ -10,7 +10,6 @@ import {DepositParams, Instructions, Call} from "./Types.sol"; contract SnowbridgeL1Adaptor { using SafeERC20 for IERC20; ISpokePool public immutable SPOKE_POOL; - IMessageHandler public immutable MULTI_CALL_HANDLER; WETH9 public immutable L1_WETH9; WETH9 public immutable L2_WETH9; @@ -20,16 +19,16 @@ contract SnowbridgeL1Adaptor { event DepositCallInvoked(bytes32 topic, uint256 depositId); - constructor(address _spokePool, address _handler, address _l1weth9, address _l2weth9) { + constructor(address _spokePool, address _l1weth9, address _l2weth9) { SPOKE_POOL = ISpokePool(_spokePool); - MULTI_CALL_HANDLER = IMessageHandler(_handler); L1_WETH9 = WETH9(payable(_l1weth9)); L2_WETH9 = WETH9(payable(_l2weth9)); } - // Send ERC20 token on L1 to L2, the fee (params.inputAmount - params.outputAmount) should be calculated off-chain + // Send ERC20 token on L1 to L2 via the Across protocol. + // The fee (params.inputAmount - params.outputAmount) should be calculated off-chain // following https://docs.across.to/reference/api-reference#get-swap-approval - // The function assumes that tokens have been pre-funded and transferred to this contract via Snowbridge unlock prior to invocation. + // Tokens are pulled from the caller via safeTransferFrom. function depositToken(DepositParams calldata params, address recipient, bytes32 topic) public { require(params.inputToken != address(0), "Input token cannot be zero address"); checkInputs(params, recipient); @@ -63,26 +62,28 @@ contract SnowbridgeL1Adaptor { emit DepositCallInvoked(topic, depositId); } - // Send native Ether on L1 to L2, the function assumes that native ETH has been pre-funded - // and transferred to this contract via Snowbridge unlock prior to invocation. - function depositNativeEther(DepositParams calldata params, address recipient, bytes32 topic) - public - payable - { + // Send native Ether on L1 to L2 by first wrapping it to WETH, then depositing via SPOKE_POOL. + function depositNativeEther( + DepositParams calldata params, + address payable recipient, + bytes32 topic + ) public payable { require( params.inputToken == address(0), "Input token must be zero address for native ETH deposits" ); checkInputs(params, recipient); - // Prepare the message (encoded instructions) to be executed on L2 upon deposit fulfillment - // (constructed via helper to avoid 'stack too deep' compiler errors) - bytes memory message = _encodeNativeEtherInstructions(recipient, params.outputAmount); - SPOKE_POOL.deposit{ - value: params.inputAmount - }( - bytes32(uint256(uint160(recipient))), - bytes32(uint256(uint160(address(MULTI_CALL_HANDLER)))), + // Wrap native ETH to L1 WETH + L1_WETH9.deposit{value: params.inputAmount}(); + + // Approve SPOKE_POOL to spend the wrapped WETH + IERC20(address(L1_WETH9)).forceApprove(address(SPOKE_POOL), params.inputAmount); + + // Deposit WETH via SPOKE_POOL + SPOKE_POOL.deposit( + bytes32(uint256(uint160(address(recipient)))), + bytes32(uint256(uint160(address(recipient)))), bytes32(uint256(uint160(address(L1_WETH9)))), bytes32(uint256(uint160(address(L2_WETH9)))), params.inputAmount, @@ -92,13 +93,13 @@ contract SnowbridgeL1Adaptor { uint32(block.timestamp), // quoteTimestamp set to current block timestamp uint32(block.timestamp + params.fillDeadlineBuffer), // fillDeadline set to fillDeadlineBuffer seconds in the future 0, // exclusivityDeadline, zero means no exclusivity - message + "" // empty message ); - // Forward any remaining balance of native Ether back to the recipient to avoid trapping funds + // Forward any remaining balance back to the recipient to avoid trapping funds uint256 remaining = address(this).balance; if (remaining > 0) { - (bool success,) = payable(recipient).call{value: remaining}(""); + (bool success,) = recipient.call{value: remaining}(""); require(success, "Failed to transfer remaining ether to recipient"); } @@ -115,22 +116,5 @@ contract SnowbridgeL1Adaptor { require(recipient != address(0), "Recipient cannot be zero address"); } - function _encodeNativeEtherInstructions(address recipient, uint256 outputAmount) - internal - view - returns (bytes memory) - { - Call[] memory calls = new Call[](2); - calls[0] = Call({ - target: address(L2_WETH9), - callData: abi.encodeCall(L2_WETH9.withdraw, (outputAmount)), - value: 0 - }); - calls[1] = Call({target: recipient, callData: "", value: outputAmount}); - Instructions memory instructions = - Instructions({calls: calls, fallbackRecipient: recipient}); - return abi.encode(instructions); - } - receive() external payable {} } From 07cd880021ed85bdf1aad38cfdfd5052cbcaea1e Mon Sep 17 00:00:00 2001 From: ron Date: Mon, 19 Jan 2026 02:24:17 +0000 Subject: [PATCH 06/28] Harden L1 adapter --- .../l2-integration/SnowbridgeL1Adaptor.sol | 60 ++++++++++--------- 1 file changed, 32 insertions(+), 28 deletions(-) diff --git a/contracts/src/l2-integration/SnowbridgeL1Adaptor.sol b/contracts/src/l2-integration/SnowbridgeL1Adaptor.sol index 49e0d1ec9..a04e253c7 100644 --- a/contracts/src/l2-integration/SnowbridgeL1Adaptor.sol +++ b/contracts/src/l2-integration/SnowbridgeL1Adaptor.sol @@ -4,8 +4,8 @@ pragma solidity 0.8.28; import {IERC20} from "openzeppelin/token/ERC20/IERC20.sol"; import {SafeERC20} from "openzeppelin/token/ERC20/utils/SafeERC20.sol"; import {WETH9} from "canonical-weth/WETH9.sol"; -import {ISpokePool, IMessageHandler} from "./interfaces/ISpokePool.sol"; -import {DepositParams, Instructions, Call} from "./Types.sol"; +import {ISpokePool} from "./interfaces/ISpokePool.sol"; +import {DepositParams} from "./Types.sol"; contract SnowbridgeL1Adaptor { using SafeERC20 for IERC20; @@ -17,9 +17,13 @@ contract SnowbridgeL1Adaptor { * EVENTS * **************************************/ - event DepositCallInvoked(bytes32 topic, uint256 depositId); + event DepositCallInvoked(bytes32 topic, uint256 depositId, address inputToken); constructor(address _spokePool, address _l1weth9, address _l2weth9) { + require(_spokePool != address(0), "SPOKE_POOL cannot be zero address"); + require(_l1weth9 != address(0), "L1_WETH9 cannot be zero address"); + require(_l2weth9 != address(0), "L2_WETH9 cannot be zero address"); + SPOKE_POOL = ISpokePool(_spokePool); L1_WETH9 = WETH9(payable(_l1weth9)); L2_WETH9 = WETH9(payable(_l2weth9)); @@ -29,14 +33,16 @@ contract SnowbridgeL1Adaptor { // The fee (params.inputAmount - params.outputAmount) should be calculated off-chain // following https://docs.across.to/reference/api-reference#get-swap-approval // Tokens are pulled from the caller via safeTransferFrom. + // If SPOKE_POOL.deposit reverts, the entire transaction reverts atomically. function depositToken(DepositParams calldata params, address recipient, bytes32 topic) public { require(params.inputToken != address(0), "Input token cannot be zero address"); checkInputs(params, recipient); - // Pull tokens from the caller to avoid relying on pre-funding and then approve SpokePool + // Pull tokens from the caller and approve SpokePool IERC20(params.inputToken).safeTransferFrom(msg.sender, address(this), params.inputAmount); IERC20(params.inputToken).forceApprove(address(SPOKE_POOL), params.inputAmount); + // Deposit via Across protocol SPOKE_POOL.deposit( bytes32(uint256(uint160(recipient))), bytes32(uint256(uint160(recipient))), @@ -52,26 +58,24 @@ contract SnowbridgeL1Adaptor { "" // empty message ); - // Forward any remaining balance of the input token back to the recipient to avoid trapping funds - uint256 remaining = IERC20(params.inputToken).balanceOf(address(this)); - if (remaining > 0) { - IERC20(params.inputToken).safeTransfer(recipient, remaining); - } - uint256 depositId = SPOKE_POOL.numberOfDeposits() - 1; - emit DepositCallInvoked(topic, depositId); + emit DepositCallInvoked(topic, depositId, params.inputToken); } // Send native Ether on L1 to L2 by first wrapping it to WETH, then depositing via SPOKE_POOL. - function depositNativeEther( - DepositParams calldata params, - address payable recipient, - bytes32 topic - ) public payable { + function depositNativeEther(DepositParams calldata params, address recipient, bytes32 topic) + public + payable + { require( params.inputToken == address(0), "Input token must be zero address for native ETH deposits" ); + require( + params.outputToken == address(0) || params.outputToken == address(L2_WETH9), + "Output token must be zero address or L2 WETH for native ETH deposits" + ); + require(msg.value == params.inputAmount, "Sent ETH amount must equal inputAmount"); checkInputs(params, recipient); // Wrap native ETH to L1 WETH @@ -82,8 +86,8 @@ contract SnowbridgeL1Adaptor { // Deposit WETH via SPOKE_POOL SPOKE_POOL.deposit( - bytes32(uint256(uint160(address(recipient)))), - bytes32(uint256(uint160(address(recipient)))), + bytes32(uint256(uint160(recipient))), + bytes32(uint256(uint160(recipient))), bytes32(uint256(uint160(address(L1_WETH9)))), bytes32(uint256(uint160(address(L2_WETH9)))), params.inputAmount, @@ -96,15 +100,17 @@ contract SnowbridgeL1Adaptor { "" // empty message ); - // Forward any remaining balance back to the recipient to avoid trapping funds - uint256 remaining = address(this).balance; - if (remaining > 0) { - (bool success,) = recipient.call{value: remaining}(""); - require(success, "Failed to transfer remaining ether to recipient"); - } - uint256 depositId = SPOKE_POOL.numberOfDeposits() - 1; - emit DepositCallInvoked(topic, depositId); + emit DepositCallInvoked(topic, depositId, address(0)); + } + + /// @notice Explicit revert for any unexpected ETH transfers. + /// Direct ETH transfers to this contract are not allowed; + /// use depositNativeEther instead to properly handle ETH deposits. + receive() external payable { + revert( + "SnowbridgeL1Adaptor: Direct ETH transfers not allowed, use depositNativeEther instead" + ); } function checkInputs(DepositParams calldata params, address recipient) internal pure { @@ -115,6 +121,4 @@ contract SnowbridgeL1Adaptor { require(params.destinationChainId != 0, "Destination chain ID cannot be zero"); require(recipient != address(0), "Recipient cannot be zero address"); } - - receive() external payable {} } From 6c34fd5ea717104250c4a5a6e4d7980471c795b5 Mon Sep 17 00:00:00 2001 From: Ron Date: Mon, 19 Jan 2026 12:03:53 +0800 Subject: [PATCH 07/28] Update contracts/src/AgentExecutor.sol Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- contracts/src/AgentExecutor.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/contracts/src/AgentExecutor.sol b/contracts/src/AgentExecutor.sol index 4b1c2c565..67c542762 100644 --- a/contracts/src/AgentExecutor.sol +++ b/contracts/src/AgentExecutor.sol @@ -24,8 +24,8 @@ contract AgentExecutor { } // Call contract with Ether value - function callContract(CallContractParams calldata param) external { - bool success = Call.safeCall(param.target, param.data, param.value); + function callContract(CallContractParams calldata params) external { + bool success = Call.safeCall(params.target, params.data, params.value); if (!success) { revert(); } From 6ef87ff234058d711e61e75ffa4d878090e0d399 Mon Sep 17 00:00:00 2001 From: ron Date: Mon, 26 Jan 2026 17:33:01 +0800 Subject: [PATCH 08/28] Revert L2 changes --- .../l2-integration/across/test/TestSnowbridgeL1Adaptor.s.sol | 2 +- .../across/test/TestSnowbridgeL1AdaptorNativeEther.s.sol | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/contracts/scripts/l2-integration/across/test/TestSnowbridgeL1Adaptor.s.sol b/contracts/scripts/l2-integration/across/test/TestSnowbridgeL1Adaptor.s.sol index 45eec3645..38b1a6ad9 100644 --- a/contracts/scripts/l2-integration/across/test/TestSnowbridgeL1Adaptor.s.sol +++ b/contracts/scripts/l2-integration/across/test/TestSnowbridgeL1Adaptor.s.sol @@ -62,7 +62,7 @@ contract TestSnowbridgeL1Adaptor is Script { fillDeadlineBuffer: TIME_BUFFER }); - IERC20(params.inputToken).approve(l1SnowbridgeAdaptor, params.inputAmount); + IERC20(params.inputToken).transfer(l1SnowbridgeAdaptor, params.inputAmount); SnowbridgeL1Adaptor(l1SnowbridgeAdaptor) .depositToken(params, recipient, keccak256("TestERC20Deposit")); diff --git a/contracts/scripts/l2-integration/across/test/TestSnowbridgeL1AdaptorNativeEther.s.sol b/contracts/scripts/l2-integration/across/test/TestSnowbridgeL1AdaptorNativeEther.s.sol index 9562eaeae..a898a7055 100644 --- a/contracts/scripts/l2-integration/across/test/TestSnowbridgeL1AdaptorNativeEther.s.sol +++ b/contracts/scripts/l2-integration/across/test/TestSnowbridgeL1AdaptorNativeEther.s.sol @@ -23,7 +23,7 @@ contract TestSnowbridgeL1AdaptorNativeEther is Script { address payable l1SnowbridgeAdaptor = payable(vm.envAddress("L1_SNOWBRIDGE_ADAPTOR_ADDRESS")); - address payable recipient = payable(vm.envAddress("RECIPIENT_ADDRESS")); + address recipient = vm.envAddress("RECIPIENT_ADDRESS"); uint256 BASE_CHAIN_ID; uint32 TIME_BUFFER; From f418da7ffa49557ca73f9469f83f701bd89e9f2c Mon Sep 17 00:00:00 2001 From: ron Date: Mon, 26 Jan 2026 19:31:15 +0800 Subject: [PATCH 09/28] Add atomic flag --- contracts/src/Gateway.sol | 100 ++++++------- contracts/src/v2/IGateway.sol | 2 + contracts/src/v2/Types.sol | 1 + contracts/test/GatewayV2.t.sol | 216 ++++++++++++++++++--------- contracts/test/mocks/MockGateway.sol | 13 +- 5 files changed, 203 insertions(+), 129 deletions(-) diff --git a/contracts/src/Gateway.sol b/contracts/src/Gateway.sol index 386768dc1..4197d4ee9 100644 --- a/contracts/src/Gateway.sol +++ b/contracts/src/Gateway.sol @@ -431,13 +431,27 @@ contract Gateway is IGatewayBase, IGatewayV1, IGatewayV2, IInitializable, IUpgra revert IGatewayBase.InvalidProof(); } - // Dispatch the message payload. The boolean returned indicates whether all commands succeeded. - bool success = v2_dispatch(message); + // Dispatch the message payload. + bool dispatchSuccess = true; + try Gateway(this).v2_dispatch(message) returns (bool success) { + dispatchSuccess = success; + } catch (bytes memory reason) { + // If an atomic command failed or insufficient gas limit, rethrow the error to stop processing + // Otherwise, silently ignore command failures + if ( + reason.length >= 4 + && (bytes4(reason) == IGatewayV2.AtomicCommandFailed.selector + || bytes4(reason) == IGatewayV2.InsufficientGasLimit.selector) + ) { + assembly { + revert(add(reason, 32), mload(reason)) + } + } + } - // Emit the event with a success value "true" if all commands successfully executed, otherwise "false" - // if all or some of the commands failed. + // Emit the event indicating message dispatch was attempted. emit IGatewayV2.InboundMessageDispatched( - message.nonce, message.topic, success, rewardAddress + message.nonce, message.topic, dispatchSuccess, rewardAddress ); } @@ -481,37 +495,37 @@ contract Gateway is IGatewayBase, IGatewayV1, IGatewayV2, IInitializable, IUpgra */ // Perform an upgrade of the gateway - function v2_handleUpgrade(bytes calldata data) external onlySelf { + function _handleUpgrade(bytes calldata data) internal { HandlersV2.upgrade(data); } // Set the operating mode of the gateway - function v2_handleSetOperatingMode(bytes calldata data) external onlySelf { + function _handleSetOperatingMode(bytes calldata data) internal { HandlersV2.setOperatingMode(data); } // Unlock Native token - function v2_handleUnlockNativeToken(bytes calldata data) external onlySelf { + function _handleUnlockNativeToken(bytes calldata data) internal { HandlersV2.unlockNativeToken(AGENT_EXECUTOR, data); } // Register a new fungible Polkadot token for an agent - function v2_handleRegisterForeignToken(bytes calldata data) external onlySelf { + function _handleRegisterForeignToken(bytes calldata data) internal { HandlersV2.registerForeignToken(data); } // Mint foreign token from polkadot - function v2_handleMintForeignToken(bytes calldata data) external onlySelf { + function _handleMintForeignToken(bytes calldata data) internal { HandlersV2.mintForeignToken(data); } // Call an arbitrary contract function - function v2_handleCallContract(bytes32 origin, bytes calldata data) external onlySelf { + function _handleCallContract(bytes32 origin, bytes calldata data) internal { HandlersV2.callContract(origin, AGENT_EXECUTOR, data); } // Call multiple arbitrary contract functions - function v2_handleCallContracts(bytes32 origin, bytes calldata data) external onlySelf { + function _handleCallContracts(bytes32 origin, bytes calldata data) internal { HandlersV2.callContracts(origin, AGENT_EXECUTOR, data); } @@ -520,67 +534,53 @@ contract Gateway is IGatewayBase, IGatewayV1, IGatewayV2, IInitializable, IUpgra */ // Internal helper to dispatch a single command - function _dispatchCommand(CommandV2 calldata command, bytes32 origin) internal returns (bool) { + function _dispatchCommand(CommandV2 calldata command, bytes32 origin) internal { // check that there is enough gas available to forward to the command handler if (gasleft() * 63 / 64 < command.gas + DISPATCH_OVERHEAD_GAS_V2) { revert IGatewayV2.InsufficientGasLimit(); } if (command.kind == CommandKind.Upgrade) { - try Gateway(this).v2_handleUpgrade{gas: command.gas}(command.payload) {} - catch { - return false; - } + _handleUpgrade(command.payload); } else if (command.kind == CommandKind.SetOperatingMode) { - try Gateway(this).v2_handleSetOperatingMode{gas: command.gas}(command.payload) {} - catch { - return false; - } + _handleSetOperatingMode(command.payload); } else if (command.kind == CommandKind.UnlockNativeToken) { - try Gateway(this).v2_handleUnlockNativeToken{gas: command.gas}(command.payload) {} - catch { - return false; - } + _handleUnlockNativeToken(command.payload); } else if (command.kind == CommandKind.RegisterForeignToken) { - try Gateway(this).v2_handleRegisterForeignToken{gas: command.gas}(command.payload) {} - catch { - return false; - } + _handleRegisterForeignToken(command.payload); } else if (command.kind == CommandKind.MintForeignToken) { - try Gateway(this).v2_handleMintForeignToken{gas: command.gas}(command.payload) {} - catch { - return false; - } + _handleMintForeignToken(command.payload); } else if (command.kind == CommandKind.CallContract) { - try Gateway(this).v2_handleCallContract{gas: command.gas}(origin, command.payload) {} - catch { - return false; - } + _handleCallContract(origin, command.payload); } else if (command.kind == CommandKind.CallContracts) { - try Gateway(this).v2_handleCallContracts{gas: command.gas}(origin, command.payload) {} - catch { - return false; - } + _handleCallContracts(origin, command.payload); } else { - // Unknown command - return false; + revert IGatewayV2.InvalidCommand(); } - return true; } // Dispatch all the commands within the batch of commands in the message payload. Each command is processed // independently, such that failures emit a `CommandFailed` event without stopping execution of subsequent commands. - function v2_dispatch(InboundMessageV2 calldata message) internal returns (bool) { - bool allCommandsSucceeded = true; - + // Returns true if all commands executed successfully, false if any non-atomic command failed. + function v2_dispatch(InboundMessageV2 calldata message) external onlySelf returns (bool) { + bool success = true; for (uint256 i = 0; i < message.commands.length; i++) { - if (!_dispatchCommand(message.commands[i], message.origin)) { + CommandV2 calldata command = message.commands[i]; + try this.v2_dispatchCommand(command, message.origin) {} + catch { emit IGatewayV2.CommandFailed(message.nonce, i); - allCommandsSucceeded = false; + if (command.atomic) { + revert IGatewayV2.AtomicCommandFailed(); + } + success = false; } } + return success; + } - return allCommandsSucceeded; + // Helper function to dispatch a single command with try-catch for error handling + function v2_dispatchCommand(CommandV2 calldata command, bytes32 origin) external onlySelf { + _dispatchCommand(command, origin); } /** diff --git a/contracts/src/v2/IGateway.sol b/contracts/src/v2/IGateway.sol index 5fb684578..5165778de 100644 --- a/contracts/src/v2/IGateway.sol +++ b/contracts/src/v2/IGateway.sol @@ -11,6 +11,8 @@ interface IGatewayV2 { error InvalidNetwork(); error InvalidAsset(); error InsufficientGasLimit(); + error AtomicCommandFailed(); + error InvalidCommand(); error InsufficientValue(); error ExceededMaximumValue(); error TooManyAssets(); diff --git a/contracts/src/v2/Types.sol b/contracts/src/v2/Types.sol index 6d7052a89..ed89e6270 100644 --- a/contracts/src/v2/Types.sol +++ b/contracts/src/v2/Types.sol @@ -19,6 +19,7 @@ struct InboundMessage { struct Command { uint8 kind; uint64 gas; + bool atomic; bytes payload; } diff --git a/contracts/test/GatewayV2.t.sol b/contracts/test/GatewayV2.t.sol index 3369926d0..d3732eb04 100644 --- a/contracts/test/GatewayV2.t.sol +++ b/contracts/test/GatewayV2.t.sol @@ -224,7 +224,10 @@ contract GatewayV2Test is Test { CommandV2[] memory commands = new CommandV2[](1); SetOperatingModeParams memory params = SetOperatingModeParams({mode: OperatingMode.Normal}); commands[0] = CommandV2({ - kind: CommandKind.SetOperatingMode, gas: 500_000, payload: abi.encode(params) + kind: CommandKind.SetOperatingMode, + gas: 500_000, + atomic: false, + payload: abi.encode(params) }); return commands; } @@ -243,8 +246,9 @@ contract GatewayV2Test is Test { bytes memory payload = abi.encode(params); CommandV2[] memory commands = new CommandV2[](1); - commands[0] = - CommandV2({kind: CommandKind.UnlockNativeToken, gas: 500_000, payload: payload}); + commands[0] = CommandV2({ + kind: CommandKind.UnlockNativeToken, gas: 500_000, atomic: false, payload: payload + }); return commands; } @@ -259,8 +263,9 @@ contract GatewayV2Test is Test { bytes memory payload = abi.encode(params); CommandV2[] memory commands = new CommandV2[](1); - commands[0] = - CommandV2({kind: CommandKind.RegisterForeignToken, gas: 1_200_000, payload: payload}); + commands[0] = CommandV2({ + kind: CommandKind.RegisterForeignToken, gas: 1_200_000, atomic: false, payload: payload + }); return commands; } @@ -273,8 +278,9 @@ contract GatewayV2Test is Test { bytes memory payload = abi.encode(params); CommandV2[] memory commands = new CommandV2[](1); - commands[0] = - CommandV2({kind: CommandKind.MintForeignToken, gas: 100_000, payload: payload}); + commands[0] = CommandV2({ + kind: CommandKind.MintForeignToken, gas: 100_000, atomic: false, payload: payload + }); return commands; } @@ -285,7 +291,9 @@ contract GatewayV2Test is Test { bytes memory payload = abi.encode(params); CommandV2[] memory commands = new CommandV2[](1); - commands[0] = CommandV2({kind: CommandKind.CallContract, gas: 500_000, payload: payload}); + commands[0] = CommandV2({ + kind: CommandKind.CallContract, gas: 500_000, atomic: false, payload: payload + }); return commands; } @@ -300,7 +308,9 @@ contract GatewayV2Test is Test { bytes memory payload = abi.encode(params); CommandV2[] memory commands = new CommandV2[](1); - commands[0] = CommandV2({kind: CommandKind.CallContract, gas: 500_000, payload: payload}); + commands[0] = CommandV2({ + kind: CommandKind.CallContract, gas: 500_000, atomic: false, payload: payload + }); return commands; } @@ -315,7 +325,8 @@ contract GatewayV2Test is Test { bytes memory payload = abi.encode(params); CommandV2[] memory commands = new CommandV2[](1); - commands[0] = CommandV2({kind: CommandKind.CallContract, gas: 1, payload: payload}); + commands[0] = + CommandV2({kind: CommandKind.CallContract, gas: 1, atomic: false, payload: payload}); return commands; } @@ -334,7 +345,9 @@ contract GatewayV2Test is Test { bytes memory payload = abi.encode(params); CommandV2[] memory commands = new CommandV2[](1); - commands[0] = CommandV2({kind: CommandKind.CallContracts, gas: 500_000, payload: payload}); + commands[0] = CommandV2({ + kind: CommandKind.CallContracts, gas: 500_000, atomic: false, payload: payload + }); return commands; } @@ -401,6 +414,7 @@ contract GatewayV2Test is Test { commands[0] = CommandV2({ kind: CommandKind.SetOperatingMode, gas: 30_000_000, // Extremely high gas value + atomic: false, payload: abi.encode(params) }); @@ -415,7 +429,11 @@ contract GatewayV2Test is Test { uint256 gasLimit = 100_000; vm.deal(relayer, 1 ether); - vm.expectRevert(IGatewayV2.InsufficientGasLimit.selector); + vm.expectEmit(true, false, false, true); + emit IGatewayV2.CommandFailed(2, 0); + vm.expectEmit(true, false, false, true); + emit IGatewayV2.InboundMessageDispatched(2, topic, false, relayerRewardAddress); + vm.prank(relayer); IGatewayV2(address(gateway)) .v2_submit{gas: gasLimit}(message, proof, makeMockProof(), relayerRewardAddress); @@ -484,7 +502,8 @@ contract GatewayV2Test is Test { claimer: "", value: 0.5 ether, executionFee: 0.1 ether, - relayerFee: 0.4 ether}) + relayerFee: 0.4 ether + }) ); hoax(user1); @@ -755,7 +774,10 @@ contract GatewayV2Test is Test { SetOperatingModeParams memory params1 = SetOperatingModeParams({mode: OperatingMode.Normal}); commands[0] = CommandV2({ - kind: CommandKind.SetOperatingMode, gas: 500_000, payload: abi.encode(params1) + kind: CommandKind.SetOperatingMode, + gas: 500_000, + atomic: false, + payload: abi.encode(params1) }); // Second command should fail - Call a function that reverts @@ -763,21 +785,27 @@ contract GatewayV2Test is Test { CallContractParams memory params2 = CallContractParams({target: address(helloWorld), data: failingData, value: 0}); commands[1] = CommandV2({ - kind: CommandKind.CallContract, gas: 500_000, payload: abi.encode(params2) + kind: CommandKind.CallContract, + gas: 500_000, + atomic: false, + payload: abi.encode(params2) }); // Third command should succeed - SetOperatingMode again SetOperatingModeParams memory params3 = SetOperatingModeParams({mode: OperatingMode.Normal}); commands[2] = CommandV2({ - kind: CommandKind.SetOperatingMode, gas: 500_000, payload: abi.encode(params3) + kind: CommandKind.SetOperatingMode, + gas: 500_000, + atomic: false, + payload: abi.encode(params3) }); // Expect the failed command to emit CommandFailed event vm.expectEmit(true, false, false, true); emit IGatewayV2.CommandFailed(1, 1); // nonce 1, command index 1 - // Expect InboundMessageDispatched to be emitted with success=false since not all commands succeeded + // Expect InboundMessageDispatched to be emitted with success=false (command failed) vm.expectEmit(true, false, false, true); emit IGatewayV2.InboundMessageDispatched(1, topic, false, relayerRewardAddress); @@ -803,13 +831,17 @@ contract GatewayV2Test is Test { SetOperatingModeParams memory params1 = SetOperatingModeParams({mode: OperatingMode.Normal}); commands[0] = CommandV2({ - kind: CommandKind.SetOperatingMode, gas: 500_000, payload: abi.encode(params1) + kind: CommandKind.SetOperatingMode, + gas: 500_000, + atomic: false, + payload: abi.encode(params1) }); // Second command is invalid commands[1] = CommandV2({ kind: 255, // Invalid command kind gas: 500_000, + atomic: false, payload: abi.encode(bytes32(0)) }); @@ -817,7 +849,7 @@ contract GatewayV2Test is Test { vm.expectEmit(true, false, false, true); emit IGatewayV2.CommandFailed(2, 1); // nonce 2, command index 1 - // Expect InboundMessageDispatched to be emitted with success=false + // Expect InboundMessageDispatched to be emitted with success=false (command failed) vm.expectEmit(true, false, false, true); emit IGatewayV2.InboundMessageDispatched(2, topic, false, relayerRewardAddress); @@ -843,21 +875,30 @@ contract GatewayV2Test is Test { SetOperatingModeParams memory params1 = SetOperatingModeParams({mode: OperatingMode.Normal}); commands[0] = CommandV2({ - kind: CommandKind.SetOperatingMode, gas: 500_000, payload: abi.encode(params1) + kind: CommandKind.SetOperatingMode, + gas: 500_000, + atomic: false, + payload: abi.encode(params1) }); // Second command - Set mode to RejectingOutboundMessages (will succeed) SetOperatingModeParams memory params2 = SetOperatingModeParams({mode: OperatingMode.RejectingOutboundMessages}); commands[1] = CommandV2({ - kind: CommandKind.SetOperatingMode, gas: 500_000, payload: abi.encode(params2) + kind: CommandKind.SetOperatingMode, + gas: 500_000, + atomic: false, + payload: abi.encode(params2) }); // Third command - Also set mode to Normal again (will succeed) SetOperatingModeParams memory params3 = SetOperatingModeParams({mode: OperatingMode.Normal}); commands[2] = CommandV2({ - kind: CommandKind.SetOperatingMode, gas: 500_000, payload: abi.encode(params3) + kind: CommandKind.SetOperatingMode, + gas: 500_000, + atomic: false, + payload: abi.encode(params3) }); // Expect InboundMessageDispatched to be emitted with success=true since all commands should succeed @@ -879,42 +920,49 @@ contract GatewayV2Test is Test { function testUnknownCommandReturnsFalse() public { bytes memory payload = ""; CommandV2 memory cmd = - CommandV2({kind: uint8(200), gas: uint64(100_000), payload: payload}); + CommandV2({kind: uint8(200), gas: uint64(100_000), payload: payload, atomic: false}); - bool ok = gatewayLogic.callDispatch(cmd, bytes32(0)); - assertTrue(!ok, "unknown command should return false"); + // unknown command should revert with InvalidCommand + vm.expectRevert(IGatewayV2.InvalidCommand.selector); + gatewayLogic.callDispatch(cmd, bytes32(0)); } function testSetOperatingModeSucceeds() public { bytes memory payload = abi.encode((SetOperatingModeParams({mode: OperatingMode.Normal}))); CommandV2 memory cmd = CommandV2({ - kind: CommandKind.SetOperatingMode, gas: uint64(200_000), payload: payload + kind: CommandKind.SetOperatingMode, + gas: uint64(200_000), + atomic: false, + payload: payload }); // Expect the OperatingModeChanged event to be emitted vm.expectEmit(true, false, false, true); emit IGatewayBase.OperatingModeChanged(OperatingMode.Normal); - bool ok = gatewayLogic.callDispatch(cmd, bytes32(0)); - assertTrue(ok, "setOperatingMode should succeed"); + gatewayLogic.callDispatch(cmd, bytes32(0)); // Verify mode was set assertEq(uint256(gatewayLogic.operatingMode()), uint256(OperatingMode.Normal)); } function testHandlerRevertIsCaught_UnlockNativeToken() public { - // Ensure no agent exists for ASSET_HUB_AGENT_ID so ensureAgent will revert and _dispatchCommand returns false + // Ensure no agent exists for ASSET_HUB_AGENT_ID so ensureAgent will revert UnlockNativeTokenParams memory params = UnlockNativeTokenParams({ token: address(0), recipient: address(this), amount: uint128(1) }); bytes memory payload = abi.encode(params); CommandV2 memory cmd = CommandV2({ - kind: CommandKind.UnlockNativeToken, gas: uint64(200_000), payload: payload + kind: CommandKind.UnlockNativeToken, + gas: uint64(200_000), + payload: payload, + atomic: false }); - bool ok = gatewayLogic.callDispatch(cmd, bytes32(0)); - assertTrue(!ok, "handler revert should be caught and return false"); + // handler revert should be caught and command should fail (revert) + vm.expectRevert(); + gatewayLogic.callDispatch(cmd, bytes32(0)); } function testHandlerRevertIsCaught_UpgradeInvalidImpl() public { @@ -923,11 +971,13 @@ contract GatewayV2Test is Test { UpgradeParams({impl: address(0), implCodeHash: bytes32(0), initParams: ""}); bytes memory payload = abi.encode(up); - CommandV2 memory cmd = - CommandV2({kind: CommandKind.Upgrade, gas: uint64(200_000), payload: payload}); + CommandV2 memory cmd = CommandV2({ + kind: CommandKind.Upgrade, gas: uint64(200_000), payload: payload, atomic: false + }); - bool ok = gatewayLogic.callDispatch(cmd, bytes32(0)); - assertTrue(!ok, "upgrade with invalid impl should be caught and return false"); + // upgrade with invalid impl should revert + vm.expectRevert(); + gatewayLogic.callDispatch(cmd, bytes32(0)); } function testMintForeignTokenNotRegisteredReturnsFalse() public { @@ -937,11 +987,15 @@ contract GatewayV2Test is Test { bytes memory payload = abi.encode(p); CommandV2 memory cmd = CommandV2({ - kind: CommandKind.MintForeignToken, gas: uint64(200_000), payload: payload + kind: CommandKind.MintForeignToken, + gas: uint64(200_000), + payload: payload, + atomic: false }); - bool ok = gatewayLogic.callDispatch(cmd, bytes32(0)); - assertTrue(!ok, "mintForeignToken for unregistered token should return false"); + // mintForeignToken for unregistered token should revert + vm.expectRevert(); + gatewayLogic.callDispatch(cmd, bytes32(0)); } function testRegisterForeignTokenDuplicateReturnsFalse() public { @@ -952,14 +1006,18 @@ contract GatewayV2Test is Test { bytes memory payload = abi.encode(p); CommandV2 memory cmd = CommandV2({ - kind: CommandKind.RegisterForeignToken, gas: uint64(3_000_000), payload: payload + kind: CommandKind.RegisterForeignToken, + gas: uint64(3_000_000), + payload: payload, + atomic: false }); - bool ok1 = gatewayLogic.callDispatch(cmd, bytes32(0)); - assertTrue(ok1, "first register should succeed"); + // first register should succeed + gatewayLogic.callDispatch(cmd, bytes32(0)); - bool ok2 = gatewayLogic.callDispatch(cmd, bytes32(0)); - assertTrue(!ok2, "duplicate register should return false"); + // duplicate register should revert + vm.expectRevert(); + gatewayLogic.callDispatch(cmd, bytes32(0)); } function testCallContractAgentDoesNotExistReturnsFalse() public { @@ -968,18 +1026,23 @@ contract GatewayV2Test is Test { bytes memory payload = abi.encode(p); // origin corresponds to agent id; use a non-existent id - CommandV2 memory cmd = - CommandV2({kind: CommandKind.CallContract, gas: uint64(200_000), payload: payload}); + CommandV2 memory cmd = CommandV2({ + kind: CommandKind.CallContract, gas: uint64(200_000), atomic: false, payload: payload + }); - bool ok = gatewayLogic.callDispatch(cmd, bytes32(uint256(0x9999))); - assertTrue(!ok, "callContract with missing agent should return false"); + // callContract with missing agent should revert + vm.expectRevert(); + gatewayLogic.callDispatch(cmd, bytes32(uint256(0x9999))); } function testInsufficientGasReverts() public { bytes memory payload = ""; // Use an extremely large gas value to trigger InsufficientGasLimit revert in _dispatchCommand CommandV2 memory cmd = CommandV2({ - kind: CommandKind.SetOperatingMode, gas: type(uint64).max, payload: payload + kind: CommandKind.SetOperatingMode, + gas: type(uint64).max, + atomic: false, + payload: payload }); vm.expectRevert(); @@ -1057,9 +1120,7 @@ contract GatewayV2Test is Test { vm.deal(assetHubAgent, 1 ether); hoax(relayer, 1 ether); - vm.expectEmit(true, false, false, true); - emit IGatewayV2.CommandFailed(1, 0); - emit IGatewayV2.InboundMessageDispatched(1, topic, false, relayerRewardAddress); + emit IGatewayV2.InboundMessageDispatched(1, topic, true, relayerRewardAddress); IGatewayV2(address(gateway)) .v2_submit( InboundMessageV2({ @@ -1080,9 +1141,11 @@ contract GatewayV2Test is Test { vm.deal(assetHubAgent, 1 ether); hoax(relayer, 1 ether); + // InsufficientGasLimit during dispatch is caught and emits CommandFailed + // but with very low gas (1), the dispatch might fail differently vm.expectEmit(true, false, false, true); - emit IGatewayV2.CommandFailed(1, 0); - emit IGatewayV2.InboundMessageDispatched(1, topic, false, relayerRewardAddress); + emit IGatewayV2.InboundMessageDispatched(1, topic, true, relayerRewardAddress); + IGatewayV2(address(gateway)) .v2_submit( InboundMessageV2({ @@ -1144,11 +1207,8 @@ contract GatewayV2Test is Test { function test_onlySelf_enforced_on_external_calls() public { MockGateway gw = MockGateway(address(gateway)); - // calling the handler externally should revert with Unauthorized - SetOperatingModeParams memory p = SetOperatingModeParams({mode: OperatingMode.Normal}); - bytes memory payload = abi.encode(p); - vm.expectRevert(IGatewayBase.Unauthorized.selector); - gw.v2_handleSetOperatingMode(payload); + // Since v2_handleSetOperatingMode is now internal, we can't call it externally + // This test is no longer applicable } function test_call_handleSetOperatingMode_via_self_changes_mode() public { @@ -1164,9 +1224,9 @@ contract GatewayV2Test is Test { function test_dispatch_unknown_command_returns_false() public { MockGateway gw = MockGateway(address(gateway)); - CommandV2 memory cmd = CommandV2({kind: 0xFF, gas: 100_000, payload: ""}); - bool ok = gw.exposed_dispatchCommand(cmd, bytes32(0)); - assertFalse(ok, "unknown command must return false"); + CommandV2 memory cmd = CommandV2({kind: 0xFF, gas: 100_000, atomic: false, payload: ""}); + vm.expectRevert(IGatewayV2.InvalidCommand.selector); + gw.exposed_dispatchCommand(cmd, bytes32(0)); } function test_v2_dispatch_partial_failure_emits_CommandFailed() public { @@ -1174,13 +1234,15 @@ contract GatewayV2Test is Test { // Build two commands: SetOperatingMode (should succeed) and CallContract (will fail due to missing agent) CommandV2[] memory cmds = new CommandV2[](2); SetOperatingModeParams memory p = SetOperatingModeParams({mode: OperatingMode.Normal}); - cmds[0] = - CommandV2({kind: CommandKind.SetOperatingMode, gas: 200_000, payload: abi.encode(p)}); + cmds[0] = CommandV2({ + kind: CommandKind.SetOperatingMode, gas: 200_000, atomic: false, payload: abi.encode(p) + }); CallContractParams memory cc = CallContractParams({target: address(0x1234), data: "", value: 0}); - cmds[1] = - CommandV2({kind: CommandKind.CallContract, gas: 200_000, payload: abi.encode(cc)}); + cmds[1] = CommandV2({ + kind: CommandKind.CallContract, gas: 200_000, atomic: false, payload: abi.encode(cc) + }); InboundMessageV2 memory msgv; msgv.origin = bytes32("orig"); msgv.nonce = 1; @@ -1263,10 +1325,16 @@ contract GatewayV2Test is Test { CommandV2[] memory commands = new CommandV2[](2); commands[0] = CommandV2({ - kind: CommandKind.UnlockNativeToken, gas: 500_000, payload: abi.encode(unlockParams) + kind: CommandKind.UnlockNativeToken, + gas: 500_000, + atomic: false, + payload: abi.encode(unlockParams) }); commands[1] = CommandV2({ - kind: CommandKind.CallContracts, gas: 500_000, payload: abi.encode(callParams) + kind: CommandKind.CallContracts, + gas: 500_000, + atomic: false, + payload: abi.encode(callParams) }); // Fund agent with balance for gas @@ -1342,17 +1410,23 @@ contract GatewayV2Test is Test { CommandV2[] memory commands = new CommandV2[](2); commands[0] = CommandV2({ - kind: CommandKind.UnlockNativeToken, gas: 500_000, payload: abi.encode(unlockParams) + kind: CommandKind.UnlockNativeToken, + gas: 500_000, + atomic: false, + payload: abi.encode(unlockParams) }); commands[1] = CommandV2({ - kind: CommandKind.CallContracts, gas: 500_000, payload: abi.encode(callParams) + kind: CommandKind.CallContracts, + gas: 500_000, + atomic: false, + payload: abi.encode(callParams) }); // Expect the CallContracts command to fail vm.expectEmit(true, false, false, true); emit IGatewayV2.CommandFailed(1, 1); // nonce 1, command index 1 - // Expect InboundMessageDispatched to be emitted with success=false + // Expect InboundMessageDispatched to be emitted with success=false (command failed) vm.expectEmit(true, false, false, true); emit IGatewayV2.InboundMessageDispatched(1, topic, false, relayerRewardAddress); diff --git a/contracts/test/mocks/MockGateway.sol b/contracts/test/mocks/MockGateway.sol index dcf4bb9fc..d66e90905 100644 --- a/contracts/test/mocks/MockGateway.sol +++ b/contracts/test/mocks/MockGateway.sol @@ -92,8 +92,8 @@ contract MockGateway is Gateway { return super.v1_transactionBaseGas(); } - function callDispatch(CommandV2 calldata command, bytes32 origin) external returns (bool) { - return super._dispatchCommand(command, origin); + function callDispatch(CommandV2 calldata command, bytes32 origin) external { + this.v2_dispatchCommand(command, origin); } function deployAgent() external returns (address) { @@ -114,12 +114,9 @@ contract MockGateway is Gateway { return v1_transactionBaseGas(); } - // Wrapper to call an internal dispatch and return the boolean result - function exposed_dispatchCommand(CommandV2 calldata cmd, bytes32 origin) - external - returns (bool) - { - return _dispatchCommand(cmd, origin); + // Wrapper to call an internal dispatch command + function exposed_dispatchCommand(CommandV2 calldata cmd, bytes32 origin) external { + _dispatchCommand(cmd, origin); } // Helper to call vulnerable-onlySelf handler from within the contract (so msg.sender == this) From 71939ab3de565ad63941072ee41a25fa9f9dc64c Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 26 Jan 2026 20:54:14 +0800 Subject: [PATCH 10/28] [WIP] Address feedback on multiple contract calls implementation (#1685) * Initial plan * Fix dispatchSuccess initialization to false Co-authored-by: yrong <4383920+yrong@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: yrong <4383920+yrong@users.noreply.github.com> --- contracts/src/Gateway.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/src/Gateway.sol b/contracts/src/Gateway.sol index 4197d4ee9..890b40e3b 100644 --- a/contracts/src/Gateway.sol +++ b/contracts/src/Gateway.sol @@ -432,7 +432,7 @@ contract Gateway is IGatewayBase, IGatewayV1, IGatewayV2, IInitializable, IUpgra } // Dispatch the message payload. - bool dispatchSuccess = true; + bool dispatchSuccess = false; try Gateway(this).v2_dispatch(message) returns (bool success) { dispatchSuccess = success; } catch (bytes memory reason) { From 432469c43556955bcfa87eb2f5185d9c8e60ccf7 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 26 Jan 2026 22:29:40 +0800 Subject: [PATCH 11/28] Remove trailing blank line in GatewayV2.t.sol (#1686) * Initial plan * Remove extra blank line for consistency Co-authored-by: yrong <4383920+yrong@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: yrong <4383920+yrong@users.noreply.github.com> --- contracts/test/GatewayV2.t.sol | 1 - 1 file changed, 1 deletion(-) diff --git a/contracts/test/GatewayV2.t.sol b/contracts/test/GatewayV2.t.sol index d3732eb04..31c4715f9 100644 --- a/contracts/test/GatewayV2.t.sol +++ b/contracts/test/GatewayV2.t.sol @@ -1465,4 +1465,3 @@ contract GatewayV2Test is Test { ); } } - From 5385b9b0f3075c12c8d1e379758f684c05ebd5af Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 27 Jan 2026 00:31:58 +0800 Subject: [PATCH 12/28] Propagate InsufficientGasLimit errors from v2_dispatch (#1688) * Initial plan * Implement Option A: Propagate InsufficientGasLimit from v2_dispatch Co-authored-by: yrong <4383920+yrong@users.noreply.github.com> * Simplify InsufficientGasLimit rethrow logic Co-authored-by: yrong <4383920+yrong@users.noreply.github.com> * Fix tests --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: yrong <4383920+yrong@users.noreply.github.com> Co-authored-by: ron --- .gitignore | 1 + contracts/src/Gateway.sol | 6 +++- contracts/test/GatewayV2.t.sol | 61 +++++++++++++++++----------------- 3 files changed, 36 insertions(+), 32 deletions(-) diff --git a/.gitignore b/.gitignore index be434ea19..70cb2c32c 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,4 @@ lodestar db.sqlite* .pnpm-store /deploy +lib/ diff --git a/contracts/src/Gateway.sol b/contracts/src/Gateway.sol index 890b40e3b..616f146fc 100644 --- a/contracts/src/Gateway.sol +++ b/contracts/src/Gateway.sol @@ -567,7 +567,11 @@ contract Gateway is IGatewayBase, IGatewayV1, IGatewayV2, IInitializable, IUpgra for (uint256 i = 0; i < message.commands.length; i++) { CommandV2 calldata command = message.commands[i]; try this.v2_dispatchCommand(command, message.origin) {} - catch { + catch (bytes memory reason) { + // Check if the error is InsufficientGasLimit and rethrow it + if (reason.length >= 4 && bytes4(reason) == IGatewayV2.InsufficientGasLimit.selector) { + revert IGatewayV2.InsufficientGasLimit(); + } emit IGatewayV2.CommandFailed(message.nonce, i); if (command.atomic) { revert IGatewayV2.AtomicCommandFailed(); diff --git a/contracts/test/GatewayV2.t.sol b/contracts/test/GatewayV2.t.sol index 31c4715f9..8677bb8b1 100644 --- a/contracts/test/GatewayV2.t.sol +++ b/contracts/test/GatewayV2.t.sol @@ -428,15 +428,12 @@ contract GatewayV2Test is Test { // Limit the gas for this test to ensure we hit the NotEnoughGas error uint256 gasLimit = 100_000; vm.deal(relayer, 1 ether); - - vm.expectEmit(true, false, false, true); - emit IGatewayV2.CommandFailed(2, 0); - vm.expectEmit(true, false, false, true); - emit IGatewayV2.InboundMessageDispatched(2, topic, false, relayerRewardAddress); - vm.prank(relayer); - IGatewayV2(address(gateway)) - .v2_submit{gas: gasLimit}(message, proof, makeMockProof(), relayerRewardAddress); + + vm.expectRevert(IGatewayV2.InsufficientGasLimit.selector); + IGatewayV2(address(gateway)).v2_submit{gas: gasLimit}( + message, proof, makeMockProof(), relayerRewardAddress + ); } function mockNativeTokenForSend(address user, uint128 amount) @@ -507,8 +504,9 @@ contract GatewayV2Test is Test { ); hoax(user1); - IGatewayV2(payable(address(gateway))) - .v2_sendMessage{value: 1 ether}("", assets, "", 0.1 ether, 0.4 ether); + IGatewayV2(payable(address(gateway))).v2_sendMessage{value: 1 ether}( + "", assets, "", 0.1 ether, 0.4 ether + ); // Verify asset balances assertEq(assetHubAgent.balance, 1 ether); @@ -533,8 +531,9 @@ contract GatewayV2Test is Test { vm.expectRevert(); hoax(user1); - IGatewayV2(payable(address(gateway))) - .v2_sendMessage{value: 1 ether}("", assets, "", 0.1 ether, 0.4 ether); + IGatewayV2(payable(address(gateway))).v2_sendMessage{value: 1 ether}( + "", assets, "", 0.1 ether, 0.4 ether + ); assertEq(feeToken.balanceOf(assetHubAgent), 0); } @@ -542,16 +541,18 @@ contract GatewayV2Test is Test { function testSendMessageFailsWithInsufficentValue() public { vm.expectRevert(IGatewayV2.InsufficientValue.selector); hoax(user1, 1 ether); - IGatewayV2(payable(address(gateway))) - .v2_sendMessage{value: 0.4 ether}("", new bytes[](0), "", 0.1 ether, 0.4 ether); + IGatewayV2(payable(address(gateway))).v2_sendMessage{value: 0.4 ether}( + "", new bytes[](0), "", 0.1 ether, 0.4 ether + ); } function testSendMessageFailsWithExceededMaximumValue() public { vm.expectRevert(IGatewayV2.ExceededMaximumValue.selector); uint256 value = uint256(type(uint128).max) + 1; hoax(user1, value); - IGatewayV2(payable(address(gateway))) - .v2_sendMessage{value: value}("", new bytes[](0), "", 0.1 ether, 0.4 ether); + IGatewayV2(payable(address(gateway))).v2_sendMessage{value: value}( + "", new bytes[](0), "", 0.1 ether, 0.4 ether + ); } function testUnlockWethSuccess() public { @@ -717,10 +718,9 @@ contract GatewayV2Test is Test { uint256 totalRequired = executionFee + relayerFee; hoax(user1, totalRequired); - IGatewayV2(payable(address(gateway))) - .v2_registerToken{ - value: totalRequired - }(validTokenContract, uint8(0), executionFee, relayerFee); + IGatewayV2(payable(address(gateway))).v2_registerToken{value: totalRequired}( + validTokenContract, uint8(0), executionFee, relayerFee + ); // Verify the token is registered assertTrue(IGatewayV2(address(gateway)).isTokenRegistered(validTokenContract)); @@ -737,10 +737,9 @@ contract GatewayV2Test is Test { vm.expectRevert(IGatewayV2.InsufficientValue.selector); hoax(user1, totalRequired); - IGatewayV2(payable(address(gateway))) - .v2_registerToken{ - value: totalRequired - 1 - }(validTokenContract, uint8(0), executionFee, relayerFee); + IGatewayV2(payable(address(gateway))).v2_registerToken{value: totalRequired - 1}( + validTokenContract, uint8(0), executionFee, relayerFee + ); // Verify token still is not registered after the failed attempt assertFalse(IGatewayV2(address(gateway)).isTokenRegistered(validTokenContract)); @@ -757,8 +756,9 @@ contract GatewayV2Test is Test { vm.expectRevert(IGatewayV2.ExceededMaximumValue.selector); uint256 value = uint256(type(uint128).max) + 1; hoax(user1, value); - IGatewayV2(payable(address(gateway))) - .v2_registerToken{value: value}(validTokenContract, uint8(0), executionFee, relayerFee); + IGatewayV2(payable(address(gateway))).v2_registerToken{value: value}( + validTokenContract, uint8(0), executionFee, relayerFee + ); // Verify token still is not registered after the failed attempt assertFalse(IGatewayV2(address(gateway)).isTokenRegistered(validTokenContract)); @@ -1135,16 +1135,15 @@ contract GatewayV2Test is Test { ); } - function testAgentCallContractRevertedForInsufficientGas() public { + function testAgentCallContractWontRevertForInsufficientGas() public { bytes32 topic = keccak256("topic"); vm.deal(assetHubAgent, 1 ether); hoax(relayer, 1 ether); - // InsufficientGasLimit during dispatch is caught and emits CommandFailed - // but with very low gas (1), the dispatch might fail differently - vm.expectEmit(true, false, false, true); - emit IGatewayV2.InboundMessageDispatched(1, topic, true, relayerRewardAddress); + // After Option A implementation, InsufficientGasLimit is now propagated + // and causes the entire v2_submit to revert + vm.expectRevert(IGatewayV2.InsufficientGasLimit.selector); IGatewayV2(address(gateway)) .v2_submit( From daead2b3a6c8c3b72ad7b0c95aa125a743520f66 Mon Sep 17 00:00:00 2001 From: ron Date: Tue, 27 Jan 2026 02:04:10 +0800 Subject: [PATCH 13/28] Fix tests --- contracts/src/AgentExecutor.sol | 7 +- contracts/src/Gateway.sol | 5 +- contracts/src/utils/Call.sol | 53 +++++++-- contracts/src/v2/Types.sol | 2 + contracts/test/GatewayV2.t.sol | 166 +++++++++++++--------------- contracts/test/mocks/HelloWorld.sol | 10 +- 6 files changed, 139 insertions(+), 104 deletions(-) diff --git a/contracts/src/AgentExecutor.sol b/contracts/src/AgentExecutor.sol index aa3000ecf..a7b9c4ec5 100644 --- a/contracts/src/AgentExecutor.sol +++ b/contracts/src/AgentExecutor.sol @@ -25,7 +25,8 @@ contract AgentExecutor { // Call contract with Ether value function callContract(CallContractParams calldata params) external { - bool success = Call.safeCall(params.target, params.data, params.value); + bool success = + Call.safeCallWithGasLimit(params.target, params.data, params.value, params.gas); if (!success) { revert(); } @@ -35,7 +36,9 @@ contract AgentExecutor { 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); + bool success = Call.safeCallWithGasLimit( + params[i].target, params[i].data, params[i].value, params[i].gas + ); if (!success) { revert(); } diff --git a/contracts/src/Gateway.sol b/contracts/src/Gateway.sol index 616f146fc..f1447b598 100644 --- a/contracts/src/Gateway.sol +++ b/contracts/src/Gateway.sol @@ -569,7 +569,10 @@ contract Gateway is IGatewayBase, IGatewayV1, IGatewayV2, IInitializable, IUpgra try this.v2_dispatchCommand(command, message.origin) {} catch (bytes memory reason) { // Check if the error is InsufficientGasLimit and rethrow it - if (reason.length >= 4 && bytes4(reason) == IGatewayV2.InsufficientGasLimit.selector) { + if ( + reason.length >= 4 + && bytes4(reason) == IGatewayV2.InsufficientGasLimit.selector + ) { revert IGatewayV2.InsufficientGasLimit(); } emit IGatewayV2.CommandFailed(message.nonce, i); diff --git a/contracts/src/utils/Call.sol b/contracts/src/utils/Call.sol index a685c0ecb..16d0a451c 100644 --- a/contracts/src/utils/Call.sol +++ b/contracts/src/utils/Call.sol @@ -5,6 +5,8 @@ pragma solidity 0.8.33; // Derived from OpenZeppelin Contracts (last updated v4.9.0) (utils/Address.sol) library Call { + error InvalidGasLimit(); + function verifyResult(bool success, bytes memory returndata) internal pure @@ -36,16 +38,47 @@ library Call { function safeCall(address target, bytes memory data, uint256 value) internal returns (bool) { bool success; assembly { - success := - call( - gas(), // gas - target, // recipient - value, // ether value - add(data, 0x20), // inloc - mload(data), // inlen - 0, // outloc - 0 // outlen - ) + success := call( + gas(), // gas + target, // recipient + value, // ether value + add(data, 0x20), // inloc + mload(data), // inlen + 0, // outloc + 0 // outlen + ) + } + return success; + } + + /** + * @notice Safely perform a low level call with a gas limit without copying any returndata + * + * @param target Address to call + * @param data Calldata to pass to the call + */ + function safeCallWithGasLimit( + address target, + bytes memory data, + uint256 value, + uint64 gasLimit + ) internal returns (bool) { + // Disallow zero gas to avoid silent no-op calls. + if (gasLimit == 0) { + revert InvalidGasLimit(); + } + + bool success; + assembly { + success := call( + gasLimit, // gas + target, // recipient + value, // ether value + add(data, 0x20), // inloc + mload(data), // inlen + 0, // outloc + 0 // outlen + ) } return success; } diff --git a/contracts/src/v2/Types.sol b/contracts/src/v2/Types.sol index ed89e6270..d91c24828 100644 --- a/contracts/src/v2/Types.sol +++ b/contracts/src/v2/Types.sol @@ -186,6 +186,8 @@ struct CallContractParams { bytes data; // Ether value uint256 value; + // Gas limit + uint64 gas; } enum Network { diff --git a/contracts/test/GatewayV2.t.sol b/contracts/test/GatewayV2.t.sol index 8677bb8b1..03e9e8f71 100644 --- a/contracts/test/GatewayV2.t.sol +++ b/contracts/test/GatewayV2.t.sol @@ -286,8 +286,9 @@ contract GatewayV2Test is Test { function makeCallContractCommand(uint256 value) public view returns (CommandV2[] memory) { bytes memory data = abi.encodeWithSignature("sayHello(string)", "World"); - CallContractParams memory params = - CallContractParams({target: address(helloWorld), data: data, value: value}); + CallContractParams memory params = CallContractParams({ + target: address(helloWorld), data: data, value: value, gas: 20_000 + }); bytes memory payload = abi.encode(params); CommandV2[] memory commands = new CommandV2[](1); @@ -303,8 +304,9 @@ contract GatewayV2Test is Test { returns (CommandV2[] memory) { bytes memory data = abi.encodeWithSignature("sayHelloNotExists(string)", "World"); - CallContractParams memory params = - CallContractParams({target: address(helloWorld), data: data, value: value}); + CallContractParams memory params = CallContractParams({ + target: address(helloWorld), data: data, value: value, gas: 20_000 + }); bytes memory payload = abi.encode(params); CommandV2[] memory commands = new CommandV2[](1); @@ -319,14 +321,18 @@ contract GatewayV2Test is Test { view returns (CommandV2[] memory) { - bytes memory data = abi.encodeWithSignature("sayHello(string)", "World"); - CallContractParams memory params = - CallContractParams({target: address(helloWorld), data: data, value: value}); + // Call expensiveOperation which requires storage writes and thus significant gas + bytes memory data = abi.encodeWithSignature("expensiveOperation()"); + uint64 callGas = 5000; // insufficient gas for storage write (typical cost: ~20000) + CallContractParams memory params = CallContractParams({ + target: address(helloWorld), data: data, value: value, gas: callGas + }); bytes memory payload = abi.encode(params); CommandV2[] memory commands = new CommandV2[](1); - commands[0] = - CommandV2({kind: CommandKind.CallContract, gas: 1, atomic: false, payload: payload}); + commands[0] = CommandV2({ + kind: CommandKind.CallContract, gas: callGas, atomic: false, payload: payload + }); return commands; } @@ -339,8 +345,12 @@ contract GatewayV2Test is Test { bytes memory data2 = abi.encodeWithSignature("sayHello(string)", "Snowbridge"); CallContractParams[] memory params = new CallContractParams[](2); - params[0] = CallContractParams({target: address(helloWorld), data: data1, value: value1}); - params[1] = CallContractParams({target: address(helloWorld), data: data2, value: value2}); + params[0] = CallContractParams({ + target: address(helloWorld), data: data1, value: value1, gas: 20_000 + }); + params[1] = CallContractParams({ + target: address(helloWorld), data: data2, value: value2, gas: 20_000 + }); bytes memory payload = abi.encode(params); @@ -431,9 +441,8 @@ contract GatewayV2Test is Test { vm.prank(relayer); vm.expectRevert(IGatewayV2.InsufficientGasLimit.selector); - IGatewayV2(address(gateway)).v2_submit{gas: gasLimit}( - message, proof, makeMockProof(), relayerRewardAddress - ); + IGatewayV2(address(gateway)) + .v2_submit{gas: gasLimit}(message, proof, makeMockProof(), relayerRewardAddress); } function mockNativeTokenForSend(address user, uint128 amount) @@ -504,9 +513,8 @@ contract GatewayV2Test is Test { ); hoax(user1); - IGatewayV2(payable(address(gateway))).v2_sendMessage{value: 1 ether}( - "", assets, "", 0.1 ether, 0.4 ether - ); + IGatewayV2(payable(address(gateway))) + .v2_sendMessage{value: 1 ether}("", assets, "", 0.1 ether, 0.4 ether); // Verify asset balances assertEq(assetHubAgent.balance, 1 ether); @@ -531,9 +539,8 @@ contract GatewayV2Test is Test { vm.expectRevert(); hoax(user1); - IGatewayV2(payable(address(gateway))).v2_sendMessage{value: 1 ether}( - "", assets, "", 0.1 ether, 0.4 ether - ); + IGatewayV2(payable(address(gateway))) + .v2_sendMessage{value: 1 ether}("", assets, "", 0.1 ether, 0.4 ether); assertEq(feeToken.balanceOf(assetHubAgent), 0); } @@ -541,18 +548,16 @@ contract GatewayV2Test is Test { function testSendMessageFailsWithInsufficentValue() public { vm.expectRevert(IGatewayV2.InsufficientValue.selector); hoax(user1, 1 ether); - IGatewayV2(payable(address(gateway))).v2_sendMessage{value: 0.4 ether}( - "", new bytes[](0), "", 0.1 ether, 0.4 ether - ); + IGatewayV2(payable(address(gateway))) + .v2_sendMessage{value: 0.4 ether}("", new bytes[](0), "", 0.1 ether, 0.4 ether); } function testSendMessageFailsWithExceededMaximumValue() public { vm.expectRevert(IGatewayV2.ExceededMaximumValue.selector); uint256 value = uint256(type(uint128).max) + 1; hoax(user1, value); - IGatewayV2(payable(address(gateway))).v2_sendMessage{value: value}( - "", new bytes[](0), "", 0.1 ether, 0.4 ether - ); + IGatewayV2(payable(address(gateway))) + .v2_sendMessage{value: value}("", new bytes[](0), "", 0.1 ether, 0.4 ether); } function testUnlockWethSuccess() public { @@ -718,9 +723,10 @@ contract GatewayV2Test is Test { uint256 totalRequired = executionFee + relayerFee; hoax(user1, totalRequired); - IGatewayV2(payable(address(gateway))).v2_registerToken{value: totalRequired}( - validTokenContract, uint8(0), executionFee, relayerFee - ); + IGatewayV2(payable(address(gateway))) + .v2_registerToken{ + value: totalRequired + }(validTokenContract, uint8(0), executionFee, relayerFee); // Verify the token is registered assertTrue(IGatewayV2(address(gateway)).isTokenRegistered(validTokenContract)); @@ -737,9 +743,10 @@ contract GatewayV2Test is Test { vm.expectRevert(IGatewayV2.InsufficientValue.selector); hoax(user1, totalRequired); - IGatewayV2(payable(address(gateway))).v2_registerToken{value: totalRequired - 1}( - validTokenContract, uint8(0), executionFee, relayerFee - ); + IGatewayV2(payable(address(gateway))) + .v2_registerToken{ + value: totalRequired - 1 + }(validTokenContract, uint8(0), executionFee, relayerFee); // Verify token still is not registered after the failed attempt assertFalse(IGatewayV2(address(gateway)).isTokenRegistered(validTokenContract)); @@ -756,9 +763,8 @@ contract GatewayV2Test is Test { vm.expectRevert(IGatewayV2.ExceededMaximumValue.selector); uint256 value = uint256(type(uint128).max) + 1; hoax(user1, value); - IGatewayV2(payable(address(gateway))).v2_registerToken{value: value}( - validTokenContract, uint8(0), executionFee, relayerFee - ); + IGatewayV2(payable(address(gateway))) + .v2_registerToken{value: value}(validTokenContract, uint8(0), executionFee, relayerFee); // Verify token still is not registered after the failed attempt assertFalse(IGatewayV2(address(gateway)).isTokenRegistered(validTokenContract)); @@ -782,8 +788,9 @@ contract GatewayV2Test is Test { // Second command should fail - Call a function that reverts bytes memory failingData = abi.encodeWithSignature("revertUnauthorized()"); - CallContractParams memory params2 = - CallContractParams({target: address(helloWorld), data: failingData, value: 0}); + CallContractParams memory params2 = CallContractParams({ + target: address(helloWorld), data: failingData, value: 0, gas: 20_000 + }); commands[1] = CommandV2({ kind: CommandKind.CallContract, gas: 500_000, @@ -1021,8 +1028,9 @@ contract GatewayV2Test is Test { } function testCallContractAgentDoesNotExistReturnsFalse() public { - CallContractParams memory p = - CallContractParams({target: address(0xdead), data: "", value: uint256(0)}); + CallContractParams memory p = CallContractParams({ + target: address(0xdead), data: "", value: uint256(0), gas: 20_000 + }); bytes memory payload = abi.encode(p); // origin corresponds to agent id; use a non-existent id @@ -1120,7 +1128,9 @@ contract GatewayV2Test is Test { vm.deal(assetHubAgent, 1 ether); hoax(relayer, 1 ether); - emit IGatewayV2.InboundMessageDispatched(1, topic, true, relayerRewardAddress); + vm.expectEmit(true, false, false, true); + emit IGatewayV2.CommandFailed(1, 0); + IGatewayV2(address(gateway)) .v2_submit( InboundMessageV2({ @@ -1141,9 +1151,8 @@ contract GatewayV2Test is Test { vm.deal(assetHubAgent, 1 ether); hoax(relayer, 1 ether); - // After Option A implementation, InsufficientGasLimit is now propagated - // and causes the entire v2_submit to revert - vm.expectRevert(IGatewayV2.InsufficientGasLimit.selector); + vm.expectEmit(true, false, false, true); + emit IGatewayV2.CommandFailed(1, 0); IGatewayV2(address(gateway)) .v2_submit( @@ -1238,7 +1247,7 @@ contract GatewayV2Test is Test { }); CallContractParams memory cc = - CallContractParams({target: address(0x1234), data: "", value: 0}); + CallContractParams({target: address(0x1234), data: "", value: 0, gas: 20_000}); cmds[1] = CommandV2({ kind: CommandKind.CallContract, gas: 200_000, atomic: false, payload: abi.encode(cc) }); @@ -1248,45 +1257,18 @@ contract GatewayV2Test is Test { msgv.topic = bytes32(0); msgv.commands = cmds; - // call v2_submit (verification overridden to true) - - // construct an empty Verification.Proof - Verification.DigestItem[] memory digestItems = new Verification.DigestItem[](0); - Verification.ParachainHeader memory header = Verification.ParachainHeader({ - parentHash: bytes32(0), - number: 0, - stateRoot: bytes32(0), - extrinsicsRoot: bytes32(0), - digestItems: digestItems - }); - - bytes32[] memory emptyBytes32 = new bytes32[](0); - Verification.HeadProof memory hp = - Verification.HeadProof({pos: 0, width: 0, proof: emptyBytes32}); - Verification.MMRLeafPartial memory lp = Verification.MMRLeafPartial({ - version: 0, - parentNumber: 0, - parentHash: bytes32(0), - nextAuthoritySetID: 0, - nextAuthoritySetLen: 0, - nextAuthoritySetRoot: bytes32(0) - }); - - Verification.Proof memory headerProof = Verification.Proof({ - header: header, - headProof: hp, - leafPartial: lp, - leafProof: emptyBytes32, - leafProofOrder: 0 - }); + // Expect CommandFailed for the second command (index 1) + vm.expectEmit(true, false, false, true); + emit IGatewayV2.CommandFailed(msgv.nonce, 1); - gw.v2_submit(msgv, proof, headerProof, bytes32(0)); + // call v2_submit (verification overridden to true) + gw.v2_submit(msgv, proof, makeMockProof(), bytes32(0)); // message should be recorded as dispatched assertTrue(gw.v2_isDispatched(msgv.nonce)); } - function testUnlockTokenThenCallContracts() public { + function testUnlockTokenThenCallContractsWillSucceed() public { bytes32 topic = keccak256("topic"); // Set up an ERC20 token (WETH) for the agent to work with @@ -1318,20 +1300,22 @@ contract GatewayV2Test is Test { abi.encodeWithSignature("consumeToken(address,uint256)", token, tokenAmount); CallContractParams[] memory callParams = new CallContractParams[](2); - callParams[0] = CallContractParams({target: token, data: approveData, value: 0}); - callParams[1] = - CallContractParams({target: address(helloWorld), data: consumeData, value: 0}); + callParams[0] = + CallContractParams({target: token, data: approveData, value: 0, gas: 100_000}); + callParams[1] = CallContractParams({ + target: address(helloWorld), data: consumeData, value: 0, gas: 100_000 + }); CommandV2[] memory commands = new CommandV2[](2); commands[0] = CommandV2({ kind: CommandKind.UnlockNativeToken, - gas: 500_000, + gas: 100_000, atomic: false, payload: abi.encode(unlockParams) }); commands[1] = CommandV2({ kind: CommandKind.CallContracts, - gas: 500_000, + gas: 200_000, atomic: false, payload: abi.encode(callParams) }); @@ -1401,22 +1385,24 @@ contract GatewayV2Test is Test { bytes memory revertData = abi.encodeWithSignature("revertUnauthorized()"); CallContractParams[] memory callParams = new CallContractParams[](3); - callParams[0] = CallContractParams({target: token, data: approveData, value: 0}); - callParams[1] = - CallContractParams({target: address(helloWorld), data: consumeData, value: 0}); - callParams[2] = - CallContractParams({target: address(helloWorld), data: revertData, value: 0}); - + callParams[0] = + CallContractParams({target: token, data: approveData, value: 0, gas: 50_000}); + callParams[1] = CallContractParams({ + target: address(helloWorld), data: consumeData, value: 0, gas: 50_000 + }); + callParams[2] = CallContractParams({ + target: address(helloWorld), data: revertData, value: 0, gas: 50_000 + }); CommandV2[] memory commands = new CommandV2[](2); commands[0] = CommandV2({ kind: CommandKind.UnlockNativeToken, - gas: 500_000, + gas: 100_000, atomic: false, payload: abi.encode(unlockParams) }); commands[1] = CommandV2({ kind: CommandKind.CallContracts, - gas: 500_000, + gas: 200_000, atomic: false, payload: abi.encode(callParams) }); diff --git a/contracts/test/mocks/HelloWorld.sol b/contracts/test/mocks/HelloWorld.sol index ec8969a86..d86d13e2e 100644 --- a/contracts/test/mocks/HelloWorld.sol +++ b/contracts/test/mocks/HelloWorld.sol @@ -9,18 +9,25 @@ contract HelloWorld { error Unauthorized(); + uint256 private counter; // storage variable for expensive operations + function sayHello(string memory _text) public payable { string memory fullMessage = string(abi.encodePacked("Hello there, ", _text)); emit SaidHello(fullMessage); } + // Function that requires significant gas due to storage operations + function expensiveOperation() public { + counter += 1; + } + function revertUnauthorized() public pure { revert Unauthorized(); } function retBomb() public pure returns (bytes memory) { assembly { - return(1, 3000000) + return(1, 0x2dc6c0) } } @@ -34,3 +41,4 @@ contract HelloWorld { emit TokenConsumed(token, msg.sender, amount); } } + From d7a315a4007c95def155ea2c5ba1eb76f7f15886 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 27 Jan 2026 13:19:13 +0800 Subject: [PATCH 14/28] Optimize gas usage for atomic command failures by skipping event emission (#1690) * Initial plan * Fix: Check atomic flag before emitting CommandFailed event Co-authored-by: yrong <4383920+yrong@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: yrong <4383920+yrong@users.noreply.github.com> --- contracts/src/Gateway.sol | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/contracts/src/Gateway.sol b/contracts/src/Gateway.sol index f1447b598..d66456768 100644 --- a/contracts/src/Gateway.sol +++ b/contracts/src/Gateway.sol @@ -560,8 +560,9 @@ contract Gateway is IGatewayBase, IGatewayV1, IGatewayV2, IInitializable, IUpgra } // Dispatch all the commands within the batch of commands in the message payload. Each command is processed - // independently, such that failures emit a `CommandFailed` event without stopping execution of subsequent commands. - // Returns true if all commands executed successfully, false if any non-atomic command failed. + // independently, such that non-atomic failures emit a `CommandFailed` event without stopping execution of + // subsequent commands, while atomic failures revert the entire transaction. Returns true if all commands + // executed successfully, false if any non-atomic command failed. function v2_dispatch(InboundMessageV2 calldata message) external onlySelf returns (bool) { bool success = true; for (uint256 i = 0; i < message.commands.length; i++) { @@ -575,10 +576,10 @@ contract Gateway is IGatewayBase, IGatewayV1, IGatewayV2, IInitializable, IUpgra ) { revert IGatewayV2.InsufficientGasLimit(); } - emit IGatewayV2.CommandFailed(message.nonce, i); if (command.atomic) { revert IGatewayV2.AtomicCommandFailed(); } + emit IGatewayV2.CommandFailed(message.nonce, i); success = false; } } From 22033e85857f31f47d8d6e0cb6969afd21ca2d6a Mon Sep 17 00:00:00 2001 From: ron Date: Tue, 27 Jan 2026 14:19:10 +0800 Subject: [PATCH 15/28] More tests --- contracts/test/GatewayV2.t.sol | 68 +++++++++++++++++++++++++++++----- 1 file changed, 58 insertions(+), 10 deletions(-) diff --git a/contracts/test/GatewayV2.t.sol b/contracts/test/GatewayV2.t.sol index 03e9e8f71..27f44f808 100644 --- a/contracts/test/GatewayV2.t.sol +++ b/contracts/test/GatewayV2.t.sol @@ -419,30 +419,42 @@ contract GatewayV2Test is Test { bytes32 topic = keccak256("topic"); // Create a command with very high gas requirement - CommandV2[] memory commands = new CommandV2[](1); - SetOperatingModeParams memory params = SetOperatingModeParams({mode: OperatingMode.Normal}); + CommandV2[] memory commands = new CommandV2[](2); + SetOperatingModeParams memory params = + SetOperatingModeParams({mode: OperatingMode.RejectingOutboundMessages}); commands[0] = CommandV2({ kind: CommandKind.SetOperatingMode, - gas: 30_000_000, // Extremely high gas value + gas: 80_000, atomic: false, payload: abi.encode(params) }); + bytes memory data = abi.encodeWithSignature("sayHello(string)", "World"); + CallContractParams[] memory callParams = new CallContractParams[](1); + callParams[0] = + CallContractParams({target: address(helloWorld), data: data, value: 0, gas: 20_000}); + commands[1] = CommandV2({ + kind: CommandKind.CallContracts, + gas: 80_000, + atomic: false, + payload: abi.encode(callParams) + }); + InboundMessageV2 memory message = InboundMessageV2({ - origin: keccak256("666"), - nonce: 2, // Use a different nonce from other tests - topic: topic, - commands: commands + origin: Constants.ASSET_HUB_AGENT_ID, nonce: 1, topic: topic, commands: commands }); - // Limit the gas for this test to ensure we hit the NotEnoughGas error - uint256 gasLimit = 100_000; + // Limit the gas for this test to ensure we hit the InsufficientGasLimit error + uint256 gasLimit = 180_000; vm.deal(relayer, 1 ether); vm.prank(relayer); vm.expectRevert(IGatewayV2.InsufficientGasLimit.selector); IGatewayV2(address(gateway)) .v2_submit{gas: gasLimit}(message, proof, makeMockProof(), relayerRewardAddress); + + OperatingMode mode = IGatewayV2(address(gateway)).operatingMode(); + assertEq(uint256(mode), uint256(OperatingMode.Normal)); } function mockNativeTokenForSend(address user, uint128 amount) @@ -1053,7 +1065,7 @@ contract GatewayV2Test is Test { payload: payload }); - vm.expectRevert(); + vm.expectRevert(IGatewayV2.InsufficientGasLimit.selector); gatewayLogic.callDispatch(cmd, bytes32(0)); } @@ -1268,6 +1280,42 @@ contract GatewayV2Test is Test { assertTrue(gw.v2_isDispatched(msgv.nonce)); } + function test_v2_dispatch_will_revert_when_atomic_command_fails() public { + MockGateway gw = MockGateway(address(gateway)); + OperatingMode mode = gw.operatingMode(); + assertEq(uint256(mode), uint256(OperatingMode.Normal)); + // Build two commands: SetOperatingMode (should succeed) and CallContract (will fail due to missing agent) + CommandV2[] memory cmds = new CommandV2[](2); + SetOperatingModeParams memory p = + SetOperatingModeParams({mode: OperatingMode.RejectingOutboundMessages}); + cmds[0] = CommandV2({ + kind: CommandKind.SetOperatingMode, gas: 200_000, atomic: false, payload: abi.encode(p) + }); + + CallContractParams memory cc = + CallContractParams({target: address(0x1234), data: "", value: 0, gas: 200_000}); + cmds[1] = CommandV2({ + kind: CommandKind.CallContract, gas: 200_000, atomic: true, payload: abi.encode(cc) + }); + InboundMessageV2 memory msgv; + msgv.origin = bytes32("orig"); + msgv.nonce = 1; + msgv.topic = bytes32(0); + msgv.commands = cmds; + + // Expect AtomicCommandFailed for the second command (index 1) + vm.expectRevert(IGatewayV2.AtomicCommandFailed.selector); + // call v2_submit (verification overridden to true) + gw.v2_submit(msgv, proof, makeMockProof(), bytes32(0)); + + // message should not be recorded as dispatched + assertFalse(gw.v2_isDispatched(msgv.nonce)); + + // operating mode should remain unchanged + mode = gw.operatingMode(); + assertEq(uint256(mode), uint256(OperatingMode.Normal)); + } + function testUnlockTokenThenCallContractsWillSucceed() public { bytes32 topic = keccak256("topic"); From 1566ffbd7e0fde5e1adef3acd895dbfc4c9615ec Mon Sep 17 00:00:00 2001 From: ron Date: Tue, 27 Jan 2026 14:24:14 +0800 Subject: [PATCH 16/28] Improve test --- contracts/test/GatewayV2.t.sol | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/contracts/test/GatewayV2.t.sol b/contracts/test/GatewayV2.t.sol index 27f44f808..29dee510d 100644 --- a/contracts/test/GatewayV2.t.sol +++ b/contracts/test/GatewayV2.t.sol @@ -1227,8 +1227,28 @@ contract GatewayV2Test is Test { function test_onlySelf_enforced_on_external_calls() public { MockGateway gw = MockGateway(address(gateway)); - // Since v2_handleSetOperatingMode is now internal, we can't call it externally - // This test is no longer applicable + // Try to call a protected handler function directly (not via internal dispatch) + // This should fail because msg.sender != address(this) + SetOperatingModeParams memory p = + SetOperatingModeParams({mode: OperatingMode.RejectingOutboundMessages}); + bytes memory payload = abi.encode(p); + + // Attempt to call v1_handleSetOperatingMode directly from external context + // Should revert with Unauthorized since onlySelf modifier requires msg.sender == address(this) + vm.expectRevert(IGatewayBase.Unauthorized.selector); + gw.v1_handleSetOperatingMode(payload); + + // Try another onlySelf protected function - v2_dispatchCommand + CommandV2 memory cmd = CommandV2({ + kind: CommandKind.SetOperatingMode, gas: 100_000, atomic: false, payload: payload + }); + + vm.expectRevert(IGatewayBase.Unauthorized.selector); + gw.v2_dispatchCommand(cmd, bytes32(0)); + + // Verify mode was not changed (stayed Normal) + OperatingMode mode = gw.operatingMode(); + assertEq(uint256(mode), uint256(OperatingMode.Normal)); } function test_call_handleSetOperatingMode_via_self_changes_mode() public { From f0941372231d07a83b1e24cd6ef31b96cda17d37 Mon Sep 17 00:00:00 2001 From: ron Date: Tue, 27 Jan 2026 14:45:38 +0800 Subject: [PATCH 17/28] Improve AtomicCommandFailed error --- contracts/src/Gateway.sol | 2 +- contracts/src/v2/IGateway.sol | 2 +- contracts/test/GatewayV2.t.sol | 8 ++++++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/contracts/src/Gateway.sol b/contracts/src/Gateway.sol index d66456768..0c61e592c 100644 --- a/contracts/src/Gateway.sol +++ b/contracts/src/Gateway.sol @@ -577,7 +577,7 @@ contract Gateway is IGatewayBase, IGatewayV1, IGatewayV2, IInitializable, IUpgra revert IGatewayV2.InsufficientGasLimit(); } if (command.atomic) { - revert IGatewayV2.AtomicCommandFailed(); + revert IGatewayV2.AtomicCommandFailed(message.nonce, i); } emit IGatewayV2.CommandFailed(message.nonce, i); success = false; diff --git a/contracts/src/v2/IGateway.sol b/contracts/src/v2/IGateway.sol index 5165778de..9ec786c23 100644 --- a/contracts/src/v2/IGateway.sol +++ b/contracts/src/v2/IGateway.sol @@ -11,7 +11,7 @@ interface IGatewayV2 { error InvalidNetwork(); error InvalidAsset(); error InsufficientGasLimit(); - error AtomicCommandFailed(); + error AtomicCommandFailed(uint64 nonce, uint256 index); error InvalidCommand(); error InsufficientValue(); error ExceededMaximumValue(); diff --git a/contracts/test/GatewayV2.t.sol b/contracts/test/GatewayV2.t.sol index 29dee510d..8d801a9e1 100644 --- a/contracts/test/GatewayV2.t.sol +++ b/contracts/test/GatewayV2.t.sol @@ -1323,8 +1323,12 @@ contract GatewayV2Test is Test { msgv.topic = bytes32(0); msgv.commands = cmds; - // Expect AtomicCommandFailed for the second command (index 1) - vm.expectRevert(IGatewayV2.AtomicCommandFailed.selector); + // Expect AtomicCommandFailed for the second command (index 1) with nonce and index + vm.expectRevert( + abi.encodeWithSelector( + IGatewayV2.AtomicCommandFailed.selector, uint64(msgv.nonce), uint256(1) + ) + ); // call v2_submit (verification overridden to true) gw.v2_submit(msgv, proof, makeMockProof(), bytes32(0)); From 3c99e2a519c7890acfd1a98c2624cdf16328d2ce Mon Sep 17 00:00:00 2001 From: ron Date: Tue, 27 Jan 2026 17:33:35 +0800 Subject: [PATCH 18/28] Remove the atomic control in Comand --- contracts/src/Gateway.sol | 16 ++--- contracts/src/v2/IGateway.sol | 1 - contracts/src/v2/Types.sol | 1 - contracts/test/GatewayV2.t.sol | 126 +++++++-------------------------- 4 files changed, 29 insertions(+), 115 deletions(-) diff --git a/contracts/src/Gateway.sol b/contracts/src/Gateway.sol index 0c61e592c..49d7db3d1 100644 --- a/contracts/src/Gateway.sol +++ b/contracts/src/Gateway.sol @@ -436,13 +436,9 @@ contract Gateway is IGatewayBase, IGatewayV1, IGatewayV2, IInitializable, IUpgra try Gateway(this).v2_dispatch(message) returns (bool success) { dispatchSuccess = success; } catch (bytes memory reason) { - // If an atomic command failed or insufficient gas limit, rethrow the error to stop processing + // If insufficient gas limit, rethrow the error to stop processing // Otherwise, silently ignore command failures - if ( - reason.length >= 4 - && (bytes4(reason) == IGatewayV2.AtomicCommandFailed.selector - || bytes4(reason) == IGatewayV2.InsufficientGasLimit.selector) - ) { + if (reason.length >= 4 && bytes4(reason) == IGatewayV2.InsufficientGasLimit.selector) { assembly { revert(add(reason, 32), mload(reason)) } @@ -560,9 +556,8 @@ contract Gateway is IGatewayBase, IGatewayV1, IGatewayV2, IInitializable, IUpgra } // Dispatch all the commands within the batch of commands in the message payload. Each command is processed - // independently, such that non-atomic failures emit a `CommandFailed` event without stopping execution of - // subsequent commands, while atomic failures revert the entire transaction. Returns true if all commands - // executed successfully, false if any non-atomic command failed. + // independently, such that failures emit a `CommandFailed` event without stopping execution of + // subsequent commands. Returns true if all commands executed successfully, false if any command failed. function v2_dispatch(InboundMessageV2 calldata message) external onlySelf returns (bool) { bool success = true; for (uint256 i = 0; i < message.commands.length; i++) { @@ -576,9 +571,6 @@ contract Gateway is IGatewayBase, IGatewayV1, IGatewayV2, IInitializable, IUpgra ) { revert IGatewayV2.InsufficientGasLimit(); } - if (command.atomic) { - revert IGatewayV2.AtomicCommandFailed(message.nonce, i); - } emit IGatewayV2.CommandFailed(message.nonce, i); success = false; } diff --git a/contracts/src/v2/IGateway.sol b/contracts/src/v2/IGateway.sol index 9ec786c23..829ed3f13 100644 --- a/contracts/src/v2/IGateway.sol +++ b/contracts/src/v2/IGateway.sol @@ -11,7 +11,6 @@ interface IGatewayV2 { error InvalidNetwork(); error InvalidAsset(); error InsufficientGasLimit(); - error AtomicCommandFailed(uint64 nonce, uint256 index); error InvalidCommand(); error InsufficientValue(); error ExceededMaximumValue(); diff --git a/contracts/src/v2/Types.sol b/contracts/src/v2/Types.sol index d91c24828..771ce740f 100644 --- a/contracts/src/v2/Types.sol +++ b/contracts/src/v2/Types.sol @@ -19,7 +19,6 @@ struct InboundMessage { struct Command { uint8 kind; uint64 gas; - bool atomic; bytes payload; } diff --git a/contracts/test/GatewayV2.t.sol b/contracts/test/GatewayV2.t.sol index 8d801a9e1..21f7559a9 100644 --- a/contracts/test/GatewayV2.t.sol +++ b/contracts/test/GatewayV2.t.sol @@ -226,7 +226,6 @@ contract GatewayV2Test is Test { commands[0] = CommandV2({ kind: CommandKind.SetOperatingMode, gas: 500_000, - atomic: false, payload: abi.encode(params) }); return commands; @@ -246,9 +245,8 @@ contract GatewayV2Test is Test { bytes memory payload = abi.encode(params); CommandV2[] memory commands = new CommandV2[](1); - commands[0] = CommandV2({ - kind: CommandKind.UnlockNativeToken, gas: 500_000, atomic: false, payload: payload - }); + commands[0] = + CommandV2({kind: CommandKind.UnlockNativeToken, gas: 500_000, payload: payload}); return commands; } @@ -263,9 +261,8 @@ contract GatewayV2Test is Test { bytes memory payload = abi.encode(params); CommandV2[] memory commands = new CommandV2[](1); - commands[0] = CommandV2({ - kind: CommandKind.RegisterForeignToken, gas: 1_200_000, atomic: false, payload: payload - }); + commands[0] = + CommandV2({kind: CommandKind.RegisterForeignToken, gas: 1_200_000, payload: payload}); return commands; } @@ -278,9 +275,8 @@ contract GatewayV2Test is Test { bytes memory payload = abi.encode(params); CommandV2[] memory commands = new CommandV2[](1); - commands[0] = CommandV2({ - kind: CommandKind.MintForeignToken, gas: 100_000, atomic: false, payload: payload - }); + commands[0] = + CommandV2({kind: CommandKind.MintForeignToken, gas: 100_000, payload: payload}); return commands; } @@ -292,9 +288,7 @@ contract GatewayV2Test is Test { bytes memory payload = abi.encode(params); CommandV2[] memory commands = new CommandV2[](1); - commands[0] = CommandV2({ - kind: CommandKind.CallContract, gas: 500_000, atomic: false, payload: payload - }); + commands[0] = CommandV2({kind: CommandKind.CallContract, gas: 500_000, payload: payload}); return commands; } @@ -310,9 +304,7 @@ contract GatewayV2Test is Test { bytes memory payload = abi.encode(params); CommandV2[] memory commands = new CommandV2[](1); - commands[0] = CommandV2({ - kind: CommandKind.CallContract, gas: 500_000, atomic: false, payload: payload - }); + commands[0] = CommandV2({kind: CommandKind.CallContract, gas: 500_000, payload: payload}); return commands; } @@ -330,9 +322,7 @@ contract GatewayV2Test is Test { bytes memory payload = abi.encode(params); CommandV2[] memory commands = new CommandV2[](1); - commands[0] = CommandV2({ - kind: CommandKind.CallContract, gas: callGas, atomic: false, payload: payload - }); + commands[0] = CommandV2({kind: CommandKind.CallContract, gas: callGas, payload: payload}); return commands; } @@ -355,9 +345,7 @@ contract GatewayV2Test is Test { bytes memory payload = abi.encode(params); CommandV2[] memory commands = new CommandV2[](1); - commands[0] = CommandV2({ - kind: CommandKind.CallContracts, gas: 500_000, atomic: false, payload: payload - }); + commands[0] = CommandV2({kind: CommandKind.CallContracts, gas: 500_000, payload: payload}); return commands; } @@ -425,7 +413,6 @@ contract GatewayV2Test is Test { commands[0] = CommandV2({ kind: CommandKind.SetOperatingMode, gas: 80_000, - atomic: false, payload: abi.encode(params) }); @@ -436,7 +423,6 @@ contract GatewayV2Test is Test { commands[1] = CommandV2({ kind: CommandKind.CallContracts, gas: 80_000, - atomic: false, payload: abi.encode(callParams) }); @@ -794,7 +780,6 @@ contract GatewayV2Test is Test { commands[0] = CommandV2({ kind: CommandKind.SetOperatingMode, gas: 500_000, - atomic: false, payload: abi.encode(params1) }); @@ -806,7 +791,6 @@ contract GatewayV2Test is Test { commands[1] = CommandV2({ kind: CommandKind.CallContract, gas: 500_000, - atomic: false, payload: abi.encode(params2) }); @@ -816,7 +800,6 @@ contract GatewayV2Test is Test { commands[2] = CommandV2({ kind: CommandKind.SetOperatingMode, gas: 500_000, - atomic: false, payload: abi.encode(params3) }); @@ -852,7 +835,6 @@ contract GatewayV2Test is Test { commands[0] = CommandV2({ kind: CommandKind.SetOperatingMode, gas: 500_000, - atomic: false, payload: abi.encode(params1) }); @@ -860,7 +842,6 @@ contract GatewayV2Test is Test { commands[1] = CommandV2({ kind: 255, // Invalid command kind gas: 500_000, - atomic: false, payload: abi.encode(bytes32(0)) }); @@ -896,7 +877,6 @@ contract GatewayV2Test is Test { commands[0] = CommandV2({ kind: CommandKind.SetOperatingMode, gas: 500_000, - atomic: false, payload: abi.encode(params1) }); @@ -906,7 +886,6 @@ contract GatewayV2Test is Test { commands[1] = CommandV2({ kind: CommandKind.SetOperatingMode, gas: 500_000, - atomic: false, payload: abi.encode(params2) }); @@ -916,7 +895,6 @@ contract GatewayV2Test is Test { commands[2] = CommandV2({ kind: CommandKind.SetOperatingMode, gas: 500_000, - atomic: false, payload: abi.encode(params3) }); @@ -939,7 +917,7 @@ contract GatewayV2Test is Test { function testUnknownCommandReturnsFalse() public { bytes memory payload = ""; CommandV2 memory cmd = - CommandV2({kind: uint8(200), gas: uint64(100_000), payload: payload, atomic: false}); + CommandV2({kind: uint8(200), gas: uint64(100_000), payload: payload}); // unknown command should revert with InvalidCommand vm.expectRevert(IGatewayV2.InvalidCommand.selector); @@ -951,7 +929,6 @@ contract GatewayV2Test is Test { CommandV2 memory cmd = CommandV2({ kind: CommandKind.SetOperatingMode, gas: uint64(200_000), - atomic: false, payload: payload }); @@ -975,8 +952,7 @@ contract GatewayV2Test is Test { CommandV2 memory cmd = CommandV2({ kind: CommandKind.UnlockNativeToken, gas: uint64(200_000), - payload: payload, - atomic: false + payload: payload }); // handler revert should be caught and command should fail (revert) @@ -990,9 +966,8 @@ contract GatewayV2Test is Test { UpgradeParams({impl: address(0), implCodeHash: bytes32(0), initParams: ""}); bytes memory payload = abi.encode(up); - CommandV2 memory cmd = CommandV2({ - kind: CommandKind.Upgrade, gas: uint64(200_000), payload: payload, atomic: false - }); + CommandV2 memory cmd = + CommandV2({kind: CommandKind.Upgrade, gas: uint64(200_000), payload: payload}); // upgrade with invalid impl should revert vm.expectRevert(); @@ -1008,8 +983,7 @@ contract GatewayV2Test is Test { CommandV2 memory cmd = CommandV2({ kind: CommandKind.MintForeignToken, gas: uint64(200_000), - payload: payload, - atomic: false + payload: payload }); // mintForeignToken for unregistered token should revert @@ -1027,8 +1001,7 @@ contract GatewayV2Test is Test { CommandV2 memory cmd = CommandV2({ kind: CommandKind.RegisterForeignToken, gas: uint64(3_000_000), - payload: payload, - atomic: false + payload: payload }); // first register should succeed @@ -1046,9 +1019,8 @@ contract GatewayV2Test is Test { bytes memory payload = abi.encode(p); // origin corresponds to agent id; use a non-existent id - CommandV2 memory cmd = CommandV2({ - kind: CommandKind.CallContract, gas: uint64(200_000), atomic: false, payload: payload - }); + CommandV2 memory cmd = + CommandV2({kind: CommandKind.CallContract, gas: uint64(200_000), payload: payload}); // callContract with missing agent should revert vm.expectRevert(); @@ -1061,7 +1033,6 @@ contract GatewayV2Test is Test { CommandV2 memory cmd = CommandV2({ kind: CommandKind.SetOperatingMode, gas: type(uint64).max, - atomic: false, payload: payload }); @@ -1239,9 +1210,8 @@ contract GatewayV2Test is Test { gw.v1_handleSetOperatingMode(payload); // Try another onlySelf protected function - v2_dispatchCommand - CommandV2 memory cmd = CommandV2({ - kind: CommandKind.SetOperatingMode, gas: 100_000, atomic: false, payload: payload - }); + CommandV2 memory cmd = + CommandV2({kind: CommandKind.SetOperatingMode, gas: 100_000, payload: payload}); vm.expectRevert(IGatewayBase.Unauthorized.selector); gw.v2_dispatchCommand(cmd, bytes32(0)); @@ -1264,7 +1234,7 @@ contract GatewayV2Test is Test { function test_dispatch_unknown_command_returns_false() public { MockGateway gw = MockGateway(address(gateway)); - CommandV2 memory cmd = CommandV2({kind: 0xFF, gas: 100_000, atomic: false, payload: ""}); + CommandV2 memory cmd = CommandV2({kind: 0xFF, gas: 100_000, payload: ""}); vm.expectRevert(IGatewayV2.InvalidCommand.selector); gw.exposed_dispatchCommand(cmd, bytes32(0)); } @@ -1274,15 +1244,13 @@ contract GatewayV2Test is Test { // Build two commands: SetOperatingMode (should succeed) and CallContract (will fail due to missing agent) CommandV2[] memory cmds = new CommandV2[](2); SetOperatingModeParams memory p = SetOperatingModeParams({mode: OperatingMode.Normal}); - cmds[0] = CommandV2({ - kind: CommandKind.SetOperatingMode, gas: 200_000, atomic: false, payload: abi.encode(p) - }); + cmds[0] = + CommandV2({kind: CommandKind.SetOperatingMode, gas: 200_000, payload: abi.encode(p)}); CallContractParams memory cc = CallContractParams({target: address(0x1234), data: "", value: 0, gas: 20_000}); - cmds[1] = CommandV2({ - kind: CommandKind.CallContract, gas: 200_000, atomic: false, payload: abi.encode(cc) - }); + cmds[1] = + CommandV2({kind: CommandKind.CallContract, gas: 200_000, payload: abi.encode(cc)}); InboundMessageV2 memory msgv; msgv.origin = bytes32("orig"); msgv.nonce = 1; @@ -1300,46 +1268,6 @@ contract GatewayV2Test is Test { assertTrue(gw.v2_isDispatched(msgv.nonce)); } - function test_v2_dispatch_will_revert_when_atomic_command_fails() public { - MockGateway gw = MockGateway(address(gateway)); - OperatingMode mode = gw.operatingMode(); - assertEq(uint256(mode), uint256(OperatingMode.Normal)); - // Build two commands: SetOperatingMode (should succeed) and CallContract (will fail due to missing agent) - CommandV2[] memory cmds = new CommandV2[](2); - SetOperatingModeParams memory p = - SetOperatingModeParams({mode: OperatingMode.RejectingOutboundMessages}); - cmds[0] = CommandV2({ - kind: CommandKind.SetOperatingMode, gas: 200_000, atomic: false, payload: abi.encode(p) - }); - - CallContractParams memory cc = - CallContractParams({target: address(0x1234), data: "", value: 0, gas: 200_000}); - cmds[1] = CommandV2({ - kind: CommandKind.CallContract, gas: 200_000, atomic: true, payload: abi.encode(cc) - }); - InboundMessageV2 memory msgv; - msgv.origin = bytes32("orig"); - msgv.nonce = 1; - msgv.topic = bytes32(0); - msgv.commands = cmds; - - // Expect AtomicCommandFailed for the second command (index 1) with nonce and index - vm.expectRevert( - abi.encodeWithSelector( - IGatewayV2.AtomicCommandFailed.selector, uint64(msgv.nonce), uint256(1) - ) - ); - // call v2_submit (verification overridden to true) - gw.v2_submit(msgv, proof, makeMockProof(), bytes32(0)); - - // message should not be recorded as dispatched - assertFalse(gw.v2_isDispatched(msgv.nonce)); - - // operating mode should remain unchanged - mode = gw.operatingMode(); - assertEq(uint256(mode), uint256(OperatingMode.Normal)); - } - function testUnlockTokenThenCallContractsWillSucceed() public { bytes32 topic = keccak256("topic"); @@ -1382,13 +1310,11 @@ contract GatewayV2Test is Test { commands[0] = CommandV2({ kind: CommandKind.UnlockNativeToken, gas: 100_000, - atomic: false, payload: abi.encode(unlockParams) }); commands[1] = CommandV2({ kind: CommandKind.CallContracts, gas: 200_000, - atomic: false, payload: abi.encode(callParams) }); @@ -1469,13 +1395,11 @@ contract GatewayV2Test is Test { commands[0] = CommandV2({ kind: CommandKind.UnlockNativeToken, gas: 100_000, - atomic: false, payload: abi.encode(unlockParams) }); commands[1] = CommandV2({ kind: CommandKind.CallContracts, gas: 200_000, - atomic: false, payload: abi.encode(callParams) }); From 9d8fa86395253a91c59642b2dc7c88f0967540d1 Mon Sep 17 00:00:00 2001 From: ron Date: Wed, 28 Jan 2026 00:45:43 +0800 Subject: [PATCH 19/28] Move multi-call out --- contracts/src/AgentExecutor.sol | 19 +- contracts/src/Gateway.sol | 26 +-- contracts/src/utils/Call.sol | 32 --- contracts/src/v2/Handlers.sol | 10 +- contracts/src/v2/Types.sol | 4 - contracts/test/GatewayV2.t.sol | 335 ++++----------------------- contracts/test/mocks/MockGateway.sol | 2 +- 7 files changed, 53 insertions(+), 375 deletions(-) diff --git a/contracts/src/AgentExecutor.sol b/contracts/src/AgentExecutor.sol index a7b9c4ec5..9197b20c0 100644 --- a/contracts/src/AgentExecutor.sol +++ b/contracts/src/AgentExecutor.sol @@ -5,7 +5,6 @@ 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. @@ -24,24 +23,10 @@ contract AgentExecutor { } // Call contract with Ether value - function callContract(CallContractParams calldata params) external { - bool success = - Call.safeCallWithGasLimit(params.target, params.data, params.value, params.gas); + function callContract(address target, bytes memory data, uint256 value) external { + bool success = Call.safeCall(target, data, value); if (!success) { 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.safeCallWithGasLimit( - params[i].target, params[i].data, params[i].value, params[i].gas - ); - if (!success) { - revert(); - } - } - } } diff --git a/contracts/src/Gateway.sol b/contracts/src/Gateway.sol index 49d7db3d1..dd930a687 100644 --- a/contracts/src/Gateway.sol +++ b/contracts/src/Gateway.sol @@ -520,22 +520,12 @@ contract Gateway is IGatewayBase, IGatewayV1, IGatewayV2, IInitializable, IUpgra HandlersV2.callContract(origin, AGENT_EXECUTOR, data); } - // Call multiple arbitrary contract functions - function _handleCallContracts(bytes32 origin, bytes calldata data) internal { - HandlersV2.callContracts(origin, AGENT_EXECUTOR, data); - } - /** * APIv2 Internal functions */ // Internal helper to dispatch a single command function _dispatchCommand(CommandV2 calldata command, bytes32 origin) internal { - // check that there is enough gas available to forward to the command handler - if (gasleft() * 63 / 64 < command.gas + DISPATCH_OVERHEAD_GAS_V2) { - revert IGatewayV2.InsufficientGasLimit(); - } - if (command.kind == CommandKind.Upgrade) { _handleUpgrade(command.payload); } else if (command.kind == CommandKind.SetOperatingMode) { @@ -548,8 +538,6 @@ contract Gateway is IGatewayBase, IGatewayV1, IGatewayV2, IInitializable, IUpgra _handleMintForeignToken(command.payload); } else if (command.kind == CommandKind.CallContract) { _handleCallContract(origin, command.payload); - } else if (command.kind == CommandKind.CallContracts) { - _handleCallContracts(origin, command.payload); } else { revert IGatewayV2.InvalidCommand(); } @@ -562,15 +550,13 @@ contract Gateway is IGatewayBase, IGatewayV1, IGatewayV2, IInitializable, IUpgra bool success = true; for (uint256 i = 0; i < message.commands.length; i++) { CommandV2 calldata command = message.commands[i]; - try this.v2_dispatchCommand(command, message.origin) {} + // check that there is enough gas available to forward to the command handler + uint256 requiredGas = command.gas + DISPATCH_OVERHEAD_GAS_V2; + if (gasleft() * 63 / 64 < requiredGas) { + revert IGatewayV2.InsufficientGasLimit(); + } + try this.v2_dispatchCommand{gas: requiredGas}(command, message.origin) {} catch (bytes memory reason) { - // Check if the error is InsufficientGasLimit and rethrow it - if ( - reason.length >= 4 - && bytes4(reason) == IGatewayV2.InsufficientGasLimit.selector - ) { - revert IGatewayV2.InsufficientGasLimit(); - } emit IGatewayV2.CommandFailed(message.nonce, i); success = false; } diff --git a/contracts/src/utils/Call.sol b/contracts/src/utils/Call.sol index 16d0a451c..2bf36e6d4 100644 --- a/contracts/src/utils/Call.sol +++ b/contracts/src/utils/Call.sol @@ -50,36 +50,4 @@ library Call { } return success; } - - /** - * @notice Safely perform a low level call with a gas limit without copying any returndata - * - * @param target Address to call - * @param data Calldata to pass to the call - */ - function safeCallWithGasLimit( - address target, - bytes memory data, - uint256 value, - uint64 gasLimit - ) internal returns (bool) { - // Disallow zero gas to avoid silent no-op calls. - if (gasLimit == 0) { - revert InvalidGasLimit(); - } - - bool success; - assembly { - success := call( - gasLimit, // gas - target, // recipient - value, // ether value - add(data, 0x20), // inloc - mload(data), // inlen - 0, // outloc - 0 // outlen - ) - } - return success; - } } diff --git a/contracts/src/v2/Handlers.sol b/contracts/src/v2/Handlers.sol index e294ee7f2..c8ce0669a 100644 --- a/contracts/src/v2/Handlers.sol +++ b/contracts/src/v2/Handlers.sol @@ -67,14 +67,8 @@ library HandlersV2 { function callContract(bytes32 origin, address executor, bytes calldata data) external { CallContractParams memory params = abi.decode(data, (CallContractParams)); address agent = Functions.ensureAgent(origin); - bytes memory call = abi.encodeCall(AgentExecutor.callContract, (params)); - Functions.invokeOnAgent(agent, executor, call); - } - - function callContracts(bytes32 origin, address executor, bytes calldata data) external { - address agent = Functions.ensureAgent(origin); - CallContractParams[] memory params = abi.decode(data, (CallContractParams[])); - bytes memory call = abi.encodeCall(AgentExecutor.callContracts, (params)); + bytes memory call = + abi.encodeCall(AgentExecutor.callContract, (params.target, params.data, params.value)); Functions.invokeOnAgent(agent, executor, call); } } diff --git a/contracts/src/v2/Types.sol b/contracts/src/v2/Types.sol index 771ce740f..95fb43d8e 100644 --- a/contracts/src/v2/Types.sol +++ b/contracts/src/v2/Types.sol @@ -36,8 +36,6 @@ 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,8 +183,6 @@ struct CallContractParams { bytes data; // Ether value uint256 value; - // Gas limit - uint64 gas; } enum Network { diff --git a/contracts/test/GatewayV2.t.sol b/contracts/test/GatewayV2.t.sol index 21f7559a9..5b6098af1 100644 --- a/contracts/test/GatewayV2.t.sol +++ b/contracts/test/GatewayV2.t.sol @@ -224,9 +224,7 @@ contract GatewayV2Test is Test { CommandV2[] memory commands = new CommandV2[](1); SetOperatingModeParams memory params = SetOperatingModeParams({mode: OperatingMode.Normal}); commands[0] = CommandV2({ - kind: CommandKind.SetOperatingMode, - gas: 500_000, - payload: abi.encode(params) + kind: CommandKind.SetOperatingMode, gas: 500_000, payload: abi.encode(params) }); return commands; } @@ -282,9 +280,8 @@ contract GatewayV2Test is Test { function makeCallContractCommand(uint256 value) public view returns (CommandV2[] memory) { bytes memory data = abi.encodeWithSignature("sayHello(string)", "World"); - CallContractParams memory params = CallContractParams({ - target: address(helloWorld), data: data, value: value, gas: 20_000 - }); + CallContractParams memory params = + CallContractParams({target: address(helloWorld), data: data, value: value}); bytes memory payload = abi.encode(params); CommandV2[] memory commands = new CommandV2[](1); @@ -298,9 +295,8 @@ contract GatewayV2Test is Test { returns (CommandV2[] memory) { bytes memory data = abi.encodeWithSignature("sayHelloNotExists(string)", "World"); - CallContractParams memory params = CallContractParams({ - target: address(helloWorld), data: data, value: value, gas: 20_000 - }); + CallContractParams memory params = + CallContractParams({target: address(helloWorld), data: data, value: value}); bytes memory payload = abi.encode(params); CommandV2[] memory commands = new CommandV2[](1); @@ -315,37 +311,12 @@ contract GatewayV2Test is Test { { // Call expensiveOperation which requires storage writes and thus significant gas bytes memory data = abi.encodeWithSignature("expensiveOperation()"); - uint64 callGas = 5000; // insufficient gas for storage write (typical cost: ~20000) - CallContractParams memory params = CallContractParams({ - target: address(helloWorld), data: data, value: value, gas: callGas - }); - bytes memory payload = abi.encode(params); - - CommandV2[] memory commands = new CommandV2[](1); - commands[0] = CommandV2({kind: CommandKind.CallContract, gas: callGas, payload: payload}); - return commands; - } - - function makeCallContractsCommand(uint256 value1, uint256 value2) - public - view - returns (CommandV2[] memory) - { - bytes memory data1 = abi.encodeWithSignature("sayHello(string)", "World"); - bytes memory data2 = abi.encodeWithSignature("sayHello(string)", "Snowbridge"); - - CallContractParams[] memory params = new CallContractParams[](2); - params[0] = CallContractParams({ - target: address(helloWorld), data: data1, value: value1, gas: 20_000 - }); - params[1] = CallContractParams({ - target: address(helloWorld), data: data2, value: value2, gas: 20_000 - }); - + CallContractParams memory params = + CallContractParams({target: address(helloWorld), data: data, value: value}); bytes memory payload = abi.encode(params); CommandV2[] memory commands = new CommandV2[](1); - commands[0] = CommandV2({kind: CommandKind.CallContracts, gas: 500_000, payload: payload}); + commands[0] = CommandV2({kind: CommandKind.CallContract, gas: 500_000, payload: payload}); return commands; } @@ -411,19 +382,14 @@ contract GatewayV2Test is Test { SetOperatingModeParams memory params = SetOperatingModeParams({mode: OperatingMode.RejectingOutboundMessages}); commands[0] = CommandV2({ - kind: CommandKind.SetOperatingMode, - gas: 80_000, - payload: abi.encode(params) + kind: CommandKind.SetOperatingMode, gas: 100_000, payload: abi.encode(params) }); bytes memory data = abi.encodeWithSignature("sayHello(string)", "World"); CallContractParams[] memory callParams = new CallContractParams[](1); - callParams[0] = - CallContractParams({target: address(helloWorld), data: data, value: 0, gas: 20_000}); + callParams[0] = CallContractParams({target: address(helloWorld), data: data, value: 0}); commands[1] = CommandV2({ - kind: CommandKind.CallContracts, - gas: 80_000, - payload: abi.encode(callParams) + kind: CommandKind.CallContract, gas: 100_000, payload: abi.encode(callParams) }); InboundMessageV2 memory message = InboundMessageV2({ @@ -658,29 +624,6 @@ contract GatewayV2Test is Test { ); } - function testAgentCallContractsSuccess() public { - bytes32 topic = keccak256("topic"); - - vm.expectEmit(true, false, false, true); - emit IGatewayV2.InboundMessageDispatched(1, topic, true, relayerRewardAddress); - - // fund the agent to forward value in both calls - vm.deal(assetHubAgent, 0.1 ether); - hoax(relayer, 1 ether); - IGatewayV2(address(gateway)) - .v2_submit( - InboundMessageV2({ - origin: Constants.ASSET_HUB_AGENT_ID, - nonce: 1, - topic: topic, - commands: makeCallContractsCommand(0.05 ether, 0.05 ether) - }), - proof, - makeMockProof(), - relayerRewardAddress - ); - } - function testCreateAgent() public { bytes32 origin = bytes32(uint256(1)); vm.expectEmit(true, false, false, false); @@ -778,29 +721,22 @@ contract GatewayV2Test is Test { SetOperatingModeParams memory params1 = SetOperatingModeParams({mode: OperatingMode.Normal}); commands[0] = CommandV2({ - kind: CommandKind.SetOperatingMode, - gas: 500_000, - payload: abi.encode(params1) + kind: CommandKind.SetOperatingMode, gas: 500_000, payload: abi.encode(params1) }); // Second command should fail - Call a function that reverts bytes memory failingData = abi.encodeWithSignature("revertUnauthorized()"); - CallContractParams memory params2 = CallContractParams({ - target: address(helloWorld), data: failingData, value: 0, gas: 20_000 - }); + CallContractParams memory params2 = + CallContractParams({target: address(helloWorld), data: failingData, value: 0}); commands[1] = CommandV2({ - kind: CommandKind.CallContract, - gas: 500_000, - payload: abi.encode(params2) + kind: CommandKind.CallContract, gas: 500_000, payload: abi.encode(params2) }); // Third command should succeed - SetOperatingMode again SetOperatingModeParams memory params3 = SetOperatingModeParams({mode: OperatingMode.Normal}); commands[2] = CommandV2({ - kind: CommandKind.SetOperatingMode, - gas: 500_000, - payload: abi.encode(params3) + kind: CommandKind.SetOperatingMode, gas: 500_000, payload: abi.encode(params3) }); // Expect the failed command to emit CommandFailed event @@ -833,9 +769,7 @@ contract GatewayV2Test is Test { SetOperatingModeParams memory params1 = SetOperatingModeParams({mode: OperatingMode.Normal}); commands[0] = CommandV2({ - kind: CommandKind.SetOperatingMode, - gas: 500_000, - payload: abi.encode(params1) + kind: CommandKind.SetOperatingMode, gas: 500_000, payload: abi.encode(params1) }); // Second command is invalid @@ -875,27 +809,21 @@ contract GatewayV2Test is Test { SetOperatingModeParams memory params1 = SetOperatingModeParams({mode: OperatingMode.Normal}); commands[0] = CommandV2({ - kind: CommandKind.SetOperatingMode, - gas: 500_000, - payload: abi.encode(params1) + kind: CommandKind.SetOperatingMode, gas: 500_000, payload: abi.encode(params1) }); // Second command - Set mode to RejectingOutboundMessages (will succeed) SetOperatingModeParams memory params2 = SetOperatingModeParams({mode: OperatingMode.RejectingOutboundMessages}); commands[1] = CommandV2({ - kind: CommandKind.SetOperatingMode, - gas: 500_000, - payload: abi.encode(params2) + kind: CommandKind.SetOperatingMode, gas: 500_000, payload: abi.encode(params2) }); // Third command - Also set mode to Normal again (will succeed) SetOperatingModeParams memory params3 = SetOperatingModeParams({mode: OperatingMode.Normal}); commands[2] = CommandV2({ - kind: CommandKind.SetOperatingMode, - gas: 500_000, - payload: abi.encode(params3) + kind: CommandKind.SetOperatingMode, gas: 500_000, payload: abi.encode(params3) }); // Expect InboundMessageDispatched to be emitted with success=true since all commands should succeed @@ -927,9 +855,7 @@ contract GatewayV2Test is Test { function testSetOperatingModeSucceeds() public { bytes memory payload = abi.encode((SetOperatingModeParams({mode: OperatingMode.Normal}))); CommandV2 memory cmd = CommandV2({ - kind: CommandKind.SetOperatingMode, - gas: uint64(200_000), - payload: payload + kind: CommandKind.SetOperatingMode, gas: uint64(200_000), payload: payload }); // Expect the OperatingModeChanged event to be emitted @@ -950,9 +876,7 @@ contract GatewayV2Test is Test { bytes memory payload = abi.encode(params); CommandV2 memory cmd = CommandV2({ - kind: CommandKind.UnlockNativeToken, - gas: uint64(200_000), - payload: payload + kind: CommandKind.UnlockNativeToken, gas: uint64(200_000), payload: payload }); // handler revert should be caught and command should fail (revert) @@ -981,9 +905,7 @@ contract GatewayV2Test is Test { bytes memory payload = abi.encode(p); CommandV2 memory cmd = CommandV2({ - kind: CommandKind.MintForeignToken, - gas: uint64(200_000), - payload: payload + kind: CommandKind.MintForeignToken, gas: uint64(200_000), payload: payload }); // mintForeignToken for unregistered token should revert @@ -999,9 +921,7 @@ contract GatewayV2Test is Test { bytes memory payload = abi.encode(p); CommandV2 memory cmd = CommandV2({ - kind: CommandKind.RegisterForeignToken, - gas: uint64(3_000_000), - payload: payload + kind: CommandKind.RegisterForeignToken, gas: uint64(3_000_000), payload: payload }); // first register should succeed @@ -1013,9 +933,8 @@ contract GatewayV2Test is Test { } function testCallContractAgentDoesNotExistReturnsFalse() public { - CallContractParams memory p = CallContractParams({ - target: address(0xdead), data: "", value: uint256(0), gas: 20_000 - }); + CallContractParams memory p = + CallContractParams({target: address(0xdead), data: "", value: uint256(0)}); bytes memory payload = abi.encode(p); // origin corresponds to agent id; use a non-existent id @@ -1027,19 +946,6 @@ contract GatewayV2Test is Test { gatewayLogic.callDispatch(cmd, bytes32(uint256(0x9999))); } - function testInsufficientGasReverts() public { - bytes memory payload = ""; - // Use an extremely large gas value to trigger InsufficientGasLimit revert in _dispatchCommand - CommandV2 memory cmd = CommandV2({ - kind: CommandKind.SetOperatingMode, - gas: type(uint64).max, - payload: payload - }); - - vm.expectRevert(IGatewayV2.InsufficientGasLimit.selector); - gatewayLogic.callDispatch(cmd, bytes32(0)); - } - function testUpgradeCallsInitialize() public { uint256 initValue = 0x12345; bytes memory initParams = abi.encode(initValue); @@ -1248,7 +1154,7 @@ contract GatewayV2Test is Test { CommandV2({kind: CommandKind.SetOperatingMode, gas: 200_000, payload: abi.encode(p)}); CallContractParams memory cc = - CallContractParams({target: address(0x1234), data: "", value: 0, gas: 20_000}); + CallContractParams({target: address(0x1234), data: "", value: 0}); cmds[1] = CommandV2({kind: CommandKind.CallContract, gas: 200_000, payload: abi.encode(cc)}); InboundMessageV2 memory msgv; @@ -1268,181 +1174,24 @@ contract GatewayV2Test is Test { assertTrue(gw.v2_isDispatched(msgv.nonce)); } - function testUnlockTokenThenCallContractsWillSucceed() public { - bytes32 topic = keccak256("topic"); - - // Set up an ERC20 token (WETH) for the agent to work with - address token = address(new WETH9()); - MockGateway(address(gateway)).prank_registerNativeToken(token); - - uint128 tokenAmount = 1 ether; - vm.deal(assetHubAgent, tokenAmount); - hoax(assetHubAgent); - WETH9(payable(token)).deposit{value: tokenAmount}(); - - // Verify agent has the WETH - assertEq(IERC20(token).balanceOf(assetHubAgent), tokenAmount); - - // Create two commands: - // 1. UnlockNativeToken: Unlock asset to the AssetHub agent - // 2. CallContracts: Two internal calls - approve HelloWorld, then transfer tokens using consumeToken - - // Command 1: UnlockNativeToken - unlock to assetHubAgent (no-op in this case since agent already has tokens) - UnlockNativeTokenParams memory unlockParams = - UnlockNativeTokenParams({token: token, recipient: assetHubAgent, amount: tokenAmount}); - - // Command 2: CallContracts - two internal calls: - // - First: approve HelloWorld to spend tokens from the agent - // - Second: agent calls consumeToken to transfer tokens to HelloWorld - bytes memory approveData = - abi.encodeWithSignature("approve(address,uint256)", address(helloWorld), tokenAmount); - bytes memory consumeData = - abi.encodeWithSignature("consumeToken(address,uint256)", token, tokenAmount); - - CallContractParams[] memory callParams = new CallContractParams[](2); - callParams[0] = - CallContractParams({target: token, data: approveData, value: 0, gas: 100_000}); - callParams[1] = CallContractParams({ - target: address(helloWorld), data: consumeData, value: 0, gas: 100_000 - }); - - CommandV2[] memory commands = new CommandV2[](2); - commands[0] = CommandV2({ - kind: CommandKind.UnlockNativeToken, - gas: 100_000, - payload: abi.encode(unlockParams) - }); - commands[1] = CommandV2({ - kind: CommandKind.CallContracts, - gas: 200_000, - payload: abi.encode(callParams) - }); - - // Fund agent with balance for gas - vm.deal(assetHubAgent, 1 ether); - - // Expect both commands to succeed - 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: commands - }), - proof, - makeMockProof(), - relayerRewardAddress - ); - - // Verify the token flow: agent -> HelloWorld - assertEq(IERC20(token).balanceOf(assetHubAgent), 0, "Agent should have no tokens left"); - assertEq( - IERC20(token).balanceOf(address(helloWorld)), - tokenAmount, - "HelloWorld should have received all tokens" - ); - } - - function testUnlockTokenThenCallContractsWithRevert() public { - bytes32 topic = keccak256("topic"); - - // Set up an ERC20 token (WETH) for the agent to work with - address token = address(new WETH9()); - MockGateway(address(gateway)).prank_registerNativeToken(token); + function testInsufficientGasReverts() public { + MockGateway gw = MockGateway(address(gateway)); - uint128 tokenAmount = 1 ether; - uint128 halfAmount = tokenAmount / 2; - vm.deal(assetHubAgent, tokenAmount); - hoax(assetHubAgent); - WETH9(payable(token)).deposit{value: tokenAmount}(); - - // Verify agent has the WETH - assertEq(IERC20(token).balanceOf(assetHubAgent), tokenAmount); - - // Create two commands: - // 1. UnlockNativeToken: Unlock asset to the relayer - // 2. CallContracts: Three internal calls - approve, transfer tokens, then revert - - // Command 1: UnlockNativeToken - unlock to relayer - UnlockNativeTokenParams memory unlockParams = - UnlockNativeTokenParams({token: token, recipient: relayer, amount: halfAmount}); - - // Command 2: CallContracts - three internal calls: - // - First: approve HelloWorld to spend tokens from the agent - // - Second: agent calls consumeToken to transfer tokens to HelloWorld - // - Third: call revertUnauthorized which will fail - bytes memory approveData = - abi.encodeWithSignature("approve(address,uint256)", address(helloWorld), halfAmount); - bytes memory consumeData = - abi.encodeWithSignature("consumeToken(address,uint256)", token, halfAmount); - bytes memory revertData = abi.encodeWithSignature("revertUnauthorized()"); - - CallContractParams[] memory callParams = new CallContractParams[](3); - callParams[0] = - CallContractParams({target: token, data: approveData, value: 0, gas: 50_000}); - callParams[1] = CallContractParams({ - target: address(helloWorld), data: consumeData, value: 0, gas: 50_000 - }); - callParams[2] = CallContractParams({ - target: address(helloWorld), data: revertData, value: 0, gas: 50_000 - }); - CommandV2[] memory commands = new CommandV2[](2); - commands[0] = CommandV2({ - kind: CommandKind.UnlockNativeToken, - gas: 100_000, - payload: abi.encode(unlockParams) - }); - commands[1] = CommandV2({ - kind: CommandKind.CallContracts, - gas: 200_000, - payload: abi.encode(callParams) + // Use an extremely large gas value to trigger InsufficientGasLimit revert in _dispatchCommand + CommandV2[] memory cmds = new CommandV2[](1); + SetOperatingModeParams memory p = SetOperatingModeParams({mode: OperatingMode.Normal}); + cmds[0] = CommandV2({ + kind: CommandKind.SetOperatingMode, gas: type(uint64).max, payload: abi.encode(p) }); - // Expect the CallContracts command to fail - vm.expectEmit(true, false, false, true); - emit IGatewayV2.CommandFailed(1, 1); // nonce 1, command index 1 - - // Expect InboundMessageDispatched to be emitted with success=false (command failed) - vm.expectEmit(true, false, false, true); - 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: commands - }), - proof, - makeMockProof(), - relayerRewardAddress - ); - - // Verify relayer received the unlocked tokens - assertEq( - IERC20(token).balanceOf(relayer), - halfAmount, - "Relayer should have received unlocked tokens" - ); + InboundMessageV2 memory msgv; + msgv.origin = bytes32("orig"); + msgv.nonce = 1; + msgv.topic = bytes32(0); + msgv.commands = cmds; - // Verify atomicity: since the third call failed, the first two calls should be reverted - // The agent should still have all tokens (no transfer occurred) - assertEq( - IERC20(token).balanceOf(assetHubAgent), - halfAmount, - "Agent should still have all tokens due to revert" - ); - assertEq( - IERC20(token).balanceOf(address(helloWorld)), - 0, - "HelloWorld should have no tokens due to revert" - ); + vm.expectRevert(IGatewayV2.InsufficientGasLimit.selector); + // call v2_submit (verification overridden to true) + gw.v2_submit(msgv, proof, makeMockProof(), bytes32(0)); } } diff --git a/contracts/test/mocks/MockGateway.sol b/contracts/test/mocks/MockGateway.sol index d66e90905..b2f4d0716 100644 --- a/contracts/test/mocks/MockGateway.sol +++ b/contracts/test/mocks/MockGateway.sol @@ -93,7 +93,7 @@ contract MockGateway is Gateway { } function callDispatch(CommandV2 calldata command, bytes32 origin) external { - this.v2_dispatchCommand(command, origin); + this.v2_dispatchCommand{gas: command.gas}(command, origin); } function deployAgent() external returns (address) { From 6a44e061bd1427562d0a7fcb461ee8c838db56ae Mon Sep 17 00:00:00 2001 From: ron Date: Wed, 28 Jan 2026 00:50:42 +0800 Subject: [PATCH 20/28] Revert change --- contracts/src/utils/Call.sol | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/contracts/src/utils/Call.sol b/contracts/src/utils/Call.sol index 2bf36e6d4..a685c0ecb 100644 --- a/contracts/src/utils/Call.sol +++ b/contracts/src/utils/Call.sol @@ -5,8 +5,6 @@ pragma solidity 0.8.33; // Derived from OpenZeppelin Contracts (last updated v4.9.0) (utils/Address.sol) library Call { - error InvalidGasLimit(); - function verifyResult(bool success, bytes memory returndata) internal pure @@ -38,15 +36,16 @@ library Call { function safeCall(address target, bytes memory data, uint256 value) internal returns (bool) { bool success; assembly { - success := call( - gas(), // gas - target, // recipient - value, // ether value - add(data, 0x20), // inloc - mload(data), // inlen - 0, // outloc - 0 // outlen - ) + success := + call( + gas(), // gas + target, // recipient + value, // ether value + add(data, 0x20), // inloc + mload(data), // inlen + 0, // outloc + 0 // outlen + ) } return success; } From e50bc6a41f1dddee83d3bc6c980a122cfbb1b30d Mon Sep 17 00:00:00 2001 From: ron Date: Wed, 28 Jan 2026 00:55:36 +0800 Subject: [PATCH 21/28] Revert change --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 70cb2c32c..be4f0e391 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ + # Local Netlify folder .netlify @@ -41,4 +42,3 @@ lodestar db.sqlite* .pnpm-store /deploy -lib/ From ead7af03357ac2c6bb5de0d5b9527e0574cf0435 Mon Sep 17 00:00:00 2001 From: ron Date: Wed, 28 Jan 2026 00:57:55 +0800 Subject: [PATCH 22/28] Revert change --- .gitignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitignore b/.gitignore index be4f0e391..be434ea19 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ - # Local Netlify folder .netlify From e22e220ad01ee528afa0a8918c03fe5282c14360 Mon Sep 17 00:00:00 2001 From: ron Date: Wed, 28 Jan 2026 01:01:57 +0800 Subject: [PATCH 23/28] Restore the comments --- contracts/src/Gateway.sol | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/contracts/src/Gateway.sol b/contracts/src/Gateway.sol index dd930a687..b3c67a280 100644 --- a/contracts/src/Gateway.sol +++ b/contracts/src/Gateway.sol @@ -431,10 +431,10 @@ contract Gateway is IGatewayBase, IGatewayV1, IGatewayV2, IInitializable, IUpgra revert IGatewayBase.InvalidProof(); } - // Dispatch the message payload. - bool dispatchSuccess = false; - try Gateway(this).v2_dispatch(message) returns (bool success) { - dispatchSuccess = success; + // Dispatch the message payload. The boolean returned indicates whether all commands succeeded. + bool success = false; + try Gateway(this).v2_dispatch(message) returns (bool _success) { + success = _success; } catch (bytes memory reason) { // If insufficient gas limit, rethrow the error to stop processing // Otherwise, silently ignore command failures @@ -445,9 +445,10 @@ contract Gateway is IGatewayBase, IGatewayV1, IGatewayV2, IInitializable, IUpgra } } - // Emit the event indicating message dispatch was attempted. + // Emit the event with a success value "true" if all commands successfully executed, otherwise "false" + // if all or some of the commands failed. emit IGatewayV2.InboundMessageDispatched( - message.nonce, message.topic, dispatchSuccess, rewardAddress + message.nonce, message.topic, success, rewardAddress ); } From de8ee0969f0d34623edceb354013310be2311887 Mon Sep 17 00:00:00 2001 From: ron Date: Wed, 28 Jan 2026 01:23:24 +0800 Subject: [PATCH 24/28] Revamp tests --- contracts/test/GatewayV2.t.sol | 172 +++++++++++++-------------- contracts/test/mocks/HelloWorld.sol | 23 +--- contracts/test/mocks/MockGateway.sol | 15 ++- 3 files changed, 96 insertions(+), 114 deletions(-) diff --git a/contracts/test/GatewayV2.t.sol b/contracts/test/GatewayV2.t.sol index 5b6098af1..63f3a73b0 100644 --- a/contracts/test/GatewayV2.t.sol +++ b/contracts/test/GatewayV2.t.sol @@ -309,14 +309,13 @@ contract GatewayV2Test is Test { view returns (CommandV2[] memory) { - // Call expensiveOperation which requires storage writes and thus significant gas - bytes memory data = abi.encodeWithSignature("expensiveOperation()"); + bytes memory data = abi.encodeWithSignature("sayHello(string)", "World"); CallContractParams memory params = CallContractParams({target: address(helloWorld), data: data, value: value}); bytes memory payload = abi.encode(params); CommandV2[] memory commands = new CommandV2[](1); - commands[0] = CommandV2({kind: CommandKind.CallContract, gas: 500_000, payload: payload}); + commands[0] = CommandV2({kind: CommandKind.CallContract, gas: 1, payload: payload}); return commands; } @@ -378,35 +377,29 @@ contract GatewayV2Test is Test { bytes32 topic = keccak256("topic"); // Create a command with very high gas requirement - CommandV2[] memory commands = new CommandV2[](2); - SetOperatingModeParams memory params = - SetOperatingModeParams({mode: OperatingMode.RejectingOutboundMessages}); + CommandV2[] memory commands = new CommandV2[](1); + SetOperatingModeParams memory params = SetOperatingModeParams({mode: OperatingMode.Normal}); commands[0] = CommandV2({ - kind: CommandKind.SetOperatingMode, gas: 100_000, payload: abi.encode(params) - }); - - bytes memory data = abi.encodeWithSignature("sayHello(string)", "World"); - CallContractParams[] memory callParams = new CallContractParams[](1); - callParams[0] = CallContractParams({target: address(helloWorld), data: data, value: 0}); - commands[1] = CommandV2({ - kind: CommandKind.CallContract, gas: 100_000, payload: abi.encode(callParams) + kind: CommandKind.SetOperatingMode, + gas: 30_000_000, // Extremely high gas value + payload: abi.encode(params) }); InboundMessageV2 memory message = InboundMessageV2({ - origin: Constants.ASSET_HUB_AGENT_ID, nonce: 1, topic: topic, commands: commands + origin: keccak256("666"), + nonce: 2, // Use a different nonce from other tests + topic: topic, + commands: commands }); - // Limit the gas for this test to ensure we hit the InsufficientGasLimit error - uint256 gasLimit = 180_000; + // Limit the gas for this test to ensure we hit the NotEnoughGas error + uint256 gasLimit = 100_000; vm.deal(relayer, 1 ether); - vm.prank(relayer); vm.expectRevert(IGatewayV2.InsufficientGasLimit.selector); + vm.prank(relayer); IGatewayV2(address(gateway)) .v2_submit{gas: gasLimit}(message, proof, makeMockProof(), relayerRewardAddress); - - OperatingMode mode = IGatewayV2(address(gateway)).operatingMode(); - assertEq(uint256(mode), uint256(OperatingMode.Normal)); } function mockNativeTokenForSend(address user, uint128 amount) @@ -743,7 +736,7 @@ contract GatewayV2Test is Test { vm.expectEmit(true, false, false, true); emit IGatewayV2.CommandFailed(1, 1); // nonce 1, command index 1 - // Expect InboundMessageDispatched to be emitted with success=false (command failed) + // Expect InboundMessageDispatched to be emitted with success=false since not all commands succeeded vm.expectEmit(true, false, false, true); emit IGatewayV2.InboundMessageDispatched(1, topic, false, relayerRewardAddress); @@ -783,7 +776,7 @@ contract GatewayV2Test is Test { vm.expectEmit(true, false, false, true); emit IGatewayV2.CommandFailed(2, 1); // nonce 2, command index 1 - // Expect InboundMessageDispatched to be emitted with success=false (command failed) + // Expect InboundMessageDispatched to be emitted with success=false vm.expectEmit(true, false, false, true); emit IGatewayV2.InboundMessageDispatched(2, topic, false, relayerRewardAddress); @@ -847,9 +840,8 @@ contract GatewayV2Test is Test { CommandV2 memory cmd = CommandV2({kind: uint8(200), gas: uint64(100_000), payload: payload}); - // unknown command should revert with InvalidCommand - vm.expectRevert(IGatewayV2.InvalidCommand.selector); - gatewayLogic.callDispatch(cmd, bytes32(0)); + bool ok = gatewayLogic.callDispatch(cmd, bytes32(0)); + assertTrue(!ok, "unknown command should return false"); } function testSetOperatingModeSucceeds() public { @@ -862,14 +854,15 @@ contract GatewayV2Test is Test { vm.expectEmit(true, false, false, true); emit IGatewayBase.OperatingModeChanged(OperatingMode.Normal); - gatewayLogic.callDispatch(cmd, bytes32(0)); + bool ok = gatewayLogic.callDispatch(cmd, bytes32(0)); + assertTrue(ok, "setOperatingMode should succeed"); // Verify mode was set assertEq(uint256(gatewayLogic.operatingMode()), uint256(OperatingMode.Normal)); } function testHandlerRevertIsCaught_UnlockNativeToken() public { - // Ensure no agent exists for ASSET_HUB_AGENT_ID so ensureAgent will revert + // Ensure no agent exists for ASSET_HUB_AGENT_ID so ensureAgent will revert and _dispatchCommand returns false UnlockNativeTokenParams memory params = UnlockNativeTokenParams({ token: address(0), recipient: address(this), amount: uint128(1) }); @@ -879,9 +872,8 @@ contract GatewayV2Test is Test { kind: CommandKind.UnlockNativeToken, gas: uint64(200_000), payload: payload }); - // handler revert should be caught and command should fail (revert) - vm.expectRevert(); - gatewayLogic.callDispatch(cmd, bytes32(0)); + bool ok = gatewayLogic.callDispatch(cmd, bytes32(0)); + assertTrue(!ok, "handler revert should be caught and return false"); } function testHandlerRevertIsCaught_UpgradeInvalidImpl() public { @@ -893,9 +885,8 @@ contract GatewayV2Test is Test { CommandV2 memory cmd = CommandV2({kind: CommandKind.Upgrade, gas: uint64(200_000), payload: payload}); - // upgrade with invalid impl should revert - vm.expectRevert(); - gatewayLogic.callDispatch(cmd, bytes32(0)); + bool ok = gatewayLogic.callDispatch(cmd, bytes32(0)); + assertTrue(!ok, "upgrade with invalid impl should be caught and return false"); } function testMintForeignTokenNotRegisteredReturnsFalse() public { @@ -908,9 +899,8 @@ contract GatewayV2Test is Test { kind: CommandKind.MintForeignToken, gas: uint64(200_000), payload: payload }); - // mintForeignToken for unregistered token should revert - vm.expectRevert(); - gatewayLogic.callDispatch(cmd, bytes32(0)); + bool ok = gatewayLogic.callDispatch(cmd, bytes32(0)); + assertTrue(!ok, "mintForeignToken for unregistered token should return false"); } function testRegisterForeignTokenDuplicateReturnsFalse() public { @@ -924,12 +914,11 @@ contract GatewayV2Test is Test { kind: CommandKind.RegisterForeignToken, gas: uint64(3_000_000), payload: payload }); - // first register should succeed - gatewayLogic.callDispatch(cmd, bytes32(0)); + bool ok1 = gatewayLogic.callDispatch(cmd, bytes32(0)); + assertTrue(ok1, "first register should succeed"); - // duplicate register should revert - vm.expectRevert(); - gatewayLogic.callDispatch(cmd, bytes32(0)); + bool ok2 = gatewayLogic.callDispatch(cmd, bytes32(0)); + assertTrue(!ok2, "duplicate register should return false"); } function testCallContractAgentDoesNotExistReturnsFalse() public { @@ -941,9 +930,19 @@ contract GatewayV2Test is Test { CommandV2 memory cmd = CommandV2({kind: CommandKind.CallContract, gas: uint64(200_000), payload: payload}); - // callContract with missing agent should revert + bool ok = gatewayLogic.callDispatch(cmd, bytes32(uint256(0x9999))); + assertTrue(!ok, "callContract with missing agent should return false"); + } + + function testInsufficientGasReverts() public { + bytes memory payload = ""; + // Use an extremely large gas value to trigger InsufficientGasLimit revert in _dispatchCommand + CommandV2 memory cmd = CommandV2({ + kind: CommandKind.SetOperatingMode, gas: type(uint64).max, payload: payload + }); + vm.expectRevert(); - gatewayLogic.callDispatch(cmd, bytes32(uint256(0x9999))); + gatewayLogic.callDispatch(cmd, bytes32(0)); } function testUpgradeCallsInitialize() public { @@ -1019,7 +1018,7 @@ contract GatewayV2Test is Test { vm.expectEmit(true, false, false, true); emit IGatewayV2.CommandFailed(1, 0); - + emit IGatewayV2.InboundMessageDispatched(1, topic, false, relayerRewardAddress); IGatewayV2(address(gateway)) .v2_submit( InboundMessageV2({ @@ -1034,7 +1033,7 @@ contract GatewayV2Test is Test { ); } - function testAgentCallContractWontRevertForInsufficientGas() public { + function testAgentCallContractRevertedForInsufficientGas() public { bytes32 topic = keccak256("topic"); vm.deal(assetHubAgent, 1 ether); @@ -1042,7 +1041,7 @@ contract GatewayV2Test is Test { vm.expectEmit(true, false, false, true); emit IGatewayV2.CommandFailed(1, 0); - + emit IGatewayV2.InboundMessageDispatched(1, topic, false, relayerRewardAddress); IGatewayV2(address(gateway)) .v2_submit( InboundMessageV2({ @@ -1104,27 +1103,14 @@ contract GatewayV2Test is Test { function test_onlySelf_enforced_on_external_calls() public { MockGateway gw = MockGateway(address(gateway)); - // Try to call a protected handler function directly (not via internal dispatch) - // This should fail because msg.sender != address(this) - SetOperatingModeParams memory p = - SetOperatingModeParams({mode: OperatingMode.RejectingOutboundMessages}); + // calling the dispatch entrypoint externally should revert with Unauthorized + SetOperatingModeParams memory p = SetOperatingModeParams({mode: OperatingMode.Normal}); bytes memory payload = abi.encode(p); - - // Attempt to call v1_handleSetOperatingMode directly from external context - // Should revert with Unauthorized since onlySelf modifier requires msg.sender == address(this) - vm.expectRevert(IGatewayBase.Unauthorized.selector); - gw.v1_handleSetOperatingMode(payload); - - // Try another onlySelf protected function - v2_dispatchCommand - CommandV2 memory cmd = - CommandV2({kind: CommandKind.SetOperatingMode, gas: 100_000, payload: payload}); - + CommandV2 memory cmd = CommandV2({ + kind: CommandKind.SetOperatingMode, gas: uint64(200_000), payload: payload + }); vm.expectRevert(IGatewayBase.Unauthorized.selector); gw.v2_dispatchCommand(cmd, bytes32(0)); - - // Verify mode was not changed (stayed Normal) - OperatingMode mode = gw.operatingMode(); - assertEq(uint256(mode), uint256(OperatingMode.Normal)); } function test_call_handleSetOperatingMode_via_self_changes_mode() public { @@ -1141,8 +1127,8 @@ contract GatewayV2Test is Test { function test_dispatch_unknown_command_returns_false() public { MockGateway gw = MockGateway(address(gateway)); CommandV2 memory cmd = CommandV2({kind: 0xFF, gas: 100_000, payload: ""}); - vm.expectRevert(IGatewayV2.InvalidCommand.selector); - gw.exposed_dispatchCommand(cmd, bytes32(0)); + bool ok = gw.callDispatch(cmd, bytes32(0)); + assertFalse(ok, "unknown command must return false"); } function test_v2_dispatch_partial_failure_emits_CommandFailed() public { @@ -1163,35 +1149,41 @@ contract GatewayV2Test is Test { msgv.topic = bytes32(0); msgv.commands = cmds; - // Expect CommandFailed for the second command (index 1) - vm.expectEmit(true, false, false, true); - emit IGatewayV2.CommandFailed(msgv.nonce, 1); - // call v2_submit (verification overridden to true) - gw.v2_submit(msgv, proof, makeMockProof(), bytes32(0)); - // message should be recorded as dispatched - assertTrue(gw.v2_isDispatched(msgv.nonce)); - } + // construct an empty Verification.Proof + Verification.DigestItem[] memory digestItems = new Verification.DigestItem[](0); + Verification.ParachainHeader memory header = Verification.ParachainHeader({ + parentHash: bytes32(0), + number: 0, + stateRoot: bytes32(0), + extrinsicsRoot: bytes32(0), + digestItems: digestItems + }); - function testInsufficientGasReverts() public { - MockGateway gw = MockGateway(address(gateway)); + bytes32[] memory emptyBytes32 = new bytes32[](0); + Verification.HeadProof memory hp = + Verification.HeadProof({pos: 0, width: 0, proof: emptyBytes32}); + Verification.MMRLeafPartial memory lp = Verification.MMRLeafPartial({ + version: 0, + parentNumber: 0, + parentHash: bytes32(0), + nextAuthoritySetID: 0, + nextAuthoritySetLen: 0, + nextAuthoritySetRoot: bytes32(0) + }); - // Use an extremely large gas value to trigger InsufficientGasLimit revert in _dispatchCommand - CommandV2[] memory cmds = new CommandV2[](1); - SetOperatingModeParams memory p = SetOperatingModeParams({mode: OperatingMode.Normal}); - cmds[0] = CommandV2({ - kind: CommandKind.SetOperatingMode, gas: type(uint64).max, payload: abi.encode(p) + Verification.Proof memory headerProof = Verification.Proof({ + header: header, + headProof: hp, + leafPartial: lp, + leafProof: emptyBytes32, + leafProofOrder: 0 }); - InboundMessageV2 memory msgv; - msgv.origin = bytes32("orig"); - msgv.nonce = 1; - msgv.topic = bytes32(0); - msgv.commands = cmds; + gw.v2_submit(msgv, proof, headerProof, bytes32(0)); - vm.expectRevert(IGatewayV2.InsufficientGasLimit.selector); - // call v2_submit (verification overridden to true) - gw.v2_submit(msgv, proof, makeMockProof(), bytes32(0)); + // message should be recorded as dispatched + assertTrue(gw.v2_isDispatched(msgv.nonce)); } } diff --git a/contracts/test/mocks/HelloWorld.sol b/contracts/test/mocks/HelloWorld.sol index d86d13e2e..1c9aeb74d 100644 --- a/contracts/test/mocks/HelloWorld.sol +++ b/contracts/test/mocks/HelloWorld.sol @@ -1,44 +1,23 @@ // SPDX-License-Identifier: Apache-2.0 pragma solidity 0.8.33; -import {IERC20} from "../../src/interfaces/IERC20.sol"; - contract HelloWorld { event SaidHello(string indexed message); - event TokenConsumed(address indexed token, address indexed from, uint256 amount); error Unauthorized(); - uint256 private counter; // storage variable for expensive operations - function sayHello(string memory _text) public payable { string memory fullMessage = string(abi.encodePacked("Hello there, ", _text)); emit SaidHello(fullMessage); } - // Function that requires significant gas due to storage operations - function expensiveOperation() public { - counter += 1; - } - function revertUnauthorized() public pure { revert Unauthorized(); } function retBomb() public pure returns (bytes memory) { assembly { - return(1, 0x2dc6c0) + return(1, 3000000) } } - - /// @dev Consume an approved ERC20 token from the caller - /// @param token The ERC20 token address - /// @param amount The amount to transfer from msg.sender to this contract - function consumeToken(address token, uint256 amount) public { - require( - IERC20(token).transferFrom(msg.sender, address(this), amount), "transferFrom failed" - ); - emit TokenConsumed(token, msg.sender, amount); - } } - diff --git a/contracts/test/mocks/MockGateway.sol b/contracts/test/mocks/MockGateway.sol index b2f4d0716..530ae1d6b 100644 --- a/contracts/test/mocks/MockGateway.sol +++ b/contracts/test/mocks/MockGateway.sol @@ -13,6 +13,7 @@ import {IInitializable} from "../../src/interfaces/IInitializable.sol"; import {UD60x18} from "prb/math/src/UD60x18.sol"; import {Command as CommandV2} from "../../src/v2/Types.sol"; +import {IGatewayV2} from "../../src/v2/IGateway.sol"; import {Agent} from "../../src/Agent.sol"; import {AgentExecutor} from "../../src/AgentExecutor.sol"; import {Constants} from "../../src/Constants.sol"; @@ -92,8 +93,18 @@ contract MockGateway is Gateway { return super.v1_transactionBaseGas(); } - function callDispatch(CommandV2 calldata command, bytes32 origin) external { - this.v2_dispatchCommand{gas: command.gas}(command, origin); + function callDispatch(CommandV2 calldata command, bytes32 origin) external returns (bool) { + // Mirror v2_dispatch per-command behavior: enforce gas budget and surface failure as false + uint256 requiredGas = command.gas + DISPATCH_OVERHEAD_GAS_V2; + if (gasleft() * 63 / 64 < requiredGas) { + revert IGatewayV2.InsufficientGasLimit(); + } + + try this.v2_dispatchCommand{gas: requiredGas}(command, origin) { + return true; + } catch { + return false; + } } function deployAgent() external returns (address) { From 1fa3437f090d49a2726b0dbfd6e2422e920b3845 Mon Sep 17 00:00:00 2001 From: ron Date: Thu, 5 Feb 2026 01:24:33 +0800 Subject: [PATCH 25/28] Refactor Gateway --- contracts/src/Gateway.sol | 124 +++++++++------------------ contracts/test/mocks/MockGateway.sol | 45 ++++++++-- 2 files changed, 79 insertions(+), 90 deletions(-) diff --git a/contracts/src/Gateway.sol b/contracts/src/Gateway.sol index b3c67a280..517462490 100644 --- a/contracts/src/Gateway.sol +++ b/contracts/src/Gateway.sol @@ -431,22 +431,17 @@ contract Gateway is IGatewayBase, IGatewayV1, IGatewayV2, IInitializable, IUpgra revert IGatewayBase.InvalidProof(); } - // Dispatch the message payload. The boolean returned indicates whether all commands succeeded. - bool success = false; - try Gateway(this).v2_dispatch(message) returns (bool _success) { - success = _success; - } catch (bytes memory reason) { - // If insufficient gas limit, rethrow the error to stop processing - // Otherwise, silently ignore command failures - if (reason.length >= 4 && bytes4(reason) == IGatewayV2.InsufficientGasLimit.selector) { - assembly { - revert(add(reason, 32), mload(reason)) - } - } + // Dispatch all the commands within the batch of commands in the message payload. Each command is processed + // independently, returns: + // 1. insufficientGasLimit: true if the gas limit provided was insufficient for any command + // 2. success: true if all commands executed successfully, false if any command failed. + (bool insufficientGasLimit, bool success) = + Gateway(this).v2_dispatch(message.commands, message.origin, message.nonce); + // Revert if the gas limit provided was insufficient for any command + if (insufficientGasLimit) { + revert IGatewayV2.InsufficientGasLimit(); } - - // Emit the event with a success value "true" if all commands successfully executed, otherwise "false" - // if all or some of the commands failed. + // Emit event for the overall message dispatch result emit IGatewayV2.InboundMessageDispatched( message.nonce, message.topic, success, rewardAddress ); @@ -487,87 +482,52 @@ contract Gateway is IGatewayBase, IGatewayV1, IGatewayV2, IInitializable, IUpgra CallsV2.createAgent(id); } - /** - * APIv2 Message Handlers - */ - - // Perform an upgrade of the gateway - function _handleUpgrade(bytes calldata data) internal { - HandlersV2.upgrade(data); - } - - // Set the operating mode of the gateway - function _handleSetOperatingMode(bytes calldata data) internal { - HandlersV2.setOperatingMode(data); - } - - // Unlock Native token - function _handleUnlockNativeToken(bytes calldata data) internal { - HandlersV2.unlockNativeToken(AGENT_EXECUTOR, data); - } - - // Register a new fungible Polkadot token for an agent - function _handleRegisterForeignToken(bytes calldata data) internal { - HandlersV2.registerForeignToken(data); - } - - // Mint foreign token from polkadot - function _handleMintForeignToken(bytes calldata data) internal { - HandlersV2.mintForeignToken(data); - } - - // Call an arbitrary contract function - function _handleCallContract(bytes32 origin, bytes calldata data) internal { - HandlersV2.callContract(origin, AGENT_EXECUTOR, data); - } - /** * APIv2 Internal functions */ - // Internal helper to dispatch a single command - function _dispatchCommand(CommandV2 calldata command, bytes32 origin) internal { - if (command.kind == CommandKind.Upgrade) { - _handleUpgrade(command.payload); - } else if (command.kind == CommandKind.SetOperatingMode) { - _handleSetOperatingMode(command.payload); - } else if (command.kind == CommandKind.UnlockNativeToken) { - _handleUnlockNativeToken(command.payload); - } else if (command.kind == CommandKind.RegisterForeignToken) { - _handleRegisterForeignToken(command.payload); - } else if (command.kind == CommandKind.MintForeignToken) { - _handleMintForeignToken(command.payload); - } else if (command.kind == CommandKind.CallContract) { - _handleCallContract(origin, command.payload); - } else { - revert IGatewayV2.InvalidCommand(); - } - } - - // Dispatch all the commands within the batch of commands in the message payload. Each command is processed - // independently, such that failures emit a `CommandFailed` event without stopping execution of - // subsequent commands. Returns true if all commands executed successfully, false if any command failed. - function v2_dispatch(InboundMessageV2 calldata message) external onlySelf returns (bool) { + function v2_dispatch(CommandV2[] calldata commands, bytes32 origin, uint64 nonce) + external + onlySelf + returns (bool, bool) + { bool success = true; - for (uint256 i = 0; i < message.commands.length; i++) { - CommandV2 calldata command = message.commands[i]; + for (uint256 i = 0; i < commands.length; i++) { + CommandV2 calldata command = commands[i]; // check that there is enough gas available to forward to the command handler uint256 requiredGas = command.gas + DISPATCH_OVERHEAD_GAS_V2; if (gasleft() * 63 / 64 < requiredGas) { - revert IGatewayV2.InsufficientGasLimit(); + return (true, false); } - try this.v2_dispatchCommand{gas: requiredGas}(command, message.origin) {} - catch (bytes memory reason) { - emit IGatewayV2.CommandFailed(message.nonce, i); + try Gateway(this).v2_dispatchCommand{gas: command.gas}(command, origin) {} + catch { success = false; + emit IGatewayV2.CommandFailed(nonce, i); } } - return success; + return (false, success); } - // Helper function to dispatch a single command with try-catch for error handling - function v2_dispatchCommand(CommandV2 calldata command, bytes32 origin) external onlySelf { - _dispatchCommand(command, origin); + function v2_dispatchCommand(CommandV2 calldata command, bytes32 origin) + external + virtual + onlySelf + { + if (command.kind == CommandKind.Upgrade) { + HandlersV2.upgrade(command.payload); + } else if (command.kind == CommandKind.SetOperatingMode) { + HandlersV2.setOperatingMode(command.payload); + } else if (command.kind == CommandKind.UnlockNativeToken) { + HandlersV2.unlockNativeToken(AGENT_EXECUTOR, command.payload); + } else if (command.kind == CommandKind.RegisterForeignToken) { + HandlersV2.registerForeignToken(command.payload); + } else if (command.kind == CommandKind.MintForeignToken) { + HandlersV2.mintForeignToken(command.payload); + } else if (command.kind == CommandKind.CallContract) { + HandlersV2.callContract(origin, AGENT_EXECUTOR, command.payload); + } else { + revert IGatewayV2.InvalidCommand(); + } } /** diff --git a/contracts/test/mocks/MockGateway.sol b/contracts/test/mocks/MockGateway.sol index b2f4d0716..195c0b82c 100644 --- a/contracts/test/mocks/MockGateway.sol +++ b/contracts/test/mocks/MockGateway.sol @@ -12,7 +12,8 @@ import {IInitializable} from "../../src/interfaces/IInitializable.sol"; import {UD60x18} from "prb/math/src/UD60x18.sol"; -import {Command as CommandV2} from "../../src/v2/Types.sol"; +import {Command as CommandV2, CommandKind} from "../../src/v2/Types.sol"; +import {IGatewayV2} from "../../src/v2/IGateway.sol"; import {Agent} from "../../src/Agent.sol"; import {AgentExecutor} from "../../src/AgentExecutor.sol"; import {Constants} from "../../src/Constants.sol"; @@ -92,8 +93,41 @@ contract MockGateway is Gateway { return super.v1_transactionBaseGas(); } - function callDispatch(CommandV2 calldata command, bytes32 origin) external { - this.v2_dispatchCommand{gas: command.gas}(command, origin); + // Dispatch a single V2 command (used by tests). Must be called via `this`. + function v2_dispatchCommand(CommandV2 calldata command, bytes32 origin) + external + override + onlySelf + { + if (command.kind == CommandKind.Upgrade) { + HandlersV2.upgrade(command.payload); + } else if (command.kind == CommandKind.SetOperatingMode) { + HandlersV2.setOperatingMode(command.payload); + } else if (command.kind == CommandKind.UnlockNativeToken) { + HandlersV2.unlockNativeToken(AGENT_EXECUTOR, command.payload); + } else if (command.kind == CommandKind.RegisterForeignToken) { + HandlersV2.registerForeignToken(command.payload); + } else if (command.kind == CommandKind.MintForeignToken) { + HandlersV2.mintForeignToken(command.payload); + } else if (command.kind == CommandKind.CallContract) { + HandlersV2.callContract(origin, AGENT_EXECUTOR, command.payload); + } else { + revert IGatewayV2.InvalidCommand(); + } + } + + function callDispatch(CommandV2 calldata command, bytes32 origin) external returns (bool) { + // Mirror v2_dispatch per-command behavior: enforce gas budget and surface failure as false + uint256 requiredGas = command.gas + DISPATCH_OVERHEAD_GAS_V2; + if (gasleft() * 63 / 64 < requiredGas) { + revert IGatewayV2.InsufficientGasLimit(); + } + + try this.v2_dispatchCommand{gas: requiredGas}(command, origin) { + return true; + } catch { + return false; + } } function deployAgent() external returns (address) { @@ -114,11 +148,6 @@ contract MockGateway is Gateway { return v1_transactionBaseGas(); } - // Wrapper to call an internal dispatch command - function exposed_dispatchCommand(CommandV2 calldata cmd, bytes32 origin) external { - _dispatchCommand(cmd, origin); - } - // Helper to call vulnerable-onlySelf handler from within the contract (so msg.sender == this) function setOperatingMode(bytes calldata data) external { HandlersV2.setOperatingMode(data); From 88e53629b6a8b6c97e89cca2567caae9b673d4a8 Mon Sep 17 00:00:00 2001 From: ron Date: Thu, 5 Feb 2026 01:27:43 +0800 Subject: [PATCH 26/28] Fix tests --- contracts/test/GatewayV2.t.sol | 172 +++++++++++++-------------- contracts/test/mocks/HelloWorld.sol | 23 +--- contracts/test/mocks/MockGateway.sol | 25 +--- 3 files changed, 84 insertions(+), 136 deletions(-) diff --git a/contracts/test/GatewayV2.t.sol b/contracts/test/GatewayV2.t.sol index 5b6098af1..63f3a73b0 100644 --- a/contracts/test/GatewayV2.t.sol +++ b/contracts/test/GatewayV2.t.sol @@ -309,14 +309,13 @@ contract GatewayV2Test is Test { view returns (CommandV2[] memory) { - // Call expensiveOperation which requires storage writes and thus significant gas - bytes memory data = abi.encodeWithSignature("expensiveOperation()"); + bytes memory data = abi.encodeWithSignature("sayHello(string)", "World"); CallContractParams memory params = CallContractParams({target: address(helloWorld), data: data, value: value}); bytes memory payload = abi.encode(params); CommandV2[] memory commands = new CommandV2[](1); - commands[0] = CommandV2({kind: CommandKind.CallContract, gas: 500_000, payload: payload}); + commands[0] = CommandV2({kind: CommandKind.CallContract, gas: 1, payload: payload}); return commands; } @@ -378,35 +377,29 @@ contract GatewayV2Test is Test { bytes32 topic = keccak256("topic"); // Create a command with very high gas requirement - CommandV2[] memory commands = new CommandV2[](2); - SetOperatingModeParams memory params = - SetOperatingModeParams({mode: OperatingMode.RejectingOutboundMessages}); + CommandV2[] memory commands = new CommandV2[](1); + SetOperatingModeParams memory params = SetOperatingModeParams({mode: OperatingMode.Normal}); commands[0] = CommandV2({ - kind: CommandKind.SetOperatingMode, gas: 100_000, payload: abi.encode(params) - }); - - bytes memory data = abi.encodeWithSignature("sayHello(string)", "World"); - CallContractParams[] memory callParams = new CallContractParams[](1); - callParams[0] = CallContractParams({target: address(helloWorld), data: data, value: 0}); - commands[1] = CommandV2({ - kind: CommandKind.CallContract, gas: 100_000, payload: abi.encode(callParams) + kind: CommandKind.SetOperatingMode, + gas: 30_000_000, // Extremely high gas value + payload: abi.encode(params) }); InboundMessageV2 memory message = InboundMessageV2({ - origin: Constants.ASSET_HUB_AGENT_ID, nonce: 1, topic: topic, commands: commands + origin: keccak256("666"), + nonce: 2, // Use a different nonce from other tests + topic: topic, + commands: commands }); - // Limit the gas for this test to ensure we hit the InsufficientGasLimit error - uint256 gasLimit = 180_000; + // Limit the gas for this test to ensure we hit the NotEnoughGas error + uint256 gasLimit = 100_000; vm.deal(relayer, 1 ether); - vm.prank(relayer); vm.expectRevert(IGatewayV2.InsufficientGasLimit.selector); + vm.prank(relayer); IGatewayV2(address(gateway)) .v2_submit{gas: gasLimit}(message, proof, makeMockProof(), relayerRewardAddress); - - OperatingMode mode = IGatewayV2(address(gateway)).operatingMode(); - assertEq(uint256(mode), uint256(OperatingMode.Normal)); } function mockNativeTokenForSend(address user, uint128 amount) @@ -743,7 +736,7 @@ contract GatewayV2Test is Test { vm.expectEmit(true, false, false, true); emit IGatewayV2.CommandFailed(1, 1); // nonce 1, command index 1 - // Expect InboundMessageDispatched to be emitted with success=false (command failed) + // Expect InboundMessageDispatched to be emitted with success=false since not all commands succeeded vm.expectEmit(true, false, false, true); emit IGatewayV2.InboundMessageDispatched(1, topic, false, relayerRewardAddress); @@ -783,7 +776,7 @@ contract GatewayV2Test is Test { vm.expectEmit(true, false, false, true); emit IGatewayV2.CommandFailed(2, 1); // nonce 2, command index 1 - // Expect InboundMessageDispatched to be emitted with success=false (command failed) + // Expect InboundMessageDispatched to be emitted with success=false vm.expectEmit(true, false, false, true); emit IGatewayV2.InboundMessageDispatched(2, topic, false, relayerRewardAddress); @@ -847,9 +840,8 @@ contract GatewayV2Test is Test { CommandV2 memory cmd = CommandV2({kind: uint8(200), gas: uint64(100_000), payload: payload}); - // unknown command should revert with InvalidCommand - vm.expectRevert(IGatewayV2.InvalidCommand.selector); - gatewayLogic.callDispatch(cmd, bytes32(0)); + bool ok = gatewayLogic.callDispatch(cmd, bytes32(0)); + assertTrue(!ok, "unknown command should return false"); } function testSetOperatingModeSucceeds() public { @@ -862,14 +854,15 @@ contract GatewayV2Test is Test { vm.expectEmit(true, false, false, true); emit IGatewayBase.OperatingModeChanged(OperatingMode.Normal); - gatewayLogic.callDispatch(cmd, bytes32(0)); + bool ok = gatewayLogic.callDispatch(cmd, bytes32(0)); + assertTrue(ok, "setOperatingMode should succeed"); // Verify mode was set assertEq(uint256(gatewayLogic.operatingMode()), uint256(OperatingMode.Normal)); } function testHandlerRevertIsCaught_UnlockNativeToken() public { - // Ensure no agent exists for ASSET_HUB_AGENT_ID so ensureAgent will revert + // Ensure no agent exists for ASSET_HUB_AGENT_ID so ensureAgent will revert and _dispatchCommand returns false UnlockNativeTokenParams memory params = UnlockNativeTokenParams({ token: address(0), recipient: address(this), amount: uint128(1) }); @@ -879,9 +872,8 @@ contract GatewayV2Test is Test { kind: CommandKind.UnlockNativeToken, gas: uint64(200_000), payload: payload }); - // handler revert should be caught and command should fail (revert) - vm.expectRevert(); - gatewayLogic.callDispatch(cmd, bytes32(0)); + bool ok = gatewayLogic.callDispatch(cmd, bytes32(0)); + assertTrue(!ok, "handler revert should be caught and return false"); } function testHandlerRevertIsCaught_UpgradeInvalidImpl() public { @@ -893,9 +885,8 @@ contract GatewayV2Test is Test { CommandV2 memory cmd = CommandV2({kind: CommandKind.Upgrade, gas: uint64(200_000), payload: payload}); - // upgrade with invalid impl should revert - vm.expectRevert(); - gatewayLogic.callDispatch(cmd, bytes32(0)); + bool ok = gatewayLogic.callDispatch(cmd, bytes32(0)); + assertTrue(!ok, "upgrade with invalid impl should be caught and return false"); } function testMintForeignTokenNotRegisteredReturnsFalse() public { @@ -908,9 +899,8 @@ contract GatewayV2Test is Test { kind: CommandKind.MintForeignToken, gas: uint64(200_000), payload: payload }); - // mintForeignToken for unregistered token should revert - vm.expectRevert(); - gatewayLogic.callDispatch(cmd, bytes32(0)); + bool ok = gatewayLogic.callDispatch(cmd, bytes32(0)); + assertTrue(!ok, "mintForeignToken for unregistered token should return false"); } function testRegisterForeignTokenDuplicateReturnsFalse() public { @@ -924,12 +914,11 @@ contract GatewayV2Test is Test { kind: CommandKind.RegisterForeignToken, gas: uint64(3_000_000), payload: payload }); - // first register should succeed - gatewayLogic.callDispatch(cmd, bytes32(0)); + bool ok1 = gatewayLogic.callDispatch(cmd, bytes32(0)); + assertTrue(ok1, "first register should succeed"); - // duplicate register should revert - vm.expectRevert(); - gatewayLogic.callDispatch(cmd, bytes32(0)); + bool ok2 = gatewayLogic.callDispatch(cmd, bytes32(0)); + assertTrue(!ok2, "duplicate register should return false"); } function testCallContractAgentDoesNotExistReturnsFalse() public { @@ -941,9 +930,19 @@ contract GatewayV2Test is Test { CommandV2 memory cmd = CommandV2({kind: CommandKind.CallContract, gas: uint64(200_000), payload: payload}); - // callContract with missing agent should revert + bool ok = gatewayLogic.callDispatch(cmd, bytes32(uint256(0x9999))); + assertTrue(!ok, "callContract with missing agent should return false"); + } + + function testInsufficientGasReverts() public { + bytes memory payload = ""; + // Use an extremely large gas value to trigger InsufficientGasLimit revert in _dispatchCommand + CommandV2 memory cmd = CommandV2({ + kind: CommandKind.SetOperatingMode, gas: type(uint64).max, payload: payload + }); + vm.expectRevert(); - gatewayLogic.callDispatch(cmd, bytes32(uint256(0x9999))); + gatewayLogic.callDispatch(cmd, bytes32(0)); } function testUpgradeCallsInitialize() public { @@ -1019,7 +1018,7 @@ contract GatewayV2Test is Test { vm.expectEmit(true, false, false, true); emit IGatewayV2.CommandFailed(1, 0); - + emit IGatewayV2.InboundMessageDispatched(1, topic, false, relayerRewardAddress); IGatewayV2(address(gateway)) .v2_submit( InboundMessageV2({ @@ -1034,7 +1033,7 @@ contract GatewayV2Test is Test { ); } - function testAgentCallContractWontRevertForInsufficientGas() public { + function testAgentCallContractRevertedForInsufficientGas() public { bytes32 topic = keccak256("topic"); vm.deal(assetHubAgent, 1 ether); @@ -1042,7 +1041,7 @@ contract GatewayV2Test is Test { vm.expectEmit(true, false, false, true); emit IGatewayV2.CommandFailed(1, 0); - + emit IGatewayV2.InboundMessageDispatched(1, topic, false, relayerRewardAddress); IGatewayV2(address(gateway)) .v2_submit( InboundMessageV2({ @@ -1104,27 +1103,14 @@ contract GatewayV2Test is Test { function test_onlySelf_enforced_on_external_calls() public { MockGateway gw = MockGateway(address(gateway)); - // Try to call a protected handler function directly (not via internal dispatch) - // This should fail because msg.sender != address(this) - SetOperatingModeParams memory p = - SetOperatingModeParams({mode: OperatingMode.RejectingOutboundMessages}); + // calling the dispatch entrypoint externally should revert with Unauthorized + SetOperatingModeParams memory p = SetOperatingModeParams({mode: OperatingMode.Normal}); bytes memory payload = abi.encode(p); - - // Attempt to call v1_handleSetOperatingMode directly from external context - // Should revert with Unauthorized since onlySelf modifier requires msg.sender == address(this) - vm.expectRevert(IGatewayBase.Unauthorized.selector); - gw.v1_handleSetOperatingMode(payload); - - // Try another onlySelf protected function - v2_dispatchCommand - CommandV2 memory cmd = - CommandV2({kind: CommandKind.SetOperatingMode, gas: 100_000, payload: payload}); - + CommandV2 memory cmd = CommandV2({ + kind: CommandKind.SetOperatingMode, gas: uint64(200_000), payload: payload + }); vm.expectRevert(IGatewayBase.Unauthorized.selector); gw.v2_dispatchCommand(cmd, bytes32(0)); - - // Verify mode was not changed (stayed Normal) - OperatingMode mode = gw.operatingMode(); - assertEq(uint256(mode), uint256(OperatingMode.Normal)); } function test_call_handleSetOperatingMode_via_self_changes_mode() public { @@ -1141,8 +1127,8 @@ contract GatewayV2Test is Test { function test_dispatch_unknown_command_returns_false() public { MockGateway gw = MockGateway(address(gateway)); CommandV2 memory cmd = CommandV2({kind: 0xFF, gas: 100_000, payload: ""}); - vm.expectRevert(IGatewayV2.InvalidCommand.selector); - gw.exposed_dispatchCommand(cmd, bytes32(0)); + bool ok = gw.callDispatch(cmd, bytes32(0)); + assertFalse(ok, "unknown command must return false"); } function test_v2_dispatch_partial_failure_emits_CommandFailed() public { @@ -1163,35 +1149,41 @@ contract GatewayV2Test is Test { msgv.topic = bytes32(0); msgv.commands = cmds; - // Expect CommandFailed for the second command (index 1) - vm.expectEmit(true, false, false, true); - emit IGatewayV2.CommandFailed(msgv.nonce, 1); - // call v2_submit (verification overridden to true) - gw.v2_submit(msgv, proof, makeMockProof(), bytes32(0)); - // message should be recorded as dispatched - assertTrue(gw.v2_isDispatched(msgv.nonce)); - } + // construct an empty Verification.Proof + Verification.DigestItem[] memory digestItems = new Verification.DigestItem[](0); + Verification.ParachainHeader memory header = Verification.ParachainHeader({ + parentHash: bytes32(0), + number: 0, + stateRoot: bytes32(0), + extrinsicsRoot: bytes32(0), + digestItems: digestItems + }); - function testInsufficientGasReverts() public { - MockGateway gw = MockGateway(address(gateway)); + bytes32[] memory emptyBytes32 = new bytes32[](0); + Verification.HeadProof memory hp = + Verification.HeadProof({pos: 0, width: 0, proof: emptyBytes32}); + Verification.MMRLeafPartial memory lp = Verification.MMRLeafPartial({ + version: 0, + parentNumber: 0, + parentHash: bytes32(0), + nextAuthoritySetID: 0, + nextAuthoritySetLen: 0, + nextAuthoritySetRoot: bytes32(0) + }); - // Use an extremely large gas value to trigger InsufficientGasLimit revert in _dispatchCommand - CommandV2[] memory cmds = new CommandV2[](1); - SetOperatingModeParams memory p = SetOperatingModeParams({mode: OperatingMode.Normal}); - cmds[0] = CommandV2({ - kind: CommandKind.SetOperatingMode, gas: type(uint64).max, payload: abi.encode(p) + Verification.Proof memory headerProof = Verification.Proof({ + header: header, + headProof: hp, + leafPartial: lp, + leafProof: emptyBytes32, + leafProofOrder: 0 }); - InboundMessageV2 memory msgv; - msgv.origin = bytes32("orig"); - msgv.nonce = 1; - msgv.topic = bytes32(0); - msgv.commands = cmds; + gw.v2_submit(msgv, proof, headerProof, bytes32(0)); - vm.expectRevert(IGatewayV2.InsufficientGasLimit.selector); - // call v2_submit (verification overridden to true) - gw.v2_submit(msgv, proof, makeMockProof(), bytes32(0)); + // message should be recorded as dispatched + assertTrue(gw.v2_isDispatched(msgv.nonce)); } } diff --git a/contracts/test/mocks/HelloWorld.sol b/contracts/test/mocks/HelloWorld.sol index d86d13e2e..1c9aeb74d 100644 --- a/contracts/test/mocks/HelloWorld.sol +++ b/contracts/test/mocks/HelloWorld.sol @@ -1,44 +1,23 @@ // SPDX-License-Identifier: Apache-2.0 pragma solidity 0.8.33; -import {IERC20} from "../../src/interfaces/IERC20.sol"; - contract HelloWorld { event SaidHello(string indexed message); - event TokenConsumed(address indexed token, address indexed from, uint256 amount); error Unauthorized(); - uint256 private counter; // storage variable for expensive operations - function sayHello(string memory _text) public payable { string memory fullMessage = string(abi.encodePacked("Hello there, ", _text)); emit SaidHello(fullMessage); } - // Function that requires significant gas due to storage operations - function expensiveOperation() public { - counter += 1; - } - function revertUnauthorized() public pure { revert Unauthorized(); } function retBomb() public pure returns (bytes memory) { assembly { - return(1, 0x2dc6c0) + return(1, 3000000) } } - - /// @dev Consume an approved ERC20 token from the caller - /// @param token The ERC20 token address - /// @param amount The amount to transfer from msg.sender to this contract - function consumeToken(address token, uint256 amount) public { - require( - IERC20(token).transferFrom(msg.sender, address(this), amount), "transferFrom failed" - ); - emit TokenConsumed(token, msg.sender, amount); - } } - diff --git a/contracts/test/mocks/MockGateway.sol b/contracts/test/mocks/MockGateway.sol index 195c0b82c..cdf78ec9f 100644 --- a/contracts/test/mocks/MockGateway.sol +++ b/contracts/test/mocks/MockGateway.sol @@ -12,7 +12,7 @@ import {IInitializable} from "../../src/interfaces/IInitializable.sol"; import {UD60x18} from "prb/math/src/UD60x18.sol"; -import {Command as CommandV2, CommandKind} from "../../src/v2/Types.sol"; +import {Command as CommandV2} from "../../src/v2/Types.sol"; import {IGatewayV2} from "../../src/v2/IGateway.sol"; import {Agent} from "../../src/Agent.sol"; import {AgentExecutor} from "../../src/AgentExecutor.sol"; @@ -93,29 +93,6 @@ contract MockGateway is Gateway { return super.v1_transactionBaseGas(); } - // Dispatch a single V2 command (used by tests). Must be called via `this`. - function v2_dispatchCommand(CommandV2 calldata command, bytes32 origin) - external - override - onlySelf - { - if (command.kind == CommandKind.Upgrade) { - HandlersV2.upgrade(command.payload); - } else if (command.kind == CommandKind.SetOperatingMode) { - HandlersV2.setOperatingMode(command.payload); - } else if (command.kind == CommandKind.UnlockNativeToken) { - HandlersV2.unlockNativeToken(AGENT_EXECUTOR, command.payload); - } else if (command.kind == CommandKind.RegisterForeignToken) { - HandlersV2.registerForeignToken(command.payload); - } else if (command.kind == CommandKind.MintForeignToken) { - HandlersV2.mintForeignToken(command.payload); - } else if (command.kind == CommandKind.CallContract) { - HandlersV2.callContract(origin, AGENT_EXECUTOR, command.payload); - } else { - revert IGatewayV2.InvalidCommand(); - } - } - function callDispatch(CommandV2 calldata command, bytes32 origin) external returns (bool) { // Mirror v2_dispatch per-command behavior: enforce gas budget and surface failure as false uint256 requiredGas = command.gas + DISPATCH_OVERHEAD_GAS_V2; From a42f979a9954b5bb305e0a9e37c60430707d564a Mon Sep 17 00:00:00 2001 From: ron Date: Thu, 5 Feb 2026 01:40:26 +0800 Subject: [PATCH 27/28] Comments --- contracts/src/Gateway.sol | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/contracts/src/Gateway.sol b/contracts/src/Gateway.sol index 517462490..d3c51f3e0 100644 --- a/contracts/src/Gateway.sol +++ b/contracts/src/Gateway.sol @@ -431,10 +431,7 @@ contract Gateway is IGatewayBase, IGatewayV1, IGatewayV2, IInitializable, IUpgra revert IGatewayBase.InvalidProof(); } - // Dispatch all the commands within the batch of commands in the message payload. Each command is processed - // independently, returns: - // 1. insufficientGasLimit: true if the gas limit provided was insufficient for any command - // 2. success: true if all commands executed successfully, false if any command failed. + // Dispatch all commands in the message (bool insufficientGasLimit, bool success) = Gateway(this).v2_dispatch(message.commands, message.origin, message.nonce); // Revert if the gas limit provided was insufficient for any command @@ -482,10 +479,10 @@ contract Gateway is IGatewayBase, IGatewayV1, IGatewayV2, IInitializable, IUpgra CallsV2.createAgent(id); } - /** - * APIv2 Internal functions - */ - + // Dispatch all the commands within the batch of commands in the message payload. Each command is processed + // independently, returns: + // 1. insufficientGasLimit: true if the gas limit provided was insufficient for any command + // 2. success: true if all commands executed successfully, false if any command failed. function v2_dispatch(CommandV2[] calldata commands, bytes32 origin, uint64 nonce) external onlySelf @@ -508,6 +505,7 @@ contract Gateway is IGatewayBase, IGatewayV1, IGatewayV2, IInitializable, IUpgra return (false, success); } + // Dispatch a single command to its handler function v2_dispatchCommand(CommandV2 calldata command, bytes32 origin) external virtual From 504bb855c899bea5c88532c5ce02f3ec4d0d4cf9 Mon Sep 17 00:00:00 2001 From: ron Date: Thu, 5 Feb 2026 10:39:29 +0800 Subject: [PATCH 28/28] Fix mock --- contracts/test/mocks/MockGateway.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/test/mocks/MockGateway.sol b/contracts/test/mocks/MockGateway.sol index cdf78ec9f..507d43cc1 100644 --- a/contracts/test/mocks/MockGateway.sol +++ b/contracts/test/mocks/MockGateway.sol @@ -100,7 +100,7 @@ contract MockGateway is Gateway { revert IGatewayV2.InsufficientGasLimit(); } - try this.v2_dispatchCommand{gas: requiredGas}(command, origin) { + try this.v2_dispatchCommand{gas: command.gas}(command, origin) { return true; } catch { return false;