Skip to content

[evm]: split IntentGatewayV2 into delegatecall modules - #1265

Merged
seunlanlege merged 1 commit into
mainfrom
seun/intent-gateway-modules
Sep 15, 2026
Merged

seunlanlege merged 1 commit into
mainfrom
seun/intent-gateway-modules

Conversation

@seunlanlege

Copy link
Copy Markdown
Member

Closes #1262

Why

IntentGatewayV2 sat 209 bytes under the EIP-170 limit, with #980 and #1259 needing room. The implementation now delegatecalls its bodies to two separately deployed modules. The proxy, its address and the governance upgrade path are unchanged.

Layout

                     EIP712
                       |
                  IntentsBase
               /       |        \
  IntentGatewayV2  IntrinsicIntents  ExtrinsicIntents
                       |                  |
                 IntrinsicModule    ExtrinsicModule
  • IntentGatewayV2 is IntentsBase, HyperApp, ReentrancyGuardTransient, Initializable. It keeps every entry point and its guards, placeOrder, select, the shared validation of fillOrder and cancelOrder, initialize, migrate() and the views. fillOrder and cancelOrder delegatecall typed module functions. onAccept and onGetResponse forward msg.data after onlyHost.
  • IntrinsicModule is IntrinsicIntents: same-chain fill and cancel. IntrinsicIntents is untouched.
  • ExtrinsicModule is ExtrinsicIntents: cross-chain fill, both cancel routes, and the onAccept and onGetResponse handlers with governance and Execute. ExtrinsicIntents stays a HyperApp; its diff is virtual on the two callbacks, relayer() and _setRelayer moving to IntentsBase, and Execute delegatecalling the module's own address instead of the implementation.
  • Module addresses are constructor immutables, checked for code. A module upgrade is an ordinary implementation upgrade. Modules inherit IntentsBase, declare no storage, refuse direct calls, and their reverts bubble byte for byte through a six-line assembly wrapper.

Details and the upgrade procedure are in evm/src/apps/intentsv2/README.md.

Sizes and gas

Contract Runtime bytes
IntentGatewayV2 (before) 24,367
IntentGatewayV2 14,610
ExtrinsicModule 14,535
IntrinsicModule 6,843

Per-test forge snapshot against main: fills and cancels cost 4.3k to 6.2k more gas (a cold access to the module plus re-encoding the order), settlement deliveries 2.6k to 2.9k, governance requests about 3.3k.

What changes for operators

  • setRelayer and upgradeToAndCall live only on the extrinsic module and leave the gateway ABI. Governance reaches them through execute_on_gateway exactly as before; the selectors are unchanged.
  • A relayer rotation can no longer ride in an upgrade's init data, since that data runs against the new implementation. Upgrade and rotation are two execute_on_gateway calls.
  • migrate() takes no argument and bumps VERSION from 2 to 3. This release's upgrade carries migrate() as init data; the deploy script prints the exact data for execute_on_gateway.
  • The constructor is (intrinsic, extrinsic); _owner is gone. intrinsicModule() and extrinsicModule() are new getters.
  • DeployIntentGatewayImpl and DeployIntentGateway share IntentGatewayScript: CREATE2 modules first, reused if already at their address, then the implementation. deploy.sh --mode full verifies the modules too.

Tests

  • The pre-existing intent suites pass against the split with only constructor, migrate() and ABI adaptations.
  • IntentGatewayModulesTest (10 tests): storage layouts read from the forge artifacts agree slot for slot (foundry.toml now emits storageLayout), modules refuse direct calls, the constructor rejects codeless modules, module reverts bubble verbatim (a custom error and DAI's string reason), either module can be swapped by an implementation upgrade, and the release's own upgrade moves a version-2 proxy to 3 with escrow and peers intact.
  • The live-fork test upgrades the mainnet proxy on a fork with migrate() and checks every readable piece of state.

Not in this PR

  • The SDK, simplex and indexer ABI copies still show the old constructor, _owner, migrate(address), setRelayer and upgradeToAndCall. Nothing in those packages calls them, so they stay call-compatible; regenerate them in a follow-up.
  • IIntentGatewayV2 in sdk/packages/core follows the gateway (migrate(), the two host-only functions removed) with a patch bump to 2.3.5.

@royvardhan royvardhan left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm!

@royvardhan

Copy link
Copy Markdown
Collaborator

I would still wait for another set of 👀. cc: @Wizdave97

@Wizdave97

Copy link
Copy Markdown
Member

I would still wait for another set of 👀. cc: @Wizdave97

I'll check it out

@seunlanlege

Copy link
Copy Markdown
Member Author

Would be good to merge this asap so we can unblock @royvardhan

The implementation sat 209 bytes under the EIP-170 limit. It now keeps every
entry point, its guards and the shared validation, and delegatecalls the
bodies to two separately deployed modules whose addresses are immutables:

- IntrinsicModule: same-chain fill and cancel (IntrinsicIntents, unchanged).
- ExtrinsicModule: cross-chain fill, both cancel routes, and the onAccept and
  onGetResponse handlers with governance and Execute (ExtrinsicIntents, kept
  as a HyperApp; its callbacks became virtual).

The implementation is `IntentsBase, HyperApp, ReentrancyGuardTransient,
Initializable` and inherits nothing from the intents contracts. fillOrder and
cancelOrder delegatecall typed module functions; onAccept and onGetResponse
forward msg.data. Execute now delegatecalls the extrinsic module's own
address, so setRelayer and upgradeToAndCall live only there and leave the
gateway ABI; a relayer rotation is its own execute_on_gateway call. migrate()
takes no argument and bumps VERSION 2 to 3. _owner is removed. __self,
relayer() and _setRelayer moved to IntentsBase.

Runtime sizes: implementation 14,610 (was 24,367), intrinsic 6,843,
extrinsic 14,535. Fills and cancels cost 4.3k to 6.2k more gas, settlement
deliveries 2.6k to 2.9k, governance 3.3k.

Deploy scripts share IntentGatewayScript, which deploys the modules via
CREATE2 before the implementation and prints the execute_on_gateway data.
A new suite asserts the storage layouts agree slot for slot from the forge
artifacts (foundry.toml now emits storageLayout), that modules refuse direct
calls, that the constructor rejects codeless modules, that module reverts
bubble verbatim, that either module can be swapped by an implementation
upgrade, and that the release's upgrade with migrate() moves a version-2
proxy to 3. IIntentGatewayV2 in core follows the ABI; core bumped to 2.3.5.

Closes #1262
@seunlanlege
seunlanlege force-pushed the seun/intent-gateway-modules branch from 8c23063 to 3bac107 Compare September 15, 2026 09:44
@Wizdave97

Copy link
Copy Markdown
Member

Review: fund-loss focus

I found no path that lets anyone take funds from solvers, users or the protocol. The review split into four areas:

  • delegatecall and storage mechanics
  • governance, upgrade and migration
  • fill and cancel fund flows, compared line by line against main
  • off-chain callers, gas and the deploy scripts

Nothing reached high or critical. The findings worth acting on are ways a governance or deploy mistake could lock escrow for good.

1. An implementation built with the wrong module addresses locks all escrow for good (medium: large impact, but needs an operator mistake)

evm/src/apps/IntentGatewayV2.sol: the constructor only checks that each module has code, and migrate() checks nothing.

  1. An implementation is deployed outside IntentGatewayScript, with (intrinsic, extrinsic) swapped or a stale module address.
  2. The upgrade still succeeds, because migrate() runs inside the implementation and never touches a module.
  3. From then on every onAccept/onGetResponse delegates to a contract without that function and reverts. That blocks RedeemEscrow, RefundEscrow, governance and Execute.
  4. upgradeToAndCall is now only reachable through Execute, so governance can't upgrade out of it.

Why it's new: before the split, every implementation carried upgradeToAndCall itself and so could always be upgraded again.

Mitigation: the script computes both addresses from each module's creationCode, so the scripted path is correct.

Suggested fix: have migrate() (or the constructor) call a view function on each module that identifies which module it is. Or keep upgradeToAndCall on the implementation, which is at 14,610 bytes and has room.

2. Upgrading to a wrong target with empty init data also locks the gateway (low, and not new)

  • If execute_on_gateway names IntrinsicModule or an unrelated contract, the proxy loses onAccept for good.
  • This release is protected, because the printed migrate() init data reverts against anything that isn't the gateway.
  • Later releases that ship empty init data won't have that protection. The script also now prints three addresses an operator could paste by mistake.
  • Suggested fix: in ExtrinsicIntents.upgradeToAndCall, check that the target answers extrinsicModule().

3. migrate() no longer arms the relayer gate (low, only matters if a proxy is still at version 1)

4. The deploy script always prints migrate() as the init data (low)

evm/script/IntentGatewayScript.sol: any later implementation that keeps VERSION = 3 gets init data that reverts with InvalidInitialization. Nothing is lost; the upgrade just fails until it is resent with empty data.

5. Minor

  • Gas: the extra hop slightly eats into the relayer's 5% gas buffer (tesseract/messaging/evm/src/tx.rs, gas_with_buffer). A delivery short on gas fails inside the host and stays retryable, so the only cost is wasted relayer gas.
  • Stale docs: the pallet comments in modules/pallets/intents-coprocessor/src/lib.rs and types.rs still say Execute delegatecalls the gateway's current implementation.

Checked and found safe

  • Storage layout: the gateway and both modules match slot for slot (checked with forge inspect), and _filled is still at slot 2, so the cancel storage proof is unaffected. The layout matches main.
  • Commitment: computed once in the gateway, and the module receives that same order and commitment. Validation and execution can't act on different orders.
  • msg.value: kept through the delegatecall and spent only once. Routing between the same-chain and cross-chain modules can't be forced the wrong way.
  • Guards: nonReentrant and the solver selection still apply while module code runs.
  • Direct calls to a module: fills and cancels revert on onlyDelegated. The host callbacks, setRelayer and upgradeToAndCall read host == 0 from the module's own empty storage, so everyone is refused. Modules can't hold funds.
  • Revert bubbling: _delegate can't report a module revert as success. When a callback fails, EvmHost deletes the receipt, so a message can't be marked delivered while its escrow stays locked.
  • Signatures: EIP-712 checks stay in the gateway and use the proxy address as before.
  • Upgrades: the version 2→3 upgrade and the chain of later upgrades through the module both work.
  • CREATE2 module reuse: safe, since the address is fixed by the bytecode.
  • Execute into fill or cancel functions: reachable, but only by governance, which can already install any implementation.
  • Callers outside the contracts: nothing in the SDK, simplex, tesseract or the pallets calls the removed functions, and existing gas budgets absorb the extra 3–6k.
  • Sizes: all three contracts are well under EIP-170 (confirmed with forge build --sizes).
  • Timeouts: onPostRequestTimeout and onGetTimeout behave as before.

@seunlanlege
seunlanlege merged commit cb471e1 into main Sep 15, 2026
3 checks passed
@seunlanlege
seunlanlege deleted the seun/intent-gateway-modules branch September 15, 2026 10:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[evm]: split IntentGatewayV2 into delegatecall modules

3 participants