Skip to content

feat: Counterfactual route policies#1434

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

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

Conversation

@tbwebb22
Copy link
Copy Markdown
Contributor

@tbwebb22 tbwebb22 commented May 19, 2026

Open Questions:

  1. How should withdraw be structured? Two axes: where auth lives (dispatcher's structural escape vs. policy merkle-proof) and where transfer logic lives (separate
    WithdrawImplementation vs. inlined in dispatcher). Currently "structural + separate" — but the impl's address is pinned by WITHDRAW_IMPL (not actually swappable) and
    two of three execute args are unused. Three options:

a) Structural + separate (current): strongest fund guarantee — withdraw works regardless of policy state. Costs: extra contract, dispatcher immutable,
deployment-ordering invariant, vestigial interface conformance on the impl.
b) Structural + inlined: same fund guarantee, drops the impl, immutable, delegatecall hop, and unused-arg conformance. Costs: dispatcher gains a transfer path; "every
execute goes through an impl" abstraction is broken.
c) Policy-committed: simplest dispatcher (no escape, no immutable). Costs: policy owner becomes load-bearing for fund safety; the current impl is unsafe to expose
this way (submitterData-controlled to would let any caller drain), so the path also requires committing to/token/amount into the leaf — a redesign, not a drop-in.

  1. Signature replay within the deadline. None of the four EIP-712 messages (SpokePool/CCTP/OFT execute, AdminWithdrawManager SignedWithdraw) carry a nonce or
    one-time mark. Replay is bounded only by deadline and clone balance — a refund of the signed amount lets the same signature drain again. SignedWithdraw is worst-case
    (committed (token, to, amount) triple → re-drains the same to). Options:

a) Operational only (current): short deadlines + signer-side bookkeeping. Zero overhead, fully reliant on signer discipline.
b) Monotonic nonce: forces ordered consumption — bad fit for permissionless executors.
c) One-time signature marks (recommended): mapping(bytes32 sigHash => bool used), ~1 SSTORE per execute, out-of-order consumption. Shared ERC-7201 mixin across
CCTP/OFT/SpokePool; normal storage in AdminWithdrawManager.

Closes ACP-106

@grasphoper
Copy link
Copy Markdown
Collaborator

Closes ACP-106

@linear
Copy link
Copy Markdown

linear Bot commented May 20, 2026

ACP-106

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

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

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
Copy link
Copy Markdown
Contributor

@fusmanii fusmanii left a comment

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
Comment thread contracts/periphery/counterfactual/CounterfactualDepositCCTP.sol Outdated
Comment thread contracts/periphery/counterfactual/CounterfactualDepositCCTP.sol Outdated
Comment thread contracts/interfaces/ICounterfactualImplementation.sol
@tbwebb22 tbwebb22 requested a review from droplet-rl May 21, 2026 22:33
Copy link
Copy Markdown

@droplet-rl droplet-rl left a comment

Choose a reason for hiding this comment

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

Solid PR — the spec doc reads cleanly and the contract changes track it 1:1. I went through the dispatcher, factory, RoutePolicy, all three bridge impls, WithdrawImplementation, AdminWithdrawManager, and the tests. No blocking bugs found. The two open questions you flagged are the right ones to focus on; opinions inline.

On the open questions

1. Withdraw architecture — recommend (a) structural + separate (current), with a tweak.
The fund-recovery guarantee is the load-bearing property here, and (a) is the cheapest way to preserve it without coupling the dispatcher to transfer logic. The vestigial address(this), recipient, outputToken, destinationChainId args on WithdrawImplementation.execute are a minor cost relative to keeping the dispatcher's "every execute goes through an impl" invariant clean.

That said, the withdrawImpl immutable in AdminWithdrawManager is mostly cosmetic: since admin can call any impl through the dispatcher's escape, the immutable doesn't actually pin anything. It does make the manager's two paths slightly less flexible (can't migrate to a new withdraw impl without redeploying the manager — and since cloneArgs.admin = manager is itself immutable in the clone's argsHash, that effectively pins withdrawImpl per-clone for life). Consider whether you'd rather make it mutable (owner-set) or drop the field entirely and have callers pass the impl. (c) policy-committed is correctly ruled out for the reason you noted.

2. Signature replay — strong recommend the one-time signature marks (option c) before mainnet.
The replay surface is real and asymmetric across the four paths:

  • SignedWithdraw is the worst — committed (token, to, amount) triple → a single refund of amount lets the same sig drain again to the same to, no further coordination.
  • CCTP/OFT — replayable if the clone is refunded to ≥ sd.amount within the deadline.
  • SpokePool — same shape; refund-then-redeposit is the attack.

The "rely on signer-side bookkeeping + short deadlines" path puts the entire safety property in a single off-chain process. A leaked or buffered signature inside its deadline is exploitable with zero on-chain protection. ~1 SSTORE per execute is cheap insurance, and the ERC-7201 mixin for the three bridge impls is a clean ~30 LOC. I'd push to land this in this PR rather than ship the design with the known footgun.

Other observations

A few smaller things worth considering — none blocking.

  • RoutePolicy uses single-step Ownable. The deployment story has a deployer EOA calling transferOwnership(chainLocalMultisig) per chain. A typo there permanently bricks the policy on that chain (no way back — and the address is supposed to be uniform cross-chain, so recovering means deploying a new policy at a new address and re-rooting every clone, breaking address consistency for affected chains). Ownable2Step shifts that risk meaningfully without adding much complexity.

  • Inconsistent EIP-712 binding of clone. CounterfactualDepositSpokePool.EXECUTE_DEPOSIT_TYPEHASH includes address clone as a struct field; CCTP / OFT / SignedWithdraw rely on the EIP-712 domain separator's verifyingContract alone. Both are safe (domain separator already binds address(this)), but the inconsistency is a future-confusion vector. Either pick one and apply it everywhere, or note the asymmetry in the impl comments.

  • Test for cross-chain address consistency is tautological. RoutePolicy.t.sol lines 108–126 compute computeCreate2Address(...) twice with identical inputs and assert equality — that's a no-op. The interesting property is "same (deployer, salt, codeHash) across chains where only block.chainid differs gives the same address" or "different deployer addresses give different addresses." Worth either fixing or dropping.

  • updateRoot(bytes32(0)) is unguarded. Open Question #1 covers the broader compromise mitigation, but worth noting the specific footgun: an owner can brick the policy in one tx. Admin escape covers fund recovery, so this is a liveness issue rather than a safety issue. A min-non-zero check would catch fat-fingers cheaply.

  • AdminWithdrawManager.setSigner / setDirectWithdrawer accept address(0). OZ ECDSA v5 reverts on signatures recovering to address(0), so no forgery risk — just a liveness footgun.

  • RootUpdated(bytes32 newRoot) is not indexed. Indexers can filter on it regardless, but indexed makes per-root subscriptions cheaper. Drive-by suggestion.

  • CounterfactualDeposit.execute is payable and msg.value carries through the delegatecall. Fine for OFT (LayerZero fees) and native SpokePool deposits; just confirming this is intentional — the dispatcher itself does no msg.value accounting and trusts each impl to handle it. (It does.)

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

Choose a reason for hiding this comment

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

Consider Ownable2Step instead of Ownable. The deployment flow has the deployer EOA calling transferOwnership(chainLocalMultisig) separately per chain (per the spec and Deployment section of RoutePolicies.md); single-step transfer means a typo or wrong-address transfer permanently bricks the policy on that chain. Recovery would require deploying a new RoutePolicy at a new address, which breaks the cross-chain address-consistency invariant for the affected chain. Ownable2Step requires the multisig to acceptOwnership() and eliminates this class of fat-finger.

}

/// @inheritdoc IRoutePolicy
function updateRoot(bytes32 newRoot) external onlyOwner {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

updateRoot(bytes32(0)) is allowed and bricks the policy in one tx (admin escape still recovers funds, so this is liveness, not safety). The PR's Open Question #1 already covers the broader compromise mitigation; if you want the cheapest possible fat-finger guard without committing to a timelock, an explicit if (newRoot == bytes32(0)) revert here would catch it. Note that this would also block the legitimate "freeze the policy" use case — your call.

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

Choose a reason for hiding this comment

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

withdrawImpl is immutable and cloneArgs.admin = address(this) is committed in the clone's argsHash for life. Together that effectively pins the withdraw implementation per clone — to migrate to a fixed-up withdraw impl, you'd have to deploy a new manager and redeploy every clone (different argsHash → different CREATE2 address). Worth either (a) making this an owner-settable field, or (b) dropping it and having directWithdraw / signedWithdraw take the impl as a parameter. Option (b) costs ~1 calldata word per call and removes the only deployment-ordering invariant for this contract.

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

Choose a reason for hiding this comment

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

Option (c) one-time signature marks (per the PR description) is the right fix here — the EIP-712 message commits (depositAddress, token, to, amount, deadline) with no nonce or used-flag, so a refund of amount during the deadline window enables the same (to, amount) drain a second time. This path is the most replay-prone of the four. For the manager specifically, a regular mapping(bytes32 sigHash => bool used) in normal storage (no ERC-7201 needed since the manager isn't delegatecalled) is the minimal fix — set the flag after ECDSA.recover succeeds.

/// @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

Choose a reason for hiding this comment

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

The typehash includes address clone as a struct field; CCTP / OFT typehashes rely on the EIP-712 domain separator (verifyingContract = address(this) = clone) alone. Both are equivalent for replay safety, but the inconsistency between the three bridge impls is a future-confusion vector. Either drop clone here (domain separator already binds it) or add the field to CCTP/OFT for symmetry. Mild preference for dropping — the typehash gets shorter and the binding lives in exactly one place (the domain separator).

* @param depositAmount Amount to deposit after deducting the execution fee.
*/
function _depositForBurn(CCTPDepositParams memory dp, CCTPSubmitterData memory sd, uint256 depositAmount) private {
function _verifySignature(bytes32 routeParamsHash, CCTPSubmitterData memory sd) private view {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Same replay concern as AdminWithdrawManager.signedWithdraw and the other bridge impls: the EIP-712 sig is reusable until signatureDeadline if the clone is re-funded with ≥ sd.amount of the burn token during that window. The shared ERC-7201 SignatureMarks mixin you described in Open Question #2 is the cleanest fix — adding if (used[sigHash]) revert; used[sigHash] = true; here after recover would shut this path. Worth landing in the same PR rather than shipping with the known footgun.

* @param depositAmount Amount to deposit after deducting the execution fee.
*/
function _deposit(OFTDepositParams memory dp, OFTSubmitterData memory sd, uint256 depositAmount) private {
function _verifySignature(bytes32 routeParamsHash, OFTSubmitterData memory sd) private view {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Same replay concern as CCTP — (paramsHash, executionFee, signatureDeadline) plus the clone-bound domain separator is sufficient to bind identity, but nothing prevents a second execute with the same sig during the deadline window if the clone is re-funded. Recommend the shared SignatureMarks ERC-7201 mixin per Open Question #2.


// --- Cross-chain address consistency: identical constructor args → identical address ---

function testIdenticalArgsProduceIdenticalAddress() public {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This test is tautological — computeCreate2Address(salt, codeHash, address(this)) returns the same value when called twice with identical inputs by construction. The interesting cross-chain consistency property (constructor args identical across chains → same address regardless of block.chainid) is implicit in how CREATE2 works (it doesn't use chainid) but isn't actually exercised here. Either rewrite to vary an input that would matter on different chains, or drop this test and rely on the production-side CheckCounterfactualDeployments.s.sol for cross-chain verification.

*/
interface IRoutePolicy {
/// @notice Emitted on every successful root update.
event RootUpdated(bytes32 newRoot);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Drive-by: event RootUpdated(bytes32 indexed newRoot) makes per-root subscriptions cheaper for indexers. Non-blocking.

@tbwebb22
Copy link
Copy Markdown
Contributor Author

@codex review

grasphoper
grasphoper previously approved these changes May 21, 2026
Copy link
Copy Markdown

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

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 👍 / 👎.

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.

4 participants