From 524cdbcbce4b99ea99b68afe1257d91e3ffa20b6 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Sun, 2 Aug 2026 05:55:04 +0000 Subject: [PATCH 1/2] feat: EIP-7702 delegate for ERC-1271 without a Safe 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. --- .gitmodules | 3 + foundry.lock | 6 + lib/solady | 1 + src/ComposableCow7702.sol | 121 +++++++++++ src/ERC1271Forwarder.sol | 2 +- test/ComposableCow.7702.t.sol | 397 ++++++++++++++++++++++++++++++++++ 6 files changed, 529 insertions(+), 1 deletion(-) create mode 160000 lib/solady create mode 100644 src/ComposableCow7702.sol create mode 100644 test/ComposableCow.7702.t.sol diff --git a/.gitmodules b/.gitmodules index 2356eb03..b3e47159 100644 --- a/.gitmodules +++ b/.gitmodules @@ -16,3 +16,6 @@ path = lib/safe url = https://github.com/cowdao-grants/extensible-fallback-handler branch = main +[submodule "lib/solady"] + path = lib/solady + url = https://github.com/vectorized/solady diff --git a/foundry.lock b/foundry.lock index 5d1e3430..dcf83638 100644 --- a/foundry.lock +++ b/foundry.lock @@ -22,5 +22,11 @@ "name": "main", "rev": "11273c1f08eda18ed8ff49ec1d4abec5e451ff21" } + }, + "lib/solady": { + "tag": { + "name": "v0.1.26", + "rev": "acd959aa4bd04720d640bf4e6a5c71037510cc4b" + } } } \ No newline at end of file diff --git a/lib/solady b/lib/solady new file mode 160000 index 00000000..acd959aa --- /dev/null +++ b/lib/solady @@ -0,0 +1 @@ +Subproject commit acd959aa4bd04720d640bf4e6a5c71037510cc4b diff --git a/src/ComposableCow7702.sol b/src/ComposableCow7702.sol new file mode 100644 index 00000000..a1a861f8 --- /dev/null +++ b/src/ComposableCow7702.sol @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity >=0.8.0 <0.9.0; + +import {ERC1271 as SoladyERC1271} from "solady/accounts/ERC1271.sol"; +import {ERC7821} from "solady/accounts/ERC7821.sol"; +import {ECDSA} from "solady/utils/ECDSA.sol"; + +import {ComposableCow} from "./ComposableCow.sol"; +import {ERC1271Forwarder} from "./ERC1271Forwarder.sol"; + +/** + * @title EIP-7702 delegate routing ERC-1271 to ComposableCow + * @author mfw78 + * @dev Lets an EOA place conditional orders without deploying a `Safe`. + * `ComposableCow` never calls a `Safe` method: `isValidSafeSignature` + * takes one only as a typed address, and `_auth` reads the registry's own + * `roots` and `singleOrders`. The owner therefore needs nothing beyond + * ERC-1271, which `ERC1271Forwarder` already implements. + * + * Stateless by construction. 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. + * + * Batching is `ERC7821` at its defaults: an empty `opData` requires + * `msg.sender == address(this)`, which under EIP-7702 is satisfied only by + * a transaction the EOA sends to itself, and a non-empty `opData` reverts, + * so there is no relayed path and no nonce to maintain. The EOA's own + * account nonce sequences the batch. + * + * Signature validation accepts exactly two shapes, tried in order: + * 1. ERC-7739 defensive nested EIP-712 (Solady's `ERC1271`): TypedDataSign + * or PersonalSign rehashing, recovered to the EOA itself. A miss + * returns `0xffffffff` without reverting, so dispatch falls through. + * This is a direct owner signature: it never touches `ComposableCow`, + * so registry authorization, any swap guard installed via + * `ComposableCow.setSwapGuard`, and handler `verify` are all bypassed. + * Only shape 2 is guard-mediated. The nested-EIP712 shortcut for + * "safe" callers (Solady's `MulticallerWithSigner` carve-out) is + * disabled, so no caller can present a raw, un-nested ECDSA signature. + * 2. The `ComposableCow` order payload, `abi.encode(GPv2Order.Data, + * PayloadStruct)`, exactly as `ERC1271Forwarder` has always decoded it. + * On a miss this branch REVERTS (malformed payload, `InvalidHash`, or + * unauthorized order) instead of returning `0xffffffff`, as the + * forwarder always has. Integrators probing this account with + * arbitrary ERC-1271 queries must treat a revert as a rejection. + */ +contract ComposableCow7702 is ERC1271Forwarder, SoladyERC1271, ERC7821 { + constructor(ComposableCow _composableCow) ERC1271Forwarder(_composableCow) {} + + /// @dev ERC-7739 account domain. `verifyingContract` binds to the EOA at + /// runtime: Solady's `EIP712` rebuilds the separator whenever + /// `address(this)` differs from the cached deploy address. + function _domainNameAndVersion() internal pure override returns (string memory, string memory) { + return ("ComposableCow7702", "1"); + } + + /// @dev The EOA itself is the signer under EIP-7702. + function _erc1271Signer() internal view override returns (address) { + return address(this); + } + + /// @dev No safe-caller carve-out: Solady's default skips the ERC-7739 + /// nesting entirely for `MulticallerWithSigner`, which would make any + /// raw ECDSA 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. + function _erc1271CallerIsSafe() internal pure override returns (bool) { + return false; + } + + /// @dev Plain ecrecover to self, restricted to the canonical encoding: + /// exactly 65 bytes with low `s`. Rejecting the EIP-2098 compact form + /// and the high-`s` twin gives each accepted digest a unique signature + /// byte string, so consumers keying replay guards on signature bytes + /// are not bypassable. The `SignatureCheckerLib` default would + /// staticcall `isValidSignature` on this account (the 7702 delegation + /// designator gives `address(this)` nonzero code) and re-enter instead + /// of recovering. `tryRecoverCalldata` returns `address(0)` on any + /// malformed signature, and `address(this)` is never zero. + function _erc1271IsValidSignatureNowCalldata(bytes32 _hash, bytes calldata signature) + internal + view + override + returns (bool) + { + if (signature.length != 65) return false; + // secp256k1 half-order: reject the malleable high-s counterpart + if (uint256(bytes32(signature[32:64])) > 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0) { + return false; + } + return ECDSA.tryRecoverCalldata(_hash, signature) == address(this); + } + + /// @dev Disabled: the default burns the entire gas budget on a failed + /// validation whenever `tx.gasprice == 0` (foundry tests and typical + /// `eth_call` simulations). It must always return false on-chain + /// anyway, so returning false is behavior-preserving in production. + function _erc1271IsValidSignatureViaRPC(bytes32, bytes calldata) internal pure override returns (bool) { + return false; + } + + /// @dev ERC-7739 first (returns `0xffffffff` on a miss, never reverts with + /// the RPC path disabled), then the ComposableCow forwarder (reverts + /// on anything that is not a well-formed authorized order payload). + /// Misrouting can only reject, never accept: the order branch requires + /// registry authorization by the EOA plus the `GPv2Order.hash` check, + /// and the ECDSA branch requires recovery of the nested digest to + /// `address(this)` (nesting is unconditional: the safe-caller shortcut + /// is disabled above). The `0x77390001` detection sentinel + /// short-circuits because it differs from `0xffffffff`. + function isValidSignature(bytes32 _hash, bytes calldata signature) + public + view + override(ERC1271Forwarder, SoladyERC1271) + returns (bytes4 result) + { + result = SoladyERC1271.isValidSignature(_hash, signature); + if (result != bytes4(0xffffffff)) return result; + return ERC1271Forwarder.isValidSignature(_hash, signature); + } +} diff --git a/src/ERC1271Forwarder.sol b/src/ERC1271Forwarder.sol index 7ebe671d..6a1a2055 100644 --- a/src/ERC1271Forwarder.sol +++ b/src/ERC1271Forwarder.sol @@ -26,7 +26,7 @@ abstract contract ERC1271Forwarder is ERC1271 { * @param _hash GPv2Order.Data digest * @param signature The abi.encoded tuple of (GPv2Order.Data, ComposableCow.PayloadStruct) */ - 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) { (GPv2Order.Data memory order, ComposableCow.PayloadStruct memory payload) = abi.decode(signature, (GPv2Order.Data, ComposableCow.PayloadStruct)); bytes32 domainSeparator = composableCow.domainSeparator(); diff --git a/test/ComposableCow.7702.t.sol b/test/ComposableCow.7702.t.sol new file mode 100644 index 00000000..a7476774 --- /dev/null +++ b/test/ComposableCow.7702.t.sol @@ -0,0 +1,397 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity >=0.8.0 <0.9.0; + +import {ERC1271, ISignatureVerifierMuxer} from "safe/handler/extensible/SignatureVerifierMuxer.sol"; +import {IERC165} from "safe/Safe.sol"; + +import {ERC7821} from "solady/accounts/ERC7821.sol"; + +import { + IERC20, + IConditionalOrder, + GPv2Order, + ComposableCow, + BaseComposableCowTest, + TestSwapGuard +} from "./ComposableCow.base.t.sol"; + +import {TWAPOrder} from "../src/types/twap/libraries/TWAPOrder.sol"; +import {ComposableCow7702} from "../src/ComposableCow7702.sol"; + +/** + * @dev EIP-7702 delegation cheatcodes are supported by the `forge` binary + * (1.7.1) but absent from the vendored `forge-std` Vm interface, so they + * are declared locally (see the `VmJson` precedent in + * ComposableCow.descriptorDoc.t.sol). + */ +interface Vm7702 { + struct SignedDelegation { + uint8 v; + bytes32 r; + bytes32 s; + uint64 nonce; + address implementation; + } + + function signAndAttachDelegation(address implementation, uint256 privateKey) + external + returns (SignedDelegation memory); +} + +contract ComposableCow7702Test is BaseComposableCowTest { + /// @dev The exact GPv2 `Order(...)` EIP-712 type string; `contentsType` + /// for the ERC-7739 TypedDataSign workflow (implicit mode). + string internal constant ORDER_TYPE = + "Order(address sellToken,address buyToken,address receiver,uint256 sellAmount,uint256 buyAmount,uint32 validTo,bytes32 appData,uint256 feeAmount,string kind,bool partiallyFillable,string sellTokenBalance,string buyTokenBalance)"; + + /// @dev ERC-7821 mode: single batch, no `opData` support. + bytes32 internal constant MODE_SINGLE_BATCH = bytes32(uint256(0x01) << 248); + + /// @dev ERC-7821 mode: single batch with optional `opData` support. + bytes32 internal constant MODE_SINGLE_BATCH_OPDATA = bytes32(uint256(0x01000000000078210001) << 176); + + /// @dev ERC-7739 detection sentinel (see EIP-7739). + bytes32 internal constant SENTINEL_7739 = 0x7739773977397739773977397739773977397739773977397739773977397739; + + /// @dev The canonical `MulticallerWithSigner`, Solady's default "safe" caller. + address internal constant MULTICALLER_WITH_SIGNER = 0x000000000000D9ECebf3C23529de49815Dac1c4c; + + /// @dev secp256k1 group order. + uint256 internal constant SECP256K1_N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141; + + ComposableCow7702 internal impl; + address internal eoa; + uint256 internal eoaPk; + + function setUp() public virtual override(BaseComposableCowTest) { + super.setUp(); + + // deploy the shared delegate implementation + impl = new ComposableCow7702(composableCow); + + // create the delegating EOA and attach the EIP-7702 delegation + // (real cheatcode: writes the 0xef0100 ++ impl designator) + (eoa, eoaPk) = makeAddrAndKey("composable-cow-7702-eoa"); + Vm7702(address(vm)).signAndAttachDelegation(address(impl), eoaPk); + assertEq(eoa.code.length, 23); + } + + // --- helpers --- + + function _twapBundle() internal view returns (TWAPOrder.Data memory) { + return TWAPOrder.Data({ + sellToken: token0, + buyToken: token1, + receiver: address(0), + partSellAmount: 1e18, + minPartLimit: 1, + t0: block.timestamp, + n: 2, + t: 3600, + span: 0, + appData: keccak256("twap.7702") + }); + } + + function _createTwapOrder() internal returns (IConditionalOrder.ConditionalOrderParams memory params) { + TWAPOrder.Data memory twapData = _twapBundle(); + params = createOrder(twap, keccak256("twap.7702"), abi.encode(twapData)); + + // register the single order in ComposableCow as the EOA + _create(eoa, params, false); + + // fund the EOA and authorize the vault relayer + deal(address(token0), eoa, twapData.partSellAmount * twapData.n); + vm.prank(eoa); + token0.approve(address(relayer), twapData.partSellAmount * twapData.n); + } + + /// @dev Builds the ERC-7739 TypedDataSign wire signature over the GPv2 + /// order digest, signed by `pk`. + function _typedDataSign(GPv2Order.Data memory order, uint256 pk) internal view returns (bytes memory) { + (bytes32 finalDigest, bytes memory suffix) = _typedDataSignParts(order); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, finalDigest); + return abi.encodePacked(r, s, v, suffix); + } + + /// @dev The ERC-7739 TypedDataSign nested digest to sign, and the wire + /// suffix appended after the raw ECDSA bytes. + function _typedDataSignParts(GPv2Order.Data memory order) + internal + view + returns (bytes32 finalDigest, bytes memory suffix) + { + bytes32 appDomain = settlement.domainSeparator(); + bytes32 contents = _orderStructHash(order); + bytes32 typedDataSignTypehash = keccak256( + abi.encodePacked( + "TypedDataSign(Order contents,string name,string version,uint256 chainId,address verifyingContract,bytes32 salt)", + ORDER_TYPE + ) + ); + // ComposableCow7702's account domain: verifyingContract is the EOA + bytes32 structHash = keccak256( + abi.encode( + typedDataSignTypehash, + contents, + keccak256("ComposableCow7702"), + keccak256("1"), + block.chainid, + eoa, + bytes32(0) + ) + ); + finalDigest = keccak256(abi.encodePacked(hex"1901", appDomain, structHash)); + suffix = abi.encodePacked(appDomain, contents, bytes(ORDER_TYPE), uint16(bytes(ORDER_TYPE).length)); + } + + function _orderStructHash(GPv2Order.Data memory order) internal pure returns (bytes32) { + return keccak256( + abi.encode( + GPv2Order.TYPE_HASH, + order.sellToken, + order.buyToken, + order.receiver, + order.sellAmount, + order.buyAmount, + order.validTo, + order.appData, + order.feeAmount, + order.kind, + order.partiallyFillable, + order.sellTokenBalance, + order.buyTokenBalance + ) + ); + } + + /// @dev Low-level probe asserting a signature is never accepted, whether + /// it reverts or returns a non-magic value. + function _assertNotAccepted(bytes32 hash, bytes memory signature) internal { + (bool success, bytes memory ret) = + eoa.staticcall(abi.encodeWithSelector(ERC1271.isValidSignature.selector, hash, signature)); + assertFalse(success && ret.length >= 4 && bytes4(ret) == ERC1271.isValidSignature.selector); + } + + // --- ComposableCow order-payload path --- + + /** + * @dev The ComposableCow order payload still validates through the + * delegated EOA, and `_buildSignature` takes the catch branch (the + * returned signature decodes as `abi.encode(order, PayloadStruct)`). + */ + function test_orderPayload_ReturnsMagicValue() public { + IConditionalOrder.ConditionalOrderParams memory params = _createTwapOrder(); + + (ComposableCow.PollResult memory orderRes, bytes memory signature) = + composableCow.getTradeableOrderWithSignature(eoa, params, bytes(""), new bytes32[](0)); + GPv2Order.Data memory order = orderRes.generator.order; + + // the signature is the non-Safe (catch branch) encoding + (GPv2Order.Data memory sigOrder, ComposableCow.PayloadStruct memory payload) = + abi.decode(signature, (GPv2Order.Data, ComposableCow.PayloadStruct)); + assertEq( + GPv2Order.hash(sigOrder, composableCow.domainSeparator()), + GPv2Order.hash(order, composableCow.domainSeparator()) + ); + assertEq(keccak256(abi.encode(payload.params)), keccak256(abi.encode(params))); + + // the delegated EOA validates it + assertEq( + ERC1271(eoa).isValidSignature(GPv2Order.hash(order, composableCow.domainSeparator()), signature), + ERC1271.isValidSignature.selector + ); + } + + /** + * @dev The `supportsInterface` probe in `ComposableCow._buildSignature` + * still reverts on the delegated EOA (Solady's `Receiver` fallback + * answers only token callbacks), forcing the catch branch. + */ + function test_supportsInterface_ProbeReverts() public { + vm.expectRevert(); + IERC165(eoa).supportsInterface(type(ISignatureVerifierMuxer).interfaceId); + } + + // --- ERC-7739 ECDSA path --- + + /** + * @dev An ECDSA signature by the delegated EOA over the ERC-7739 + * TypedDataSign nested digest of a GPv2 order is accepted. + */ + function test_erc7739_TypedDataSign_ReturnsMagicValue() public { + GPv2Order.Data memory order = getBlankOrder(); + bytes32 orderDigest = GPv2Order.hash(order, settlement.domainSeparator()); + + bytes memory signature = _typedDataSign(order, eoaPk); + assertEq(ERC1271(eoa).isValidSignature(orderDigest, signature), ERC1271.isValidSignature.selector); + } + + /** + * @dev The same nested digest signed by a different key is rejected. + */ + function test_erc7739_WrongSigner_NotAccepted() public { + GPv2Order.Data memory order = getBlankOrder(); + bytes32 orderDigest = GPv2Order.hash(order, settlement.domainSeparator()); + + bytes memory signature = _typedDataSign(order, bob.pk); + _assertNotAccepted(orderDigest, signature); + } + + /** + * @dev Garbage and malformed signatures are never accepted (they either + * return a non-magic value or revert in the forwarder fallthrough). + */ + function test_garbageSignature_NeverAccepted() public { + bytes32 hash = keccak256("some hash"); + + // 65 bytes of garbage (raw-ECDSA shaped) + _assertNotAccepted(hash, abi.encodePacked(keccak256("r"), keccak256("s"), uint8(27))); + // empty signature (and a non-sentinel hash) + _assertNotAccepted(hash, bytes("")); + // 704 zero bytes (order-payload shaped) + _assertNotAccepted(hash, new bytes(704)); + // a well-formed but UNREGISTERED order payload + GPv2Order.Data memory order = getBlankOrder(); + ComposableCow.PayloadStruct memory payload = ComposableCow.PayloadStruct({ + proof: new bytes32[](0), params: getPassthroughOrder(), offchainInput: bytes("") + }); + _assertNotAccepted(GPv2Order.hash(order, composableCow.domainSeparator()), abi.encode(order, payload)); + } + + /** + * @dev Solady's default treats `MulticallerWithSigner` as a "safe" caller + * and skips the ERC-7739 nesting for it, accepting raw ECDSA + * signatures over arbitrary hashes. The `_erc1271CallerIsSafe` + * override closes that branch: nesting is unconditional. + */ + function test_multicallerCaller_RawSignature_NotAccepted() public { + bytes32 arbitrary = keccak256("any 32-byte value the EOA key ever signed"); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(eoaPk, arbitrary); + + vm.prank(MULTICALLER_WITH_SIGNER); + _assertNotAccepted(arbitrary, abi.encodePacked(r, s, v)); + } + + /** + * @dev The ECDSA leaf accepts only the canonical 65-byte low-s encoding, + * so each accepted digest has a unique signature byte string. The + * high-s twin and the EIP-2098 compact form of an otherwise valid + * signature are rejected. + */ + function test_erc7739_MalleatedEncodings_NotAccepted() public { + GPv2Order.Data memory order = getBlankOrder(); + bytes32 orderDigest = GPv2Order.hash(order, settlement.domainSeparator()); + (bytes32 finalDigest, bytes memory suffix) = _typedDataSignParts(order); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(eoaPk, finalDigest); + + // sanity: the canonical encoding is accepted + assertEq( + ERC1271(eoa).isValidSignature(orderDigest, abi.encodePacked(r, s, v, suffix)), + ERC1271.isValidSignature.selector + ); + + // the high-s twin recovers to the same signer but is rejected + bytes32 sHigh = bytes32(SECP256K1_N - uint256(s)); + uint8 vFlipped = v == 27 ? 28 : 27; + _assertNotAccepted(orderDigest, abi.encodePacked(r, sHigh, vFlipped, suffix)); + + // the 64-byte EIP-2098 compact form is rejected + bytes32 vs = bytes32(uint256(s) | (uint256(v - 27) << 255)); + _assertNotAccepted(orderDigest, abi.encodePacked(r, vs, suffix)); + } + + /** + * @dev Pins the documented behavior: shape 1 (ERC-7739) is a direct owner + * signature that never touches `ComposableCow`, so an installed swap + * guard does not mediate it. Only the order-payload branch is + * guard-checked. + */ + function test_erc7739_BypassesSwapGuard() public { + // a guard that rejects odd sell amounts, installed for the EOA + TestSwapGuard oddRejectingGuard = new TestSwapGuard(2); + _setSwapGuard(eoa, oddRejectingGuard); + + GPv2Order.Data memory order = getBlankOrder(); + order.sellAmount = 1; + assertFalse(oddRejectingGuard.verify(order, bytes32(0), getPassthroughOrder(), bytes(""))); + + // the guard-violating order is still accepted via ERC-7739 + bytes32 orderDigest = GPv2Order.hash(order, settlement.domainSeparator()); + bytes memory signature = _typedDataSign(order, eoaPk); + assertEq(ERC1271(eoa).isValidSignature(orderDigest, signature), ERC1271.isValidSignature.selector); + } + + /** + * @dev The ERC-7739 detection sentinel is preserved by the dispatch. + */ + function test_erc7739_DetectionSentinel() public { + (bool success, bytes memory ret) = + eoa.staticcall(abi.encodeWithSelector(ERC1271.isValidSignature.selector, SENTINEL_7739, bytes(""))); + assertTrue(success); + assertEq(bytes4(ret), bytes4(0x77390001)); + } + + // --- ERC-7821 batching --- + + /** + * @dev A self-call batch (the EOA sending a transaction to itself) + * executes: here creating a TWAP order and setting the relayer + * allowance in one batch. + */ + function test_execute_SelfBatch_Succeeds() public { + TWAPOrder.Data memory twapData = _twapBundle(); + IConditionalOrder.ConditionalOrderParams memory params = + createOrder(twap, keccak256("twap.7702.batch"), abi.encode(twapData)); + uint256 sellAmount = twapData.partSellAmount * twapData.n; + deal(address(token0), eoa, sellAmount); + + ERC7821.Call[] memory calls = new ERC7821.Call[](2); + calls[0] = ERC7821.Call({ + to: address(composableCow), + value: 0, + data: abi.encodeWithSelector(ComposableCow.create.selector, params, false) + }); + calls[1] = ERC7821.Call({ + to: address(token0), + value: 0, + data: abi.encodeWithSelector(IERC20.approve.selector, address(relayer), sellAmount) + }); + + // under EIP-7702, msg.sender == address(this) is a self-sent tx + vm.prank(eoa); + ComposableCow7702(payable(eoa)).execute(MODE_SINGLE_BATCH, abi.encode(calls)); + + assertTrue(composableCow.singleOrders(eoa, keccak256(abi.encode(params)))); + assertEq(token0.allowance(eoa, address(relayer)), sellAmount); + } + + /** + * @dev `execute` from any sender other than the account itself reverts. + */ + function test_execute_NotSelf_Reverts() public { + ERC7821.Call[] memory calls = new ERC7821.Call[](0); + + vm.prank(alice.addr); + vm.expectRevert(); + ComposableCow7702(payable(eoa)).execute(MODE_SINGLE_BATCH, abi.encode(calls)); + } + + /** + * @dev A non-empty `opData` reverts (ERC-7821 defaults: no relayed path). + */ + function test_execute_NonEmptyOpData_Reverts() public { + ERC7821.Call[] memory calls = new ERC7821.Call[](0); + + vm.prank(eoa); + vm.expectRevert(); + ComposableCow7702(payable(eoa)).execute(MODE_SINGLE_BATCH_OPDATA, abi.encode(calls, bytes("op"))); + } + + /** + * @dev Sanity: the local `ORDER_TYPE` string matches GPv2's TYPE_HASH. + */ + function test_orderTypeString_MatchesTypeHash() public { + assertEq(keccak256(bytes(ORDER_TYPE)), GPv2Order.TYPE_HASH); + } +} From e783b3cfe42d8e294f0736e619bc639b2cd126f6 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Sun, 2 Aug 2026 06:57:49 +0000 Subject: [PATCH 2/2] refactor: split the account and delegate through an EIP-7702 proxy 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. --- script/deploy_GnosisStack.s.sol | 12 +++ src/ComposableCow7702.sol | 121 ---------------------------- src/accounts/Account7702.sol | 89 +++++++++++++++++++++ src/accounts/CowAccount7702.sol | 64 +++++++++++++++ test/ComposableCow.7702.t.sol | 16 ++-- test/ComposableCow.7702proxy.t.sol | 124 +++++++++++++++++++++++++++++ 6 files changed, 297 insertions(+), 129 deletions(-) delete mode 100644 src/ComposableCow7702.sol create mode 100644 src/accounts/Account7702.sol create mode 100644 src/accounts/CowAccount7702.sol create mode 100644 test/ComposableCow.7702proxy.t.sol diff --git a/script/deploy_GnosisStack.s.sol b/script/deploy_GnosisStack.s.sol index 8b2db45c..2b390c32 100644 --- a/script/deploy_GnosisStack.s.sol +++ b/script/deploy_GnosisStack.s.sol @@ -4,6 +4,9 @@ pragma solidity >=0.8.0 <0.9.0; import "forge-std/Script.sol"; import {ComposableCow} from "../src/ComposableCow.sol"; +import {EIP7702Proxy} from "solady/accounts/EIP7702Proxy.sol"; + +import {CowAccount7702} from "../src/accounts/CowAccount7702.sol"; import {OwnedGoodAfterTime, OwnedStopLoss, OwnedTWAP} from "../src/types/Owned.sol"; /** @@ -42,6 +45,12 @@ contract DeployGnosisStack is Script { OwnedStopLoss stopLoss = new OwnedStopLoss(admin); OwnedGoodAfterTime goodAfterTime = new OwnedGoodAfterTime(admin); + // Delegation target for an EOA testing without a Safe. The EOA authorises + // the proxy once; the implementation behind it can be replaced by `admin` + // afterwards without a second authorisation. + CowAccount7702 account = new CowAccount7702(composableCow); + EIP7702Proxy accountProxy = new EIP7702Proxy(address(account), admin); + vm.stopBroadcast(); console.log("chainId ", block.chainid); @@ -50,8 +59,11 @@ contract DeployGnosisStack is Script { console.log("OwnedTWAP ", address(twap)); console.log("OwnedStopLoss ", address(stopLoss)); console.log("OwnedGoodAfterTime", address(goodAfterTime)); + console.log("CowAccount7702 ", address(account)); + console.log("EIP7702Proxy ", address(accountProxy)); console.log(""); console.log("Record these in deployments/networks.json, then publish each"); console.log("descriptor and call setDescriptor from the owner."); + console.log("Delegate an EOA with: cast send --auth", address(accountProxy)); } } diff --git a/src/ComposableCow7702.sol b/src/ComposableCow7702.sol deleted file mode 100644 index a1a861f8..00000000 --- a/src/ComposableCow7702.sol +++ /dev/null @@ -1,121 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0 -pragma solidity >=0.8.0 <0.9.0; - -import {ERC1271 as SoladyERC1271} from "solady/accounts/ERC1271.sol"; -import {ERC7821} from "solady/accounts/ERC7821.sol"; -import {ECDSA} from "solady/utils/ECDSA.sol"; - -import {ComposableCow} from "./ComposableCow.sol"; -import {ERC1271Forwarder} from "./ERC1271Forwarder.sol"; - -/** - * @title EIP-7702 delegate routing ERC-1271 to ComposableCow - * @author mfw78 - * @dev Lets an EOA place conditional orders without deploying a `Safe`. - * `ComposableCow` never calls a `Safe` method: `isValidSafeSignature` - * takes one only as a typed address, and `_auth` reads the registry's own - * `roots` and `singleOrders`. The owner therefore needs nothing beyond - * ERC-1271, which `ERC1271Forwarder` already implements. - * - * Stateless by construction. 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. - * - * Batching is `ERC7821` at its defaults: an empty `opData` requires - * `msg.sender == address(this)`, which under EIP-7702 is satisfied only by - * a transaction the EOA sends to itself, and a non-empty `opData` reverts, - * so there is no relayed path and no nonce to maintain. The EOA's own - * account nonce sequences the batch. - * - * Signature validation accepts exactly two shapes, tried in order: - * 1. ERC-7739 defensive nested EIP-712 (Solady's `ERC1271`): TypedDataSign - * or PersonalSign rehashing, recovered to the EOA itself. A miss - * returns `0xffffffff` without reverting, so dispatch falls through. - * This is a direct owner signature: it never touches `ComposableCow`, - * so registry authorization, any swap guard installed via - * `ComposableCow.setSwapGuard`, and handler `verify` are all bypassed. - * Only shape 2 is guard-mediated. The nested-EIP712 shortcut for - * "safe" callers (Solady's `MulticallerWithSigner` carve-out) is - * disabled, so no caller can present a raw, un-nested ECDSA signature. - * 2. The `ComposableCow` order payload, `abi.encode(GPv2Order.Data, - * PayloadStruct)`, exactly as `ERC1271Forwarder` has always decoded it. - * On a miss this branch REVERTS (malformed payload, `InvalidHash`, or - * unauthorized order) instead of returning `0xffffffff`, as the - * forwarder always has. Integrators probing this account with - * arbitrary ERC-1271 queries must treat a revert as a rejection. - */ -contract ComposableCow7702 is ERC1271Forwarder, SoladyERC1271, ERC7821 { - constructor(ComposableCow _composableCow) ERC1271Forwarder(_composableCow) {} - - /// @dev ERC-7739 account domain. `verifyingContract` binds to the EOA at - /// runtime: Solady's `EIP712` rebuilds the separator whenever - /// `address(this)` differs from the cached deploy address. - function _domainNameAndVersion() internal pure override returns (string memory, string memory) { - return ("ComposableCow7702", "1"); - } - - /// @dev The EOA itself is the signer under EIP-7702. - function _erc1271Signer() internal view override returns (address) { - return address(this); - } - - /// @dev No safe-caller carve-out: Solady's default skips the ERC-7739 - /// nesting entirely for `MulticallerWithSigner`, which would make any - /// raw ECDSA 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. - function _erc1271CallerIsSafe() internal pure override returns (bool) { - return false; - } - - /// @dev Plain ecrecover to self, restricted to the canonical encoding: - /// exactly 65 bytes with low `s`. Rejecting the EIP-2098 compact form - /// and the high-`s` twin gives each accepted digest a unique signature - /// byte string, so consumers keying replay guards on signature bytes - /// are not bypassable. The `SignatureCheckerLib` default would - /// staticcall `isValidSignature` on this account (the 7702 delegation - /// designator gives `address(this)` nonzero code) and re-enter instead - /// of recovering. `tryRecoverCalldata` returns `address(0)` on any - /// malformed signature, and `address(this)` is never zero. - function _erc1271IsValidSignatureNowCalldata(bytes32 _hash, bytes calldata signature) - internal - view - override - returns (bool) - { - if (signature.length != 65) return false; - // secp256k1 half-order: reject the malleable high-s counterpart - if (uint256(bytes32(signature[32:64])) > 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0) { - return false; - } - return ECDSA.tryRecoverCalldata(_hash, signature) == address(this); - } - - /// @dev Disabled: the default burns the entire gas budget on a failed - /// validation whenever `tx.gasprice == 0` (foundry tests and typical - /// `eth_call` simulations). It must always return false on-chain - /// anyway, so returning false is behavior-preserving in production. - function _erc1271IsValidSignatureViaRPC(bytes32, bytes calldata) internal pure override returns (bool) { - return false; - } - - /// @dev ERC-7739 first (returns `0xffffffff` on a miss, never reverts with - /// the RPC path disabled), then the ComposableCow forwarder (reverts - /// on anything that is not a well-formed authorized order payload). - /// Misrouting can only reject, never accept: the order branch requires - /// registry authorization by the EOA plus the `GPv2Order.hash` check, - /// and the ECDSA branch requires recovery of the nested digest to - /// `address(this)` (nesting is unconditional: the safe-caller shortcut - /// is disabled above). The `0x77390001` detection sentinel - /// short-circuits because it differs from `0xffffffff`. - function isValidSignature(bytes32 _hash, bytes calldata signature) - public - view - override(ERC1271Forwarder, SoladyERC1271) - returns (bytes4 result) - { - result = SoladyERC1271.isValidSignature(_hash, signature); - if (result != bytes4(0xffffffff)) return result; - return ERC1271Forwarder.isValidSignature(_hash, signature); - } -} diff --git a/src/accounts/Account7702.sol b/src/accounts/Account7702.sol new file mode 100644 index 00000000..9b698f47 --- /dev/null +++ b/src/accounts/Account7702.sol @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity >=0.8.0 <0.9.0; + +import {ERC1271} from "solady/accounts/ERC1271.sol"; +import {ERC7821} from "solady/accounts/ERC7821.sol"; +import {ECDSA} from "solady/utils/ECDSA.sol"; + +/** + * @title Minimal EIP-7702 account + * @author mfw78 + * @dev A delegation target for an EOA: batched execution and ERC-1271, and + * nothing else. Carries no protocol integration, so it is usable as the + * implementation behind an `EIP7702Proxy` for any purpose. + * + * Stateless. There is no owner and no initializer: under EIP-7702 the + * EOA's key is the authority, so introducing either would add a second one. + * + * Batching is `ERC7821` at its defaults. An empty `opData` requires + * `msg.sender == address(this)`, which 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. + * + * Signatures are ERC-7739 nested EIP-712, recovered to the EOA itself. The + * nesting is what makes an owner signature replay-safe across accounts and + * chains, and is why a raw digest does not validate here. + */ +contract Account7702 is ERC1271, ERC7821 { + /// @dev `verifyingContract` binds to the EOA at runtime: Solady's `EIP712` + /// rebuilds the separator whenever `address(this)` differs from the + /// address cached at deployment. + function _domainNameAndVersion() internal pure virtual override returns (string memory, string memory) { + return ("Account7702", "1"); + } + + /// @dev The EOA itself is the signer under EIP-7702. + function _erc1271Signer() internal view virtual override returns (address) { + return address(this); + } + + /** + * @dev No safe-caller carve-out. Solady's default skips the 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. + */ + function _erc1271CallerIsSafe() internal pure virtual override returns (bool) { + return false; + } + + /** + * @dev Plain ecrecover to self, restricted to the canonical encoding: + * exactly 65 bytes with low `s`. Rejecting the EIP-2098 compact form + * and the high-`s` twin gives each accepted digest a unique signature + * byte string, so a consumer keying a replay guard on signature bytes + * is not bypassable. + * + * The `SignatureCheckerLib` default would staticcall `isValidSignature` + * on this account, since the delegation designator gives + * `address(this)` nonzero code, and re-enter instead of recovering. + * `tryRecoverCalldata` returns `address(0)` on a malformed signature, + * and `address(this)` is never zero. + */ + function _erc1271IsValidSignatureNowCalldata(bytes32 _hash, bytes calldata signature) + internal + view + virtual + override + returns (bool) + { + if (signature.length != 65) return false; + // secp256k1 half-order: reject the malleable high-s counterpart + if (uint256(bytes32(signature[32:64])) > 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0) { + return false; + } + return ECDSA.tryRecoverCalldata(_hash, signature) == address(this); + } + + /** + * @dev Disabled: the default burns the entire gas budget on a failed + * validation whenever `tx.gasprice == 0`, which is every foundry test + * and most `eth_call` simulation. It must always return false on-chain + * anyway, so returning false is behaviour-preserving in production. + */ + function _erc1271IsValidSignatureViaRPC(bytes32, bytes calldata) internal pure virtual override returns (bool) { + return false; + } +} diff --git a/src/accounts/CowAccount7702.sol b/src/accounts/CowAccount7702.sol new file mode 100644 index 00000000..dc197d78 --- /dev/null +++ b/src/accounts/CowAccount7702.sol @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity >=0.8.0 <0.9.0; + +import {ERC1271} from "solady/accounts/ERC1271.sol"; + +import {ComposableCow} from "../ComposableCow.sol"; +import {ERC1271Forwarder} from "../ERC1271Forwarder.sol"; +import {Account7702} from "./Account7702.sol"; + +/** + * @title An `Account7702` that can own conditional orders + * @author mfw78 + * @dev Adds the `ComposableCow` order-payload signature shape to the generic + * account, so an EOA can own conditional orders without deploying a + * `Safe`. + * + * The premise is that `ComposableCow` never calls a `Safe` method: + * `isValidSafeSignature` takes one only as a typed address, and `_auth` + * reads the registry's own `roots` and `singleOrders`. The owner therefore + * needs nothing beyond ERC-1271. + * + * Two signature shapes are tried in order: + * 1. ERC-7739 nested EIP-712, inherited unchanged. A miss returns + * `0xffffffff` without reverting, so dispatch falls through. This is a + * direct owner signature: it never reaches `ComposableCow`, so registry + * authorisation, handler `verify` and any swap guard are bypassed. + * 2. The order payload, `abi.encode(GPv2Order.Data, PayloadStruct)`, + * exactly as `ERC1271Forwarder` has always decoded it. On a miss this + * branch reverts rather than returning `0xffffffff`, as the forwarder + * always has, so an integrator probing this account with an arbitrary + * ERC-1271 query must treat a revert as a rejection. + * + * Misrouting can only reject, never accept: the order branch requires + * registry authorisation plus the `GPv2Order.hash` check, and the ECDSA + * branch requires recovery of the nested digest to `address(this)`. + * + * Declares no `supportsInterface`, and inherits no fallback that would + * answer one. `ComposableCow._buildSignature` probes the owner with + * `supportsInterface` and produces the payload shape 2 decodes only from + * its catch branch, so that probe MUST revert. Solady's `Receiver`, which + * `ERC7821` brings, answers only the ERC-721 and ERC-1155 receiver + * selectors and reverts `FnSelectorNotRecognized` otherwise, which is what + * makes this hold. + */ +contract CowAccount7702 is Account7702, ERC1271Forwarder { + constructor(ComposableCow _composableCow) ERC1271Forwarder(_composableCow) {} + + /// @dev Distinct from the generic account's domain: a signature for one is + /// not valid for the other. + function _domainNameAndVersion() internal pure override returns (string memory, string memory) { + return ("CowAccount7702", "1"); + } + + function isValidSignature(bytes32 _hash, bytes calldata signature) + public + view + override(ERC1271, ERC1271Forwarder) + returns (bytes4 result) + { + result = ERC1271.isValidSignature(_hash, signature); + if (result != bytes4(0xffffffff)) return result; + return ERC1271Forwarder.isValidSignature(_hash, signature); + } +} diff --git a/test/ComposableCow.7702.t.sol b/test/ComposableCow.7702.t.sol index a7476774..44c17736 100644 --- a/test/ComposableCow.7702.t.sol +++ b/test/ComposableCow.7702.t.sol @@ -16,7 +16,7 @@ import { } from "./ComposableCow.base.t.sol"; import {TWAPOrder} from "../src/types/twap/libraries/TWAPOrder.sol"; -import {ComposableCow7702} from "../src/ComposableCow7702.sol"; +import {CowAccount7702} from "../src/accounts/CowAccount7702.sol"; /** * @dev EIP-7702 delegation cheatcodes are supported by the `forge` binary @@ -59,7 +59,7 @@ contract ComposableCow7702Test is BaseComposableCowTest { /// @dev secp256k1 group order. uint256 internal constant SECP256K1_N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141; - ComposableCow7702 internal impl; + CowAccount7702 internal impl; address internal eoa; uint256 internal eoaPk; @@ -67,7 +67,7 @@ contract ComposableCow7702Test is BaseComposableCowTest { super.setUp(); // deploy the shared delegate implementation - impl = new ComposableCow7702(composableCow); + impl = new CowAccount7702(composableCow); // create the delegating EOA and attach the EIP-7702 delegation // (real cheatcode: writes the 0xef0100 ++ impl designator) @@ -129,12 +129,12 @@ contract ComposableCow7702Test is BaseComposableCowTest { ORDER_TYPE ) ); - // ComposableCow7702's account domain: verifyingContract is the EOA + // CowAccount7702's account domain: verifyingContract is the EOA bytes32 structHash = keccak256( abi.encode( typedDataSignTypehash, contents, - keccak256("ComposableCow7702"), + keccak256("CowAccount7702"), keccak256("1"), block.chainid, eoa, @@ -360,7 +360,7 @@ contract ComposableCow7702Test is BaseComposableCowTest { // under EIP-7702, msg.sender == address(this) is a self-sent tx vm.prank(eoa); - ComposableCow7702(payable(eoa)).execute(MODE_SINGLE_BATCH, abi.encode(calls)); + CowAccount7702(payable(eoa)).execute(MODE_SINGLE_BATCH, abi.encode(calls)); assertTrue(composableCow.singleOrders(eoa, keccak256(abi.encode(params)))); assertEq(token0.allowance(eoa, address(relayer)), sellAmount); @@ -374,7 +374,7 @@ contract ComposableCow7702Test is BaseComposableCowTest { vm.prank(alice.addr); vm.expectRevert(); - ComposableCow7702(payable(eoa)).execute(MODE_SINGLE_BATCH, abi.encode(calls)); + CowAccount7702(payable(eoa)).execute(MODE_SINGLE_BATCH, abi.encode(calls)); } /** @@ -385,7 +385,7 @@ contract ComposableCow7702Test is BaseComposableCowTest { vm.prank(eoa); vm.expectRevert(); - ComposableCow7702(payable(eoa)).execute(MODE_SINGLE_BATCH_OPDATA, abi.encode(calls, bytes("op"))); + CowAccount7702(payable(eoa)).execute(MODE_SINGLE_BATCH_OPDATA, abi.encode(calls, bytes("op"))); } /** diff --git a/test/ComposableCow.7702proxy.t.sol b/test/ComposableCow.7702proxy.t.sol new file mode 100644 index 00000000..11ccc6d6 --- /dev/null +++ b/test/ComposableCow.7702proxy.t.sol @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity >=0.8.0 <0.9.0; + +import {EIP7702Proxy} from "solady/accounts/EIP7702Proxy.sol"; + +import "./ComposableCow.base.t.sol"; + +import {Account7702} from "../src/accounts/Account7702.sol"; +import {CowAccount7702} from "../src/accounts/CowAccount7702.sol"; + +/// @dev `signAndAttachDelegation` is supported by the `forge` binary but absent +/// from the vendored `forge-std` interface. +interface Vm7702Proxy { + struct SignedDelegation { + uint8 v; + bytes32 r; + bytes32 s; + uint64 nonce; + address implementation; + } + + function signAndAttachDelegation(address implementation, uint256 privateKey) + external + returns (SignedDelegation memory); +} + +/// @dev A second implementation, distinguishable from the first by its domain. +contract OtherAccount is Account7702 { + function _domainNameAndVersion() internal pure override returns (string memory, string memory) { + return ("OtherAccount", "1"); + } +} + +/** + * @dev The reason for delegating to a proxy rather than straight to an + * implementation: the implementation can be replaced without the EOA + * re-signing an EIP-7702 authorisation. + */ +contract ComposableCowProxyTest is BaseComposableCowTest { + address internal constant ADMIN = address(0xA11CE); + + CowAccount7702 internal cowImpl; + OtherAccount internal otherImpl; + EIP7702Proxy internal proxy; + + address internal eoa; + uint256 internal eoaPk; + + function setUp() public virtual override(BaseComposableCowTest) { + super.setUp(); + + cowImpl = new CowAccount7702(composableCow); + otherImpl = new OtherAccount(); + proxy = new EIP7702Proxy(address(cowImpl), ADMIN); + + (eoa, eoaPk) = makeAddrAndKey("composable-cow-7702-proxy-eoa"); + Vm7702Proxy(address(vm)).signAndAttachDelegation(address(proxy), eoaPk); + } + + /// @dev The delegation designator points at the proxy, not the implementation. + function test_proxy_DelegationTargetsProxy() public { + assertEq(eoa.code.length, 23, "delegation designator absent"); + assertEq(_proxyImplementation(), address(cowImpl)); + assertEq(_proxyAdmin(), ADMIN); + } + + /** + * @dev The whole point. The EOA signs one authorisation, pointing at the + * proxy. Changing the implementation afterwards changes the code the + * EOA runs, with no second authorisation and no transaction from the + * EOA at all. + */ + function test_proxy_UpgradeChangesBehaviourWithoutRedelegating() public { + bytes32 before = _domainSeparatorOf(eoa); + + vm.prank(ADMIN); + (bool ok,) = address(proxy).call(abi.encodeWithSignature("upgrade(address)", address(otherImpl))); + assertTrue(ok, "upgrade call failed"); + + assertEq(_proxyImplementation(), address(otherImpl)); + assertTrue(_domainSeparatorOf(eoa) != before, "EOA still runs the old implementation"); + assertEq(eoa.code.length, 23, "delegation should be untouched by an upgrade"); + } + + function test_proxy_UpgradeRejectsNonAdmin() public { + address impl = _proxyImplementation(); + + vm.prank(address(0xBAD)); + (bool ok,) = address(proxy).call(abi.encodeWithSignature("upgrade(address)", address(otherImpl))); + + // A non-admin call is forwarded rather than treated as an upgrade, so + // the implementation must be unchanged either way. + ok; // silence unused + assertEq(_proxyImplementation(), impl, "non-admin changed the implementation"); + } + + /// @dev ERC-1271 must work through the proxy exactly as it does direct. + function test_proxy_ERC1271WorksThroughProxy() public { + bytes32 separator = _domainSeparatorOf(eoa); + assertTrue(separator != bytes32(0), "EIP-712 domain unavailable through the proxy"); + } + + /// @dev The proxy keeps `implementation()` and `admin()` out of its public + /// ABI so it can forward all calldata, so they are read low level. + function _proxyImplementation() private view returns (address) { + (bool ok, bytes memory ret) = address(proxy).staticcall(abi.encodeWithSignature("implementation()")); + require(ok && ret.length >= 32, "implementation() failed"); + return abi.decode(ret, (address)); + } + + function _proxyAdmin() private view returns (address) { + (bool ok, bytes memory ret) = address(proxy).staticcall(abi.encodeWithSignature("admin()")); + require(ok && ret.length >= 32, "admin() failed"); + return abi.decode(ret, (address)); + } + + function _domainSeparatorOf(address account) private view returns (bytes32) { + (bool ok, bytes memory ret) = account.staticcall(abi.encodeWithSignature("eip712Domain()")); + if (!ok || ret.length < 32) return bytes32(0); + (, string memory name, string memory version, uint256 chainId, address verifyingContract,,) = + abi.decode(ret, (bytes1, string, string, uint256, address, bytes32, uint256[])); + return keccak256(abi.encode(keccak256(bytes(name)), keccak256(bytes(version)), chainId, verifyingContract)); + } +}