Skip to content

feat: EIP-7702 account, proxy delegation, and ERC-1271 without a Safe - #60

Closed
mfw78 wants to merge 2 commits into
feat/admin-commitmentsfrom
feat/eip7702-delegate
Closed

feat: EIP-7702 account, proxy delegation, and ERC-1271 without a Safe#60
mfw78 wants to merge 2 commits into
feat/admin-commitmentsfrom
feat/eip7702-delegate

Conversation

@mfw78

@mfw78 mfw78 commented Aug 2, 2026

Copy link
Copy Markdown

Stacked on #59. Merge that first.

An EIP-7702 delegate so an EOA can place conditional orders without deploying a Safe. Nothing is deployed by this PR.

The premise

ComposableCow never calls a Safe method. isValidSafeSignature takes one only as a typed address, and _auth reads the registry's own roots and singleOrders, so any address can own an order.

The repo already assumed this. test/ComposableCow.base.t.sol:318 has TestNonSafeWallet, an ERC1271Forwarder with no Safe, and ComposableCow._buildSignature (src/ComposableCow.sol:560) has a catch branch commented "Assume a non-Safe wallet" emitting exactly the payload the forwarder decodes. This contract is that branch's intended client.

Two contracts plus a proxy

Contract Role
Account7702 generic: ERC-7821 batching plus ERC-7739 ERC-1271. No CoW or ComposableCow references. Usable as any 7702 implementation
CowAccount7702 extends it with the order-payload signature shape
Solady EIP7702Proxy deployed unmodified as the delegation target

Batching is ERC7821 at its defaults. Empty opData requires msg.sender == address(this), which under 7702 only a self-sent transaction satisfies; non-empty opData reverts. No relayed path, so no nonce to maintain: the EOA's account nonce sequences the batch.

Signatures try ERC-7739 nested EIP-712 first, then fall through to the order payload. The two accounts use different EIP-712 domains, so a signature for one is not valid for the other.

Why the proxy

Delegating to the proxy rather than straight to an implementation means the implementation can be replaced without the EOA signing a second EIP-7702 authorisation. test_proxy_UpgradeChangesBehaviourWithoutRedelegating upgrades the proxy and asserts the EOA's behaviour changes while its delegation designator does not. Mutation-checked: upgrading to the same implementation makes that test fail, so it detects the swap rather than passing either way.

The trade, stated plainly. The proxy adds a second authority: whoever holds the proxy admin can change the code running on every delegated EOA that has not pinned its own implementation, so the account's security becomes the EOA key and the admin key rather than the EOA key alone. It also writes ERC-1967 slots into the EOA's storage, which the earlier stateless delegate avoided, and those slots persist if the EOA is later re-delegated elsewhere. Worth a multisig for the admin on anything real.

Three Solady defaults overridden, each non-obvious

  • _erc1271CallerIsSafe to false. Solady skips ERC-7739 nesting entirely for MulticallerWithSigner, which would make any raw signature the EOA ever produced over any 32-byte value a valid ERC-1271 signature for that caller. No multicaller integration here, so it is pure attack surface on a contract holding the EOA's whole balance. Found by adversarial review, not by me.
  • _erc1271IsValidSignatureNowCalldata recovers directly, requiring exactly 65 bytes with low s. The SignatureCheckerLib default would staticcall isValidSignature on this account (the delegation designator gives address(this) nonzero code) and re-enter instead of recovering. The length and low-s bounds give each accepted digest a single valid encoding, so a consumer keying a replay guard on signature bytes is not bypassable.
  • _erc1271IsValidSignatureViaRPC to false. The default burns the whole gas budget on failed validation when tx.gasprice == 0, which is every foundry test and most eth_call simulations.

What is load-bearing and looks incidental

Solady's Receiver, which ERC7821 brings, answers only the ERC-721 and ERC-1155 receiver selectors and reverts FnSelectorNotRecognized otherwise. _buildSignature probes the owner with supportsInterface, and the payload the forwarder decodes is produced only from the catch branch, so that probe MUST revert. A fallback that answered it would break settlement silently. For the same reason the delegate declares no supportsInterface.

The one change to an audited contract

ERC1271Forwarder.isValidSignature, two tokens: bytes memory to bytes calldata, plus virtual. Same selector, same external ABI, body untouched. Solc forbids co-inheriting public functions whose data locations differ, and the function was not overridable.

Tested

21 new tests using the real signAndAttachDelegation cheatcode rather than vm.etch, TWAP as handler. 203 pass (182 pre-existing plus 21), forge fmt --check clean, descriptors still current.

The supportsInterface probe is asserted to revert through an actual getTradeableOrderWithSignature call, so the catch branch is exercised end to end rather than mocked. I mutation-checked the security fix myself: removing the _erc1271CallerIsSafe override makes test_multicallerCaller_RawSignature_NotAccepted fail.

Two consequences, asserted by tests rather than left implicit

  • A direct ERC-7739 signature never touches ComposableCow, so registry authorization, handler verify and any swap guard are bypassed. That is what "the owner signed it" means, but test_erc7739_BypassesSwapGuard makes it visible.
  • ERC-7739 requires the nested digest, so this does not make raw-digest ERC-1271 queries validate through the ECDSA path, including CoW's own eip1271 scheme over a plain order digest.

Note for reviewers using jj

lib/solady (v0.1.26) is a submodule. jj 0.43 carries existing gitlinks but will not create a new one from a working-copy submodule, so this commit's tree was assembled with git plumbing and pushed with git push. A plain jj git push would drop the gitlink and CI would fail on the missing import. Verified present on the remote at acd959a.

Open question, deliberately not resolved here

ERC1271Forwarder.isValidSignature still reverts rather than returning 0xffffffff on a bad payload. Returning a non-magic value would make the dispatch cleaner, but GPv2Signing.recoverEip1271Signer calls it directly rather than inspecting returndata, so today a settlement failure bubbles the specific error (OrderNotValid(0x...), SingleOrderNotAuthed) instead of collapsing to "GPv2: invalid eip1271 signature". Worth doing scoped by failure kind, in a follow-up.

mfw78 added 2 commits August 2, 2026 05:55
Lets an EOA place conditional orders by delegating to a contract that answers
ERC-1271, rather than deploying a `Safe`.

The premise holds because `ComposableCow` never calls a `Safe` method.
`isValidSafeSignature` takes one only as a typed address, and `_auth` reads the
registry's own `roots` and `singleOrders`, so any address can own an order. The
repo already assumed this: `test/ComposableCow.base.t.sol` has
`TestNonSafeWallet`, an `ERC1271Forwarder` with no Safe, and
`ComposableCow._buildSignature` has a catch branch commented "Assume a non-Safe
wallet" that emits exactly the payload the forwarder decodes.

`ComposableCow7702` is that catch branch's intended client:

- Batching is Solady's `ERC7821` at its defaults. An empty `opData` requires
  `msg.sender == address(this)`, which under EIP-7702 only a transaction the EOA
  sends to itself satisfies, and a non-empty `opData` reverts. There is no
  relayed path and so no nonce to maintain; the EOA's account nonce sequences
  the batch.
- Signature validation tries ERC-7739 nested EIP-712 first, then falls through
  to the order payload. The nesting is what makes a direct owner signature
  replay-safe across accounts and chains.
- Stateless. The only member is the immutable `composableCow`, which lives in
  code, so there is no initializer to front-run and no storage to collide with a
  later delegate.

Three of Solady's defaults had to be overridden, each for a reason that is not
obvious:

- `_erc1271CallerIsSafe` returns false. Solady's default skips ERC-7739 nesting
  entirely for `MulticallerWithSigner`, which would make any raw signature the
  EOA ever produced over any 32-byte value a valid ERC-1271 signature for that
  caller. This account has no multicaller integration, so the branch is pure
  attack surface on a contract holding the EOA's whole balance.
- `_erc1271IsValidSignatureNowCalldata` recovers directly and requires exactly
  65 bytes with low `s`. The `SignatureCheckerLib` default would staticcall
  `isValidSignature` on this account, since the delegation designator gives
  `address(this)` nonzero code, and re-enter instead of recovering. The length
  and low-`s` bounds give each accepted digest a single valid encoding, so a
  consumer keying a replay guard on signature bytes is not bypassable.
- `_erc1271IsValidSignatureViaRPC` returns false. The default burns the whole
  gas budget on failed validation when `tx.gasprice == 0`, which is every
  foundry test and most `eth_call` simulations.

Solady's `Receiver`, which `ERC7821` brings, answers only the ERC-721 and
ERC-1155 receiver selectors and reverts `FnSelectorNotRecognized` otherwise.
That is load-bearing rather than incidental: `_buildSignature` probes the owner
with `supportsInterface`, and the payload this forwarder decodes is produced
only from the catch branch, so the probe MUST revert. A fallback that answered
it would break settlement silently. For the same reason the delegate declares no
`supportsInterface`.

`ERC1271Forwarder.isValidSignature` changes by two tokens: `bytes memory` to
`bytes calldata`, and `virtual`. Same selector, same external ABI, body
untouched. Solc forbids co-inheriting public functions whose data locations
differ, and the function was not overridable.

Tested against the real `signAndAttachDelegation` cheatcode rather than
`vm.etch`, with TWAP as the handler. The `supportsInterface` probe is asserted
to revert through an actual `getTradeableOrderWithSignature` call, so the catch
branch is exercised end to end rather than mocked.

Two consequences are asserted by tests rather than left implicit. A direct
ERC-7739 signature never touches `ComposableCow`, so registry authorization,
handler `verify` and any swap guard are bypassed; that is what "the owner signed
it" means, but it is worth seeing. And ERC-7739 requires the nested digest, so
this does not make raw-digest ERC-1271 queries, including CoW's own `eip1271`
scheme over a plain order digest, validate through the ECDSA path.

Nothing is deployed by this commit.
Splits the delegate into a generic account and a ComposableCow extension, and
puts Solady's `EIP7702Proxy` between the EOA and the implementation.

`Account7702` carries no protocol integration: ERC-7821 batching and ERC-7739
ERC-1271, nothing else. It is usable as the implementation behind a proxy for
any purpose, and has no ComposableCow or CoW references. The three Solady
defaults that had to be overridden live here, since none of them is specific to
conditional orders.

`CowAccount7702` extends it with the order-payload signature shape, dispatching
ERC-7739 first and falling through to the forwarder. Its EIP-712 domain differs
from the generic account's, so a signature for one is not valid for the other.

Delegating to the proxy rather than to an implementation means the
implementation can be replaced without the EOA signing a second EIP-7702
authorisation. A test upgrades the proxy and asserts the EOA's behaviour changes
while its delegation designator does not; upgrading to the same implementation
instead makes that test fail, so it is detecting the swap rather than passing
either way.

The trade is deliberate and worth stating: the proxy adds a second authority.
Whoever holds the proxy admin can change the code running on every delegated EOA
that has not pinned its own implementation, so the account's security becomes
the EOA key and the admin key rather than the EOA key alone. It also writes
ERC-1967 slots into the EOA's storage, which the previous stateless delegate
avoided, and those slots persist if the EOA is later re-delegated elsewhere.

`deploy_GnosisStack` now deploys the implementation and the proxy, and prints
the proxy address as the delegation target.
@mfw78 mfw78 changed the title feat: EIP-7702 delegate for ERC-1271 without a Safe feat: EIP-7702 account, proxy delegation, and ERC-1271 without a Safe Aug 2, 2026
@mfw78

mfw78 commented Aug 2, 2026

Copy link
Copy Markdown
Author

Superseded by #61. The account work moved to nxm-rs/nexum-account, which depends on this repository for ERC1271Forwarder. All that remains here is the two-token change making isValidSignature overridable, which #61 carries.

@mfw78 mfw78 closed this Aug 2, 2026
mfw78 added a commit that referenced this pull request Aug 2, 2026
Based on `develop`. **#59 is stacked on this**, so this merges first.

Two ABI-neutral token changes to `ERC1271Forwarder.isValidSignature`:
`bytes memory` to `bytes calldata`, and `virtual`. Same selector, same
external ABI, body untouched.

```diff
-function isValidSignature(bytes32 _hash, bytes memory signature) public view override returns (bytes4)
+function isValidSignature(bytes32 _hash, bytes calldata signature) public view virtual override returns (bytes4)
```

## Why

A contract co-inheriting this forwarder with another ERC-1271
implementation cannot compile otherwise. Solc forbids co-inheriting
public functions whose data locations differ, and the function was not
overridable.

That is not hypothetical. An owner that wants to answer both the
ComposableCow order payload and a direct signature of its own has to
implement `isValidSignature` twice over and dispatch between them, which
requires overriding this one.

Nothing here assumes a `Safe`, and it never did: `isValidSafeSignature`
takes one only as a typed address, `_auth` reads the registry's own
`roots` and `singleOrders`, `test/ComposableCow.base.t.sol` already has
`TestNonSafeWallet`, and `_buildSignature` already has a catch branch
commented "Assume a non-Safe wallet". This makes that path usable by an
owner that also has signature logic of its own.

#60 carried more alongside this and is closed.

## Verification

172 tests pass, `forge build` and `forge fmt --check` clean, descriptors
current. No behaviour changes, so no test changes.
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.

1 participant