Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
6a68f59
Multiple calls support
yrong Jan 18, 2026
457e275
Support multiple calls
yrong Jan 18, 2026
a6c84ed
Add test
yrong Jan 18, 2026
d19ad8e
Revamp test
yrong Jan 18, 2026
71cbe16
Improve L1 adapter
yrong Jan 18, 2026
07cd880
Harden L1 adapter
yrong Jan 19, 2026
6c34fd5
Update contracts/src/AgentExecutor.sol
yrong Jan 19, 2026
09b8b9e
Merge branch 'main' into ron/multi-contract-calls
yrong Jan 26, 2026
6ef87ff
Revert L2 changes
yrong Jan 26, 2026
f418da7
Add atomic flag
yrong Jan 26, 2026
71939ab
[WIP] Address feedback on multiple contract calls implementation (#1685)
Copilot Jan 26, 2026
432469c
Remove trailing blank line in GatewayV2.t.sol (#1686)
Copilot Jan 26, 2026
5385b9b
Propagate InsufficientGasLimit errors from v2_dispatch (#1688)
Copilot Jan 26, 2026
daead2b
Fix tests
yrong Jan 26, 2026
d7a315a
Optimize gas usage for atomic command failures by skipping event emis…
Copilot Jan 27, 2026
22033e8
More tests
yrong Jan 27, 2026
1566ffb
Improve test
yrong Jan 27, 2026
f094137
Improve AtomicCommandFailed error
yrong Jan 27, 2026
3c99e2a
Remove the atomic control in Comand
yrong Jan 27, 2026
9d8fa86
Move multi-call out
yrong Jan 27, 2026
6a44e06
Revert change
yrong Jan 27, 2026
e50bc6a
Revert change
yrong Jan 27, 2026
ead7af0
Revert change
yrong Jan 27, 2026
e22e220
Restore the comments
yrong Jan 27, 2026
de8ee09
Revamp tests
yrong Jan 27, 2026
1fa3437
Refactor Gateway
yrong Feb 4, 2026
88e5362
Fix tests
yrong Feb 4, 2026
d6590c8
Merge branch 'ron/multi-contract-calls' of https://github.com/Snowfor…
yrong Feb 4, 2026
04bb804
Merge branch 'ron/multi-contract-calls-revamped' into ron/multi-contr…
yrong Feb 4, 2026
a42f979
Comments
yrong Feb 4, 2026
504bb85
Fix mock
yrong Feb 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 47 additions & 95 deletions contracts/src/Gateway.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -431,11 +431,14 @@ 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);

// Emit the event with a success value "true" if all commands successfully executed, otherwise "false"
// if all or some of the commands 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
if (insufficientGasLimit) {
revert IGatewayV2.InsufficientGasLimit();
}
// Emit event for the overall message dispatch result
emit IGatewayV2.InboundMessageDispatched(
message.nonce, message.topic, success, rewardAddress
);
Expand Down Expand Up @@ -476,104 +479,53 @@ contract Gateway is IGatewayBase, IGatewayV1, IGatewayV2, IInitializable, IUpgra
CallsV2.createAgent(id);
}

/**
* APIv2 Message Handlers
*/

// Perform an upgrade of the gateway
function v2_handleUpgrade(bytes calldata data) external onlySelf {
HandlersV2.upgrade(data);
}

// Set the operating mode of the gateway
function v2_handleSetOperatingMode(bytes calldata data) external onlySelf {
HandlersV2.setOperatingMode(data);
}

// Unlock Native token
function v2_handleUnlockNativeToken(bytes calldata data) external onlySelf {
HandlersV2.unlockNativeToken(AGENT_EXECUTOR, data);
}

// Register a new fungible Polkadot token for an agent
function v2_handleRegisterForeignToken(bytes calldata data) external onlySelf {
HandlersV2.registerForeignToken(data);
}

// Mint foreign token from polkadot
function v2_handleMintForeignToken(bytes calldata data) external onlySelf {
HandlersV2.mintForeignToken(data);
}

// Call an arbitrary contract function
function v2_handleCallContract(bytes32 origin, bytes calldata data) external onlySelf {
HandlersV2.callContract(origin, AGENT_EXECUTOR, data);
}

/**
* APIv2 Internal functions
*/

// Internal helper to dispatch a single command
function _dispatchCommand(CommandV2 calldata command, bytes32 origin)
internal
returns (bool)
// 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
returns (bool, 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();
bool success = true;
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) {
return (true, false);
}
try Gateway(this).v2_dispatchCommand{gas: command.gas}(command, origin) {}
catch {
success = false;
emit IGatewayV2.CommandFailed(nonce, i);
}
}
return (false, success);
}

// Dispatch a single command to its handler
function v2_dispatchCommand(CommandV2 calldata command, bytes32 origin)
external
virtual
onlySelf
{
if (command.kind == CommandKind.Upgrade) {
try Gateway(this).v2_handleUpgrade{gas: command.gas}(command.payload) {}
catch {
return false;
}
HandlersV2.upgrade(command.payload);
} else if (command.kind == CommandKind.SetOperatingMode) {
try Gateway(this).v2_handleSetOperatingMode{gas: command.gas}(command.payload) {}
catch {
return false;
}
HandlersV2.setOperatingMode(command.payload);
} else if (command.kind == CommandKind.UnlockNativeToken) {
try Gateway(this).v2_handleUnlockNativeToken{gas: command.gas}(command.payload) {}
catch {
return false;
}
HandlersV2.unlockNativeToken(AGENT_EXECUTOR, command.payload);
} else if (command.kind == CommandKind.RegisterForeignToken) {
try Gateway(this).v2_handleRegisterForeignToken{gas: command.gas}(command.payload) {}
catch {
return false;
}
HandlersV2.registerForeignToken(command.payload);
} else if (command.kind == CommandKind.MintForeignToken) {
try Gateway(this).v2_handleMintForeignToken{gas: command.gas}(command.payload) {}
catch {
return false;
}
HandlersV2.mintForeignToken(command.payload);
} else if (command.kind == CommandKind.CallContract) {
try Gateway(this).v2_handleCallContract{gas: command.gas}(origin, command.payload) {}
catch {
return false;
}
HandlersV2.callContract(origin, AGENT_EXECUTOR, 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;

for (uint256 i = 0; i < message.commands.length; i++) {
if (!_dispatchCommand(message.commands[i], message.origin)) {
emit IGatewayV2.CommandFailed(message.nonce, i);
allCommandsSucceeded = false;
}
}

return allCommandsSucceeded;
}

/**
Expand Down
1 change: 1 addition & 0 deletions contracts/src/v2/IGateway.sol
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ interface IGatewayV2 {
error InvalidNetwork();
error InvalidAsset();
error InsufficientGasLimit();
error InvalidCommand();
error InsufficientValue();
error ExceededMaximumValue();
error TooManyAssets();
Expand Down
14 changes: 8 additions & 6 deletions contracts/test/GatewayV2.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -496,9 +496,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);
}
Expand Down Expand Up @@ -1104,11 +1103,14 @@ 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
// calling the dispatch entrypoint externally should revert with Unauthorized
SetOperatingModeParams memory p = SetOperatingModeParams({mode: OperatingMode.Normal});
bytes memory payload = abi.encode(p);
CommandV2 memory cmd = CommandV2({
kind: CommandKind.SetOperatingMode, gas: uint64(200_000), payload: payload
});
vm.expectRevert(IGatewayBase.Unauthorized.selector);
gw.v2_handleSetOperatingMode(payload);
gw.v2_dispatchCommand(cmd, bytes32(0));
}

function test_call_handleSetOperatingMode_via_self_changes_mode() public {
Expand All @@ -1125,7 +1127,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, payload: ""});
bool ok = gw.exposed_dispatchCommand(cmd, bytes32(0));
bool ok = gw.callDispatch(cmd, bytes32(0));
assertFalse(ok, "unknown command must return false");
}

Expand Down
21 changes: 12 additions & 9 deletions contracts/test/mocks/MockGateway.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -93,7 +94,17 @@ contract MockGateway is Gateway {
}

function callDispatch(CommandV2 calldata command, bytes32 origin) external returns (bool) {
return super._dispatchCommand(command, origin);
// 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: command.gas}(command, origin) {
return true;
} catch {
return false;
}
}

function deployAgent() external returns (address) {
Expand All @@ -114,14 +125,6 @@ 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);
}

// Helper to call vulnerable-onlySelf handler from within the contract (so msg.sender == this)
function setOperatingMode(bytes calldata data) external {
HandlersV2.setOperatingMode(data);
Expand Down