feat: counterfactual enumerated routes#1438
Conversation
| if (newRoot == merkleRoot) revert NoOpMigration(); | ||
|
|
||
| bytes32 identityHash = abi.decode(Clones.fetchCloneArgs(address(this)), (bytes32)); | ||
| bytes32 metaLeaf = keccak256(bytes.concat(keccak256(abi.encode(identityHash, newRoot)))); |
There was a problem hiding this comment.
Seems like we can just use address(this) as the identity?
|
@droplet-rl review |
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8d37606981
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| function initialize(bytes32 initialRoot) external { | ||
| if (merkleRoot != bytes32(0)) revert AlreadyInitialized(); | ||
| if (initialRoot == bytes32(0)) revert InvalidInitialRoot(); | ||
| merkleRoot = initialRoot; |
There was a problem hiding this comment.
Prevent initializing the dispatcher implementation itself
initialize is permissionless and only checks merkleRoot == 0, so anyone can initialize the standalone dispatcher contract (not just clones) and then call execute against an attacker-chosen merkle root. Because execute now reads merkleRoot from storage instead of clone immutables, this makes the implementation address itself executable and allows draining any ETH/ERC20 mistakenly sent there via a crafted leaf (e.g., using WithdrawImplementation). The previous design implicitly prevented this by requiring clone args during execute; this regression should be blocked by rejecting direct use of the implementation instance (or restricting initialization to factory-created clones).
Useful? React with 👍 / 👎.
droplet-rl
left a comment
There was a problem hiding this comment.
Summary
Strong design write-up in ENUMERATED_ROUTES.md and the on-chain pieces (CounterfactualMigrationRegistry, identity-keyed dispatcher with initialRoot folded into the CREATE2 salt, in-impl EIP-712 for CCTP/OFT) implement that design cleanly. The front-run analysis at deploy/initialize is sound, and using OZ's EIP712 under delegatecall is fine because the cached-domain check rebuilds the separator when address(this) != _cachedThis.
That said, I'm requesting changes — the headline issue is that no test file was updated, so this branch doesn't compile, let alone exercise any of the new behavior. A couple of smaller doc/code mismatches and one missing constructor guard noted inline.
Blocking
-
Tests broken; no coverage for new behavior. Every existing counterfactual test file still calls the old constructors:
test/evm/foundry/local/CounterfactualDeposit.t.sol:54-55—new CounterfactualDeposit()/new CounterfactualDepositFactory()test/evm/foundry/local/CounterfactualDepositCCTP.t.sol:74-76— same, plusnew CounterfactualDepositCCTP(srcPeriphery, SOURCE_DOMAIN)(now needs_signer)test/evm/foundry/local/CounterfactualDepositOFT.t.sol:91-93— same, plusnew CounterfactualDepositOFT(srcPeriphery, SRC_EID)(now needs_signer)test/evm/foundry/local/CounterfactualDepositSpokePool.t.sol:124-126test/evm/foundry/local/Tron_Counterfactual.t.sol:85-87
The new constructors require
_migrationRegistry,_dispatcher, and_signerrespectively, soforge buildfails before any test runs. Beyond the compile break, none of the surfaces introduced here have coverage:initializeguard (AlreadyInitialized,InvalidInitialRoot)migratehappy path +NoOpMigration+ stale-metaRoot rejectionCounterfactualMigrationRegistry(owner gating,transferOwnership,setMetaRootevents, zero-owner constructor revert)chainId-in-leaf cross-chain isolation (setvm.chainIdand prove a leaf is only valid on its chainId)- Same-address invariant across
vm.chainIdvalues for a fixed(identityHash, initialRoot) - Front-run protection (different
initialRoot→ different address; honest predicted address still reachable) deployAndMigrateAndExecute— both the "migrate skipped because already at target root" and "migrate executed" paths- Dynamic
executionFeefor CCTP/OFT — happy path, tampered fee →InvalidSignature, expiredsignatureDeadline→SignatureExpired,paramsHashbinding so a signature for route A can't be replayed against route B on the same impl
The spec's "Tests" section already lists most of these; please port that list into actual
.t.soland fix the existing fixtures.
Notable, non-blocking
-
Spec ↔ code disagreement on
executionFeeRecipientin the typehash. The spec (§5, "Implementations — dynamicexecutionFee") says the typehash binds, at minimum, "every signed amount/fee field,executionFeeRecipient, the route'sparamsHash, andsignatureDeadline". All three impls deliberately omitexecutionFeeRecipient(and SpokePool's natspec documents that as intentional — "submitter-chosen so any relayer can claim the fee"). The chosen design seems right; the spec line is the thing that should change. -
MIGRATION_REGISTRY"compile-time constant" in spec vs.immutablein code. The dispatcher takes_migrationRegistryas a constructor arg and stores it asimmutable. Functionally equivalent to a constant for cross-chain consistency (deployer passes the same value everywhere → identical bytecode → identical CREATE2 address), but the spec language at lines 287 and 336 still says "compile-time constant." Worth aligning the wording so future readers don't expect aconstant. -
Stale-root execution gap (already noted as Q4). The spec calls this out explicitly; flagging it so it doesn't get lost: between
setMetaRootand per-clonemigrate, revocation-style updates (signer rotation after compromise, fee tightening, withdraw-admin rotation) remain executable on unmigrated clones. The "off-chain sweeper watchesMetaRootUpdatedand migrates every affected clone" mitigation is fine as a v1 stance, but it needs to ship with the sweeper and a metaRoot-lag dashboard before this can be relied on for revocations. -
CCTP/OFT impl signatures are replay-safe within a deadline window only via balance consumption. No nonce in the impl typehash; if a user re-funds the clone before
signatureDeadline, the sameimplSignaturecan fire again. Matches the SpokePool design and the spec rationale, but worth a one-liner in the CCTP/OFT natspec so re-funding within the deadline window isn't a surprise. -
Permissionless
initializeis safe only via the factory's atomicity. Currently safe becauseClones.cloneDeterministicWithImmutableArgsdeploys from the factory and the factoryinitializes in the same tx — only the factory can land a clone at the predicted CREATE2 address. A comment to that effect oninitialize's natspec would help auditors not have to reconstruct the argument. -
delegatecallto a code-less address silently no-ops.CounterfactualDeposit.sol:76—delegatecallto an EOA or precompile returnssuccess = truewith empty returndata. The merkle proof check guaranteesimplementationmatches an admin-approved leaf, so this is admin-discipline rather than a contract bug, but a cheapif (implementation.code.length == 0) revert ...would catch an admin typo in a leaf and cost almost nothing relative to the bridge call.
|
|
||
| constructor(address _migrationRegistry) { | ||
| migrationRegistry = _migrationRegistry; | ||
| } |
There was a problem hiding this comment.
Suggest a _migrationRegistry == address(0) check here. Today nothing rejects a misconfigured dispatcher; with a zero registry, migrate would call metaRoot() on address(0), which reverts with empty returndata and bubbles up as an unhelpful failure. Since the dispatcher's address derives from this arg, a misconfigured deploy is unrecoverable — fail loud in the constructor.
There was a problem hiding this comment.
Took a look at 8e4eace — all six inline points are addressed, and the dispatcher lock is a nicer answer than what I asked for.
Dispatcher (CounterfactualDeposit.sol)
- ✅
_migrationRegistry == address(0)revert in the constructor. - ✅ Bonus:
IMPLEMENTATION_LOCK = bytes32(uint256(1))written to the dispatcher's ownmerkleRootin the constructor. This makes a directinitializeon the dispatcher hitAlreadyInitialized, and a directexecutefind no provable leaf (any leaf collision with1is cryptographically infeasible). One small note:migratestill passes if a metaProof against(dispatcherAddr, newRoot)ever existed — purely theoretical since the admin would have to publish that leaf, but worth knowing the lock isn't load-bearing formigrate. - ✅
initializenatspec now spells out the three-part safety argument (factory-only CREATE2 + atomic init +merkleRoot != 0guard + dispatcher lock). Reads cleanly.
CCTP / OFT / SpokePool natspec
- ✅ Replay-window semantics now written down. The CCTP/OFT switch to binding
nonce(instead ofparamsHash) is actually a nicer answer — single-use replay protection falls out for free once the periphery records the nonce. SpokePool's natspec correctly distinguishes itself as the deadline-bounded case (no nonce → re-funding within the deadline window re-arms the same sig).
Spec (ENUMERATED_ROUTES.md)
- ✅
MIGRATION_REGISTRY→migrationRegistryimmutable everywhere (text, Q1, D5, implementation plan). - ✅
executionFeeRecipientexplicitly called out as intentionally not bound. D9 now reflects the per-bridge route-binding choice (paramsHashfor SpokePool,noncefor CCTP/OFT).
Still outstanding (from the original review): the test files haven't been updated, so forge build still fails on the old constructor signatures (new CounterfactualDeposit() etc.) across all five .t.sol fixtures, and there's still no coverage for initialize/migrate/the registry/cross-chain leaf isolation/deployAndMigrateAndExecute/dynamic executionFee. That's the gate I'd still want to see before this lands — happy to re-review once it's in.
|
|
||
| - New constant per impl: `address public immutable signer` (same configuration mechanism as today's SpokePool impl). | ||
| - EIP-712 domain uses `address(this)` (the clone) → cross-clone replay prevention; no nonce needed (deadline + token-balance consumption bound the replay window). | ||
| - Typehash binds, at minimum: every signed amount/fee field, `executionFeeRecipient`, the route's `paramsHash`, and `signatureDeadline`. Binding `paramsHash` prevents the cross-leaf attack flagged in `DESIGN_COMPARISON.md` §"Per-bridge considerations" if a clone ever ends up with multiple leaves on the same impl. |
There was a problem hiding this comment.
This bullet says the typehash binds executionFeeRecipient, but none of the three impls actually bind it (intentionally — SpokePool's natspec explicitly calls it submitter-chosen so any relayer can earn the fee). Update the spec to drop executionFeeRecipient from the "at minimum" list, or call it out as a per-bridge choice.
|
|
||
| Notes: | ||
|
|
||
| - `MIGRATION_REGISTRY` is a compile-time constant — the deterministic address of the registry, identical on every chain. |
There was a problem hiding this comment.
MIGRATION_REGISTRY is described here (and in step 2 of the implementation plan, line 336) as a compile-time constant. The actual dispatcher uses a constructor-set immutable. Functionally equivalent for the cross-chain-address argument, but worth aligning the wording so reviewers don't go hunting for a constant.
| CCTPDepositParams memory dp = abi.decode(params, (CCTPDepositParams)); | ||
| CCTPSubmitterData memory sd = abi.decode(submitterData, (CCTPSubmitterData)); | ||
|
|
||
| if (block.timestamp > sd.signatureDeadline) revert SignatureExpired(); |
There was a problem hiding this comment.
Consider documenting in the natspec (or near the deadline check) that the impl signature replay window is bounded by signatureDeadline and balance consumption, but not by a nonce — so if the clone is re-funded before the deadline, the same implSignature can fire again. This matches the design rationale in ENUMERATED_ROUTES.md D8, but it's currently only visible to readers who've read the design doc.
There was a problem hiding this comment.
@droplet-rl For CCTP and OFT, the signature is single use because it is bound to the nonce of the periphery signature
There was a problem hiding this comment.
Confirmed — looking at the current CounterfactualDepositCCTP.sol (after fdb5ad45), the typehash is ExecuteCCTP(bytes32 nonce,uint256 executionFee,uint32 signatureDeadline), so the local sig is bound to the same nonce the SponsoredCCTPSrcPeriphery consumes. Once the periphery records the nonce, both sigs are permanently unreplayable. Same story for OFT. The natspec on line 109-111 already states this cleanly ("single-use replay protection through periphery nonce consumption").
My original comment was written against the pre-cleanup diff that bound paramsHash only — the fix in fdb5ad45 made the concern moot for CCTP/OFT. SpokePool is the remaining deadline-only case, and its natspec already calls that out explicitly. Nothing further needed here.
| if (initialRoot == bytes32(0)) revert InvalidInitialRoot(); | ||
| merkleRoot = initialRoot; | ||
| emit Initialized(initialRoot); | ||
| } |
There was a problem hiding this comment.
Suggest extending the natspec on initialize to spell out the safety argument: initialize is external with no caller check, but the only address that can deploy a clone at the predicted CREATE2 address is the factory, which calls initialize atomically in the same tx (so the merkleRoot != 0 guard then rejects any subsequent call). Without that argument written down, this reads as a missing access control.
No description provided.