feat: EIP-7702 account, proxy delegation, and ERC-1271 without a Safe - #60
Closed
mfw78 wants to merge 2 commits into
Closed
feat: EIP-7702 account, proxy delegation, and ERC-1271 without a Safe#60mfw78 wants to merge 2 commits into
mfw78 wants to merge 2 commits into
Conversation
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.
Author
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
ComposableCownever calls aSafemethod.isValidSafeSignaturetakes one only as a typed address, and_authreads the registry's ownrootsandsingleOrders, so any address can own an order.The repo already assumed this.
test/ComposableCow.base.t.sol:318hasTestNonSafeWallet, anERC1271Forwarderwith no Safe, andComposableCow._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
Account7702CowAccount7702EIP7702ProxyBatching is
ERC7821at its defaults. EmptyopDatarequiresmsg.sender == address(this), which under 7702 only a self-sent transaction satisfies; non-emptyopDatareverts. 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_UpgradeChangesBehaviourWithoutRedelegatingupgrades 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
_erc1271CallerIsSafeto false. Solady skips ERC-7739 nesting entirely forMulticallerWithSigner, 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._erc1271IsValidSignatureNowCalldatarecovers directly, requiring exactly 65 bytes with lows. TheSignatureCheckerLibdefault would staticcallisValidSignatureon this account (the delegation designator givesaddress(this)nonzero code) and re-enter instead of recovering. The length and low-sbounds give each accepted digest a single valid encoding, so a consumer keying a replay guard on signature bytes is not bypassable._erc1271IsValidSignatureViaRPCto false. The default burns the whole gas budget on failed validation whentx.gasprice == 0, which is every foundry test and mosteth_callsimulations.What is load-bearing and looks incidental
Solady's
Receiver, whichERC7821brings, answers only the ERC-721 and ERC-1155 receiver selectors and revertsFnSelectorNotRecognizedotherwise._buildSignatureprobes the owner withsupportsInterface, 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 nosupportsInterface.The one change to an audited contract
ERC1271Forwarder.isValidSignature, two tokens:bytes memorytobytes calldata, plusvirtual. 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
signAndAttachDelegationcheatcode rather thanvm.etch, TWAP as handler. 203 pass (182 pre-existing plus 21),forge fmt --checkclean, descriptors still current.The
supportsInterfaceprobe is asserted to revert through an actualgetTradeableOrderWithSignaturecall, so the catch branch is exercised end to end rather than mocked. I mutation-checked the security fix myself: removing the_erc1271CallerIsSafeoverride makestest_multicallerCaller_RawSignature_NotAcceptedfail.Two consequences, asserted by tests rather than left implicit
ComposableCow, so registry authorization, handlerverifyand any swap guard are bypassed. That is what "the owner signed it" means, buttest_erc7739_BypassesSwapGuardmakes it visible.eip1271scheme 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 withgit push. A plainjj git pushwould drop the gitlink and CI would fail on the missing import. Verified present on the remote atacd959a.Open question, deliberately not resolved here
ERC1271Forwarder.isValidSignaturestill reverts rather than returning0xffffffffon a bad payload. Returning a non-magic value would make the dispatch cleaner, butGPv2Signing.recoverEip1271Signercalls 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.