Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 6 additions & 0 deletions foundry.lock
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,11 @@
"name": "main",
"rev": "11273c1f08eda18ed8ff49ec1d4abec5e451ff21"
}
},
"lib/solady": {
"tag": {
"name": "v0.1.26",
"rev": "acd959aa4bd04720d640bf4e6a5c71037510cc4b"
}
}
}
1 change: 1 addition & 0 deletions lib/solady
Submodule solady added at acd959
12 changes: 12 additions & 0 deletions script/deploy_GnosisStack.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand Down Expand Up @@ -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);
Expand All @@ -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));
}
}
2 changes: 1 addition & 1 deletion src/ERC1271Forwarder.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
89 changes: 89 additions & 0 deletions src/accounts/Account7702.sol
Original file line number Diff line number Diff line change
@@ -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 <mfw78@nxm.rs>
* @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;
}
}
64 changes: 64 additions & 0 deletions src/accounts/CowAccount7702.sol
Original file line number Diff line number Diff line change
@@ -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 <mfw78@nxm.rs>
* @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);
}
}
Loading
Loading