Skip to content

feat: Counterfactual route policies#1434

Open
tbwebb22 wants to merge 34 commits into
masterfrom
taylor/counterfactual-route-policy
Open

feat: Counterfactual route policies#1434
tbwebb22 wants to merge 34 commits into
masterfrom
taylor/counterfactual-route-policy

Conversation

@tbwebb22

@tbwebb22 tbwebb22 commented May 19, 2026

Copy link
Copy Markdown
Contributor

Closes ACP-106

@grasphoper

Copy link
Copy Markdown
Collaborator

Closes ACP-106

@linear

linear Bot commented May 20, 2026

Copy link
Copy Markdown

ACP-106

@tbwebb22 tbwebb22 marked this pull request as ready for review May 20, 2026 19:28

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 54e6ffe789

ℹ️ 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".

Comment on lines +52 to +55
if (implementation == WITHDRAW_IMPL && msg.sender == cloneArgs.withdrawUser) {
_delegate(implementation, cloneArgs, params, submitterData);
return;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Block proof-path calls to the withdraw implementation

When implementation == WITHDRAW_IMPL but msg.sender != cloneArgs.withdrawUser, execution falls through to the normal proof path instead of being rejected. In this commit, WithdrawImplementation.execute no longer performs any caller authorization, so if a route policy ever includes a withdraw leaf, any caller with that proof can choose arbitrary (token,to,amount) in submitterData and sweep clone funds. Previously this was mitigated by auth inside WithdrawImplementation; now the dispatcher should explicitly reject non-escape withdraw calls to preserve that safety invariant.

Useful? React with 👍 / 👎.

@tbwebb22 tbwebb22 requested review from fusmanii and grasphoper May 20, 2026 19:45

@fusmanii fusmanii left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

first pass

Comment on lines +70 to +71
if (leafDestinationChainId != cloneArgs.destinationChainId || leafOutputToken != cloneArgs.outputToken)
revert InvalidIdentity();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

submitter passed in both params and cloneArgs kind of strange that we are checking them against each other, is there a way you think we can just have the submitter pass them once?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Well the leaves on the policy (params) can point to different destination chains and tokens. We have to ensure that the selected leaf targets the same destination as this clone is. If we don't the submitter could select a leaf that sends to the wrong destination chain or token.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

right, I was just thinking more along the lines submitter provides destinationChainId and outputToken and the function then constructs params/cloneArgs, but that might be uglier

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.

I think we can remove duplication here, see slack

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

done 62597e5

uint256 executionFee;
uint32 signatureDeadline;
bytes peripherySignature;
bytes signature;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
bytes signature;
bytes authSignature;

or something similar to distinguish it form the other signature

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

yeah good call, maybe rename it to counterfactualSignature?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

renamed to counterfactualSignature here 0c5f57d

if (sd.executionFee > 0) IERC20(inputToken).safeTransfer(sd.executionFeeRecipient, sd.executionFee);

uint256 depositAmount = sd.amount - dp.executionFee;
uint256 depositAmount = sd.amount - sd.executionFee;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

what happens if executionFee > amount? I guess the funds will just get stuck until more funds are sent

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thats right. And I think thats fine. Basically maxExecutionFee is a lower bound for how much the user should be sending through the counterfactual.

uint256 relayerFee = depositAmount > outputInInputToken ? depositAmount - outputInInputToken : 0;
uint256 totalFee = relayerFee + dp.executionFee;
uint256 totalFee = relayerFee + executionFee;
uint256 maxFee = dp.maxFeeFixed + (dp.maxFeeBps * inputAmount) / BPS_SCALAR;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

why does only SpokePool have a fixed and dynamic fee but the rest CCTP/OFT have only fixed?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

So the CCTP / OFT implementations take maxFee as a param, so we're kind of delegating that dynamic fee part to the sponsored bridging contracts

* independent `activeRoot` storage.
* @custom:security-contact bugs@across.to
*/
contract RoutePolicy is IRoutePolicy, Ownable {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Should all the leaf verification happen here? like even the leaf construction? that way when a RouterPolicy is replaced the leaf computation also gets updated

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Can you elaborate on what you mean here? I'm not sure I follow

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

mainly thinking if these lines from CounterfactualDeposit.sol can be moved here?

        bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(implementation, keccak256(params)))));
        bytes32 root = IRoutePolicy(cloneArgs.routePolicyAddress).activeRoot();
        if (!MerkleProof.verify(proof, root, leaf)) revert InvalidProof();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

yeah that definitely could work. Do you think its cleaner?
It would be a little more gas (Claude is telling me +1k gas) since we're passing more calldata in the external call

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

actually now that I think about this, its not going to work since RoutePolicy isn't upgradable so doesn't give us any benefit with moving the logic there

Comment thread contracts/periphery/counterfactual/CounterfactualDepositCCTP.sol Outdated
@tbwebb22

Copy link
Copy Markdown
Contributor Author

@codex review

grasphoper
grasphoper previously approved these changes May 21, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3fb438b6ea

ℹ️ 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".

Comment on lines +146 to +147
abi.encode(EXECUTE_CCTP_TYPEHASH, routeParamsHash, sd.executionFee, sd.signatureDeadline)
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Bind CCTP fee signature to a one-time quote nonce

The new local EIP-712 check only signs (routeParamsHash, executionFee, signatureDeadline), but the executed quote still carries a mutable sd.nonce and sd.cctpDeadline later in _depositForBurn. Because sd.nonce is not part of the signed payload, the same local signature can be reused with different periphery quotes/nonces before signatureDeadline, allowing repeated fee-authorized executions against the same clone balance instead of one intended authorization.

Useful? React with 👍 / 👎.

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.

I actually think that structHash = abi.encode(routeParams.nonce, sd.executionFee, sd.signatureDeadline can be all we need here.

Will tie the two signatures together effectively, since API commits to once nonce per unique quote

Comment on lines +143 to +144
abi.encode(EXECUTE_OFT_TYPEHASH, routeParamsHash, sd.executionFee, sd.signatureDeadline)
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Bind OFT fee signature to a one-time quote nonce

As implemented, the OFT local signature covers only (routeParamsHash, executionFee, signatureDeadline) and excludes sd.nonce/sd.oftDeadline, even though those values are consumed in the downstream quote. That means a valid local fee signature can be replayed across multiple distinct OFT quotes (new nonces) until expiry, enabling repeated executions that were not uniquely authorized per quote.

Useful? React with 👍 / 👎.

@tbwebb22

Copy link
Copy Markdown
Contributor Author

@codex review - note the changes to the IRoutePolicy interface, and the new RoutePolicyImmutableRoot implementation

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Swish!

ℹ️ 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".

@mrice32 mrice32 requested a review from droplet-rl May 25, 2026 00:55

@droplet-rl droplet-rl left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Re-reviewed against the head of taylor/counterfactual-route-policy (9d6aee9). I focused on what changed since my prior review (59e71b6): the CCTP/OFT replay mitigation, the new RoutePolicyImmutableRoot, the admin arg added to ICounterfactualImplementation, and the WithdrawImplementation defense-in-depth check.

What the new diff resolves cleanly

  • CCTP/OFT replay (OQ #2 partial close). Binding the local counterfactualSignature to nonce instead of routeParamsHash is the right shape. I verified both peripheries (SponsoredCCTPSrcPeriphery.sol:85,89 and SponsoredOFTSrcPeriphery.sol:221,98) reject duplicate nonces and mark them used, so once the periphery consumes the nonce, the local sig is also burned — no on-chain bookkeeping needed in the bridge impl itself. The replay window is genuinely closed for these two paths.
  • WithdrawImplementation defense-in-depth. The explicit if (msg.sender != admin) revert Unauthorized() covers the "policy owner accidentally adds a withdraw leaf" footgun. admin flows through the dispatcher's verified cloneArgs, so this is safe to trust here.
  • RoutePolicyImmutableRoot. Bytecode-immutable root + UUPS upgrade-as-rotation is a reasonable trade — reads become bytecode constant loads instead of SLOADs, at the cost of a small impl deploy per rotation. The interface adds the clone arg for forward compatibility with per-clone roots without a future breaking change. Good move.
  • Cross-chain consistency test. testIdenticalDay0ArgsProduceIdenticalProxyAddress now actually exercises the property (impl → proxy CREATE2 chain, then asserts an arg change cascades through to a different proxy address). My prior comment about the tautological test is closed.

Still open

  • OQ #2 for SpokePool + AdminWithdrawManager.signedWithdraw. Neither has a nonce-like field that something downstream consumes single-use, so both remain replayable within signatureDeadline if the clone is re-funded. The PR description acknowledges this. My recommendation hasn't changed: land a one-time signature-mark mapping for these two before mainnet. SignedWithdraw is the more dangerous of the pair (committed (token, to, amount) triple → re-drain to the same to on refund).
  • Single-step OwnableOwnable2Step. Still applies, now on OwnableUpgradeable. The deployment story calls a single-step transferOwnership(chainLocalMultisig) per chain (RoutePolicies.md step 4); a typo permanently bricks the policy on that chain, which then can't be recovered without breaking cross-chain address consistency. Ownable2StepUpgradeable removes that class of fat-finger.
  • AdminWithdrawManager.withdrawImpl immutable. Still vestigial in the sense I flagged — since cloneArgs.admin = manager is in the clone's argsHash for life and the manager pins its withdrawImpl, the withdraw impl is effectively immutable per-clone. Either making it owner-settable or dropping it (and having the manager pass it in per call) would eliminate the only deployment-ordering invariant left on this contract.
  • Inconsistent typehash binding. SpokePool typehash now includes address clone while CCTP/OFT bind clone only via the EIP-712 domain separator. Both are safe; the inconsistency is the only thing worth tidying up.

Drive-by on the new code

  • RoutePolicyImmutableRoot._authorizeUpgrade(address) doesn't constrain what the new impl looks like beyond onlyOwner. Documented in OQ #1. The owner can in principle deploy an impl whose activeRoot does anything; the admin escape covers fund safety regardless. Fine.
  • _disableInitializers() in the impl constructor is the right move (line 39). Test at line 52 covers it.
  • The genesis-root-must-be-identical-across-chains invariant is now load-bearing for cross-chain proxy address consistency. Worth a sanity check in CheckCounterfactualDeployments.s.sol when that's wired up: assert the proxy's current impl matches a chain-specific impl after first rotation, and that the proxy address itself matches a reference chain.

Overall: the design has tightened substantially since my first pass. With OQ #2 closed for SpokePool + the manager, and Ownable2Step on the policy, this is solidly ship-ready.

* The proxy address stays constant on each chain throughout all rotations.
* @custom:security-contact bugs@across.to
*/
contract RoutePolicyImmutableRoot is IRoutePolicy, Initializable, OwnableUpgradeable, UUPSUpgradeable {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same recommendation as my prior review, now applied to the upgradeable variant: Ownable2StepUpgradeable instead of OwnableUpgradeable. The deployment story (RoutePolicies.md step 4) has the deployer EOA calling transferOwnership(chainLocalMultisig) separately per chain — single-step transfer leaves a fat-finger trap that bricks the policy on the affected chain, and recovery requires a fresh policy at a new address, which breaks cross-chain address consistency. Two-step transfer eliminates this class of footgun cheaply.

/// (and cleanly gives single-use replay protection — once the periphery consumes the
/// nonce, the local sig can never be replayed).
bytes32 public constant EXECUTE_CCTP_TYPEHASH =
keccak256("ExecuteCCTP(bytes32 nonce,uint256 executionFee,uint32 signatureDeadline)");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Confirmed this is now safe against in-deadline replay: SponsoredCCTPSrcPeriphery.sol:85,89 rejects duplicate nonces, so binding the local sig to nonce transitively closes the replay window. Nice idea — no extra storage in the impl. (The comment block already explains this; flagging for reviewers' confidence.)

/// (and cleanly gives single-use replay protection — once the periphery consumes the
/// nonce, the local sig can never be replayed).
bytes32 public constant EXECUTE_OFT_TYPEHASH =
keccak256("ExecuteOFT(bytes32 nonce,uint256 executionFee,uint32 signatureDeadline)");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same as CCTP — SponsoredOFTSrcPeriphery.sol:221,98 consumes nonces single-use, so the nonce-binding here closes the local-sig replay too. Both peripheries verified.


/// @notice EIP-712 typehash for execute deposit signature verification.
/// @notice EIP-712 typehash binding the signature to (clone, leaf, runtime fields).
bytes32 public constant EXECUTE_DEPOSIT_TYPEHASH =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Still replayable in deadline (OQ #2). Unlike CCTP/OFT, the SpokePool path has no downstream nonce consumption to lean on, so the routeParamsHash-bound sig remains valid against the same clone until signatureDeadline — a refund of inputAmount within that window enables a second deposit. Strong recommend either (a) adding a bytes32 nonce to the typehash + a mapping(bytes32 => bool) in clone storage (ERC-7201 to avoid colliding with other impls), or (b) committing block.timestamp lower-bound the signer must move forward for each subsequent sig. (a) is the cleaner fit for the broadcast-and-pick-up executor model.

* @dev The signer fixes the recipient (`to`) in the EIP-712 message; the caller cannot redirect.
*/
function signedWithdrawToUser(
function signedWithdraw(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Still replayable in deadline (OQ #2). This path is the more dangerous of the two open replays — the signature commits a fixed (token, to, amount) triple, so any refund of amount during the deadline window enables a second drain to the same to with no further coordination. A regular mapping(bytes32 sigHash => bool used) in the manager's storage (no ERC-7201 since this contract isn't delegatecalled) checked + set after ECDSA.recover is the minimal fix.

keccak256("SignedWithdraw(address depositAddress,address token,address to,uint256 amount,uint256 deadline)");

/// @notice Canonical `WithdrawImplementation` address (passed to the dispatcher's escape path).
address public immutable withdrawImpl;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same observation as my prior review: this immutable plus the fact that cloneArgs.admin = address(this) is itself in the clone's argsHash for life effectively pins the withdraw implementation per-clone. To rotate the withdraw impl you'd need a new manager and a redeploy of every clone (which changes their addresses and defeats the address-persistence goal). Making this owner-settable, or moving it to a per-call argument, removes that constraint. Not blocking, but it costs nothing structurally and unlocks future flexibility.

bytes32 public constant EXECUTE_DEPOSIT_TYPEHASH =
keccak256(
"ExecuteDeposit(uint256 inputAmount,uint256 outputAmount,bytes32 exclusiveRelayer,uint32 exclusivityDeadline,uint32 quoteTimestamp,uint32 fillDeadline,uint32 signatureDeadline)"
"ExecuteDeposit(address clone,bytes32 routeParamsHash,uint256 inputAmount,uint256 outputAmount,bytes32 exclusiveRelayer,uint32 exclusivityDeadline,uint32 quoteTimestamp,uint32 fillDeadline,uint32 signatureDeadline,uint256 executionFee)"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Mild inconsistency with CCTP/OFT typehashes — those bind clone implicitly via the EIP-712 domain separator alone, while this one redundantly includes address clone in the struct. Both are safe; consider dropping clone here and relying on the domain separator (matches the CCTP/OFT pattern and shortens the typehash).

/// @dev Required by UUPS — only the owner can upgrade the implementation. Upgrading is the
/// mechanism by which the active root changes: the owner deploys a new implementation
/// carrying the new root and points the proxy at it.
function _authorizeUpgrade(address) internal override onlyOwner {}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Worth verifying as part of the deployment runbook: a chain whose policy proxy points at an impl whose code is somehow lost (selfdestruct of the impl, etc.) would be unable to upgradeToAndCall because the proxy's _dispatchUpgradeToAndCall selfdelegate calls into the current impl's upgradeTo via UUPS. The impls here have no selfdestruct, so this is theoretical, but the CheckCounterfactualDeployments.s.sol (still pending) should at least assert each chain's proxy has a non-zero current impl with the expected code hash for the deployed impl revision.

@mrice32 mrice32 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Overall looks great, just a few comments!

bytes32 outputToken;
uint256 destinationChainId;
bytes32 recipient;
address admin;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Who do we intend the admin to be set to? Some admin multisig or the user?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

so this is intended to be set to the AdminWithdrawManager contract - which in turn allows itsdirectWithdrawer (should be a multsig) to call directWithdraw, or for an executor to call signedWithdraw

Comment on lines +59 to +60
cloneArgs.outputToken,
cloneArgs.destinationChainId,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can we drop these and let the leaf validate that it knows how to handle this particular outputToken and destinationChainId (via routeParams or otherwise)?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

* @custom:security-contact bugs@across.to
*/
contract CounterfactualDepositCCTP is ICounterfactualImplementation {
contract CounterfactualDepositCCTP is ICounterfactualImplementation, EIP712 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Does this only work with chains where destination SponsoredBridging contracts are deployed?

I'm assuming we don't have a way to do a vanilla CCTP leaf, right? I could see that being nice for destination Solana, for instance.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yep thats right, this is specific to our SponsoredBridging contracts. And yeah for a vanilla CCTP leaf we would need a new implementation for that specifically

* @custom:security-contact bugs@across.to
*/
contract CounterfactualDepositOFT is ICounterfactualImplementation {
contract CounterfactualDepositOFT is ICounterfactualImplementation, EIP712 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Similar to CCTP, we don't have a way to talk to non sponsored bridging OFT contracts, right?

I'm assuming there's nothing stopping us from doing that in the future, right?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Correct, and yeah no reason we couldn't add an implementation for this in the future

bytes calldata /* routeParams */,
bytes calldata submitterData
) external payable {
if (msg.sender != admin) revert Unauthorized();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This would be the AdminWithdrawManager, right? Does that mean that we would need to have a distinct AdminWithdrawManager contract deployed for each counterfactual address that has their EOA encoded? Or am I missing something?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

And if it doesn't, then does that mean the signed withdrawer (i.e. the API) can authorize a withdrawal to any address (since the unique refund address isn't known/stored onchain)?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This would be the AdminWithdrawManager, right? Does that mean that we would need to have a distinct AdminWithdrawManager contract deployed for each counterfactual address that has their EOA encoded? Or am I missing something?

so I'm envisioning that there would generally be a global AdminWithdrawManager instance per chain that all counterfactual clones point to as their admin

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

does that mean the signed withdrawer (i.e. the API) can authorize a withdrawal to any address (since the unique refund address isn't known/stored onchain)?

thats correct. Do you think this is placing to much trust in the API? And that the user's refund address should be store onchain?

@tbwebb22

Copy link
Copy Markdown
Contributor Author

@codex review the last commit that restructured the merkle tree and moved outputToken and destinationChain into routeParams

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Nice work!

ℹ️ 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".

@tbwebb22

Copy link
Copy Markdown
Contributor Author

@codex review the last commit that updated the withdraw flow

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Breezy!

ℹ️ 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".

droplet-rl
droplet-rl previously approved these changes May 25, 2026

@droplet-rl droplet-rl left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Re-reviewed against af3fe4d. Two new commits since my last pass: the withdraw-flow rewrite and the dispatcher-leaf simplification (outputToken/destinationChainId moved out of the leaf, into per-impl routeParams with opt-in binding via CloneIdentity.enforce).

This iteration is a notable security improvement. The two design changes work together to materially shrink the trust footprint of every authority in the system:

Why this is meaningfully better

WithdrawImplementation now forces destination to userAddress. The destination is no longer chosen at call time — it's the clone-bound userAddress, period. This collapses several of my prior concerns at once:

  • SignedWithdraw replay (OQ #2) is now benign: a replayed signature still routes to the user, not to an attacker. The "blast radius bounded" note in the open-questions section is correct.
  • A compromised directWithdrawer can force withdrawals but cannot redirect them.
  • A compromised signer can force withdrawals but cannot redirect them.
  • The trust model collapses to "when and how much," never "where." Clean.

AdminWithdrawManager no longer pins withdrawImpl. Passed per-call now, with the signed typehash committing withdrawImpl so a compromised submitter can't substitute. The construction-order constraint I flagged is replaced with a clean dependency: manager deploys first, impl deploys second pointing at the manager. My prior concern about the immutable being vestigial / pinning impl per-clone is closed.

Identity binding moved from dispatcher to per-impl CloneIdentity.enforce. I went back and forth on this trade. The dispatcher's leaf is now simpler ((impl, keccak256(routeParams))), and each impl declares whether it needs identity binding. The footgun is real — a future impl author who needs binding and forgets to call CloneIdentity.enforce ships a vulnerability — but the convention is explicit in the lib's natspec and in the per-impl natspec, and the three current impls that need binding all call it. Auditors can grep for CloneIdentity.enforce to verify coverage on future additions. Acceptable trade.

The SpokePoolRouteParams natspec now spells out why binding is required there (the stableExchangeRate per-pair assumption). Important context that was implicit before; making it explicit reduces the chance a future maintainer drops the enforcement.

What's still open (all flagged, none blocking)

  • RoutePolicyImmutableRoot still uses single-step OwnableUpgradeable. I flagged this twice. The deployment story still has the deployer EOA single-step-transferring to a per-chain multisig; a typo bricks the policy on that chain, and recovery breaks cross-chain address consistency. Ownable2StepUpgradeable removes that class of fat-finger for free. Not blocking — it's an operational risk you can offset with paranoid deployment scripting + reviews — but it's the cheapest hardening left on the table. AdminWithdrawManager's single-step Ownable is less load-bearing (per-chain manager, no cross-chain invariant) but worth flipping in the same pass for consistency.
  • OQ #2 SpokePool replay is the last unresolved replay window, and it too is now mostly benign (the replay funds go to cloneArgs.recipient, which is the legitimate user destination). Worth still doing the one-time-marks fix before mainnet — "user receives their own money again" is still a UX/liveness footgun (someone can involuntarily move funds out of a clone the user wanted to keep funded) — but no longer security-load-bearing.
  • OQ #1 owner-compromise / timelock split is the genuine remaining design question. Not for this PR.
  • SpokePool typehash redundantly includes address clone while CCTP/OFT bind clone only via the domain separator. Pure cosmetic at this point.

Process-level observation

The implementation plan's Scripts checklist (RoutePolicies.md) is still entirely unchecked. The deploy/check/rotate scripts (DeployRoutePolicy.s.sol, RotateRoutePolicyRoot.s.sol, CheckCounterfactualDeployments.s.sol, etc.) aren't in this PR. That's fine — splitting into a follow-up keeps this PR reviewable — but make sure the follow-up lands before any testnet deployment, and that CheckCounterfactualDeployments.s.sol asserts current-impl code hashes on every chain (the cross-chain proxy address consistency invariant depends on no chain having upgraded prematurely).

A defensive item for the security-review checklist: every new ICounterfactualImplementation either calls CloneIdentity.enforce or has an explicit comment justifying why identity binding is unnecessary (e.g. WithdrawImplementation because destination is forced to userAddress). Catches the future-maintainer footgun cheaply.

Approving. Nice work tightening this through three review cycles.

bytes calldata /* routeParams */,
bytes calldata submitterData
) external payable {
if (msg.sender != admin && msg.sender != userAddress) revert Unauthorized();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is the right shape. Forcing the destination to userAddress instead of a caller-supplied to collapses three separate trust concerns (signer compromise, directWithdrawer compromise, signature replay) into a single benign outcome: the user receives their own funds. Strong improvement over the prior (token, to, amount) triple.

bytes32 public constant SIGNED_WITHDRAW_TYPEHASH =
keccak256("SignedWithdraw(address depositAddress,address token,uint256 amount,uint256 deadline)");
keccak256(
"SignedWithdraw(address depositAddress,address withdrawImpl,address token,uint256 amount,uint256 deadline)"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Good that withdrawImpl is committed in the typehash — a compromised submitter can't substitute a malicious impl on a valid signature. Combined with the policy's merkle gate (only policy-authorized impls can be reached) and the impl's msg.sender == admin check (only the manager or user can trigger this impl), the signer's authority is genuinely "when + how much" only. The destination question is resolved structurally inside the impl.

* @param routeDestinationChainId `destinationChainId` committed inside `routeParams`.
* @param cloneDestinationChainId `destinationChainId` forwarded by the dispatcher from `cloneArgs`.
*/
function enforce(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The opt-in model relies on impl authors remembering to call this. The current three impls that need binding (SpokePool, CCTP, OFT) all do, and the natspec spells out the contract. Worth adding to the security-review checklist: every new ICounterfactualImplementation either calls CloneIdentity.enforce or has an explicit comment justifying why identity binding is unnecessary (e.g. WithdrawImplementation because destination is forced to userAddress).

if (msg.sender != cloneArgs.userAddress) {
// Verify merkle proof against the policy's active root. The leaf commits only
// `(implementation, keccak256(routeParams))`
bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(implementation, keccak256(routeParams)))));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The leaf format change is a real design pivot — worth a paragraph in the changelog (or PR description) calling out that off-chain leaf builders must be updated to match the new commitment keccak256(impl, keccak256(routeParams)) (without outputToken / destinationChainId at the dispatcher level). Easy to miss when porting indexers / SDK encoders.

/// @notice EIP-712 typehash for execute deposit signature verification.
/// @notice EIP-712 typehash binding the signature to (clone, leaf, runtime fields).
bytes32 public constant EXECUTE_DEPOSIT_TYPEHASH =
keccak256(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pure cosmetic: typehash still binds address clone explicitly while CCTP/OFT typehashes bind clone implicitly via the EIP-712 domain separator. Both are safe — the inconsistency is the only thing left to tidy. Drop clone here for symmetry, or add it to CCTP/OFT, but pick one.

@mrice32

mrice32 commented May 25, 2026

Copy link
Copy Markdown
Contributor

@codex review

@mrice32 mrice32 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM!

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Already looking forward to the next diff.

ℹ️ 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".

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.

5 participants