Minimal smart account for delegated EOAs (EIP-7702), targeting ERC-4337 EntryPoint v0.8 and v0.9.
An EOA delegates its code to HermesDelegateV1 and gains smart-account capabilities — call batching, gas abstraction through ERC-4337, typed-data authorization, ERC-1271/ERC-7739 signature verification — while keeping its address and an EOA-like signature lifecycle. The delegate holds no mutable storage: the only mutable state lives in the HermesV1 manager singleton (a per-account nonce), so re-delegation never leaves stale storage behind.
| Contract | Purpose |
|---|---|
HermesDelegateV1.sol |
The EIP-7702 delegate implementation. ERC-4337 account (IAccount), ERC-1271/ERC-7739 verifier, ERC-7821 batch executor, ERC-5267 domain. |
HermesBase.sol |
Token-receiver callbacks (ERC-721/1155/777/677), ERC-165 and the ERC-7779 surface (accountId, accountStorageBases). |
HermesV1.sol |
Manager singleton (IHermesNonce): per-account incrementing nonce used as replay protection for signature-based execution. |
interfaces/IHermesNonce.sol |
Minimal manager interface (useNonce) the delegate pins as an immutable dependency. |
interfaces/IERC7779.sol |
ERC-7779 account-introspection interface. |
All execution goes through the single standard ERC-7821 entry point execute(bytes32 mode, bytes executionData), with two supported modes. The three authorization schemes map onto them:
| Flow | Mode | Authorization | Replay protection |
|---|---|---|---|
| ERC-4337 | validateUserOp → execute (no opData) |
EOA signature over userOpHash, checked in validateUserOp; execute is callable by a trusted EntryPoint (v0.8/v0.9) |
EntryPoint nonce |
| Self-call | execute (no opData) |
msg.sender == address(this) (the delegated EOA sends a tx to itself) |
The EOA's own tx nonce |
| Signature-based | execute (with opData) |
EIP-712 Execute(bytes32 mode, Call[] calls, uint256 nonce) signature carried in opData; any relayer may submit |
Hermes manager nonce |
- No-
opDatamode (0x01000000000000000000…) —executionDataisabi.encode(Call[]); authorized bymsg.sender(the account itself or a trusted EntryPoint). opDatamode (0x01000000000078210001…) —executionDataisabi.encode(Call[], opData), whereopDatais the EOA's signature;msg.sender-agnostic.
Notes on the design:
- Standard execution surface. Exposing ERC-7821 (
execute+supportsExecutionMode) lets standard tooling drive the account — e.g. an EIP-5792wallet_sendCallsbatch routes through the no-opDatamode. - Validation/execution coupling (4337).
validateUserOprejects any userOp whose callData does not targetexecute, and the no-opDatamode only authorizes a trusted EntryPoint (or self). EntryPoint's protocol guarantees validation runs before execution, so no transient flag is needed. - Fully-typed signing. The
opDatapath is authorized by a canonical EIP-712 struct, soeth_signTypedData_v4wallets render every target, value and calldata at signing time — no opaque hashes. - EOA-like signature lifecycle. Signatures have no expiry; a pending signature is cancelled by consuming its nonce, exactly like replacing a pending EOA transaction. The nonce is consumed before any external call, so a signature cannot be replayed by reentering from a target.
- Re-delegation safety. The EIP-712 domain uses the implementation address as
salt, pinning every signature to the specific delegate version. Re-delegating the EOA to different code invalidates all outstanding signatures. - Defensive ERC-1271 (ERC-7739). Contract-signature checks use ERC-7739 nested rehashing — the two standard forms
TypedDataSign(typed data) andPersonalSign(an EIP-191 mimic) — built on OpenZeppelin's auditedERC7739Utils. The request is re-nested under this account's EIP-712 domain (including the impl-pinningsalt), so a signature for one account, domain or protocol never validates here while the signed content stays human-readable. The empty-signature probe (hash = 0x7739…7739) returns the ERC-7739 detection magic0x77390001. - Discoverable domain. ERC-5267
eip712Domain()exposes the full domain (including the non-standardsalt), so signers/tooling can reconstruct the signing domain on-chain instead of hardcoding it per deployment. - EntryPoints. Two canonical EntryPoints are trusted as callers — v0.8 (
0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108) and v0.9 (0x433709009B8330FDa32311DF1C2AFA402eD8D009) — at the same addresses on all supported chains.entryPoint()reports v0.9 for discovery andisSupportedEntryPointprobes the set. v0.7 is intentionally excluded: itsuserOpHashis a plain keccak rather than an EIP-712 typed hash. Each EntryPoint binds its own address as the EIP-712verifyingContract, so a userOp signature never replays across the two.
Toolchain: Hardhat, Solidity 0.8.35 (viaIR, Prague EVM), TypeChain, ethers v6.
yarn install
yarn compile # hardhat compile (+ ABI export to reports/abi)
yarn hardhat test # full suite on the in-process Hardhat network
yarn coverage # solidity-coverage
yarn size # contract size report
REPORT_GAS=true yarn hardhat test # gas report to reports/gasAgainst a local node:
yarn lh # start a Hardhat node on 127.0.0.1:8545
yarn test:lh # run the tests against itTests live in test/: HermesDelegateV1.test.ts covers the three flows, ERC-1271 and the EIP-712 encoding against reference implementations; GasComparison.test.ts benchmarks the execution paths.