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/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/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/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 new file mode 100644 index 00000000..44c17736 --- /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 {CowAccount7702} from "../src/accounts/CowAccount7702.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; + + CowAccount7702 internal impl; + address internal eoa; + uint256 internal eoaPk; + + function setUp() public virtual override(BaseComposableCowTest) { + super.setUp(); + + // deploy the shared delegate implementation + impl = new CowAccount7702(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 + ) + ); + // CowAccount7702's account domain: verifyingContract is the EOA + bytes32 structHash = keccak256( + abi.encode( + typedDataSignTypehash, + contents, + keccak256("CowAccount7702"), + 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); + 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); + } + + /** + * @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(); + CowAccount7702(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(); + CowAccount7702(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); + } +} 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)); + } +}