Skip to content

Security

Account Abstraction (ERC-4337) and Smart-Contract Wallets: The Custody Architecture of 2026

ERC-4337 account abstraction for institutional custody: UserOperation flow, session keys, social recovery, gas abstraction, and how ZeroCopy uses it for on-chain settlement.

11 min
#erc4337 #account-abstraction #smart-contracts #custody #session-keys #social-recovery

ZeroCopy’s on-chain settlement layer uses ERC-4337 patterns. When I designed this, account abstraction was not yet a widely-understood architectural choice for institutional trading - most teams were still thinking in EOA (Externally Owned Account) terms. Two years later, ERC-4337 is production-deployed across most major wallet providers, and its custody properties are significantly better than EOA for trading use cases.

This post covers the full ERC-4337 architecture, explains why it matters for institutional custody specifically, and walks through the session key pattern that ZeroCopy uses for automated trading authorization.

What Account Abstraction Means

In standard Ethereum, there are two types of accounts:

  • EOA (Externally Owned Account): controlled by a private key. Authorization = valid signature from that key.
  • Contract account: controlled by code. Authorization = whatever the code says.

EOAs have a fundamental limitation: the authorization logic is hardcoded. If you want “3-of-5 multisig,” you have to use a multisig contract and coordinate your EOA signatures off-chain. If you want “time-limited authorization,” you cannot express that at the account level.

Account abstraction makes every account a smart contract. The authorization logic - who can authorize transactions, under what conditions, with what limits - is defined in code. You can program custody logic, spending limits, session keys, time locks, and social recovery directly into the account.

ERC-4337 achieves account abstraction without a protocol change. It introduces a new mempool of UserOperation objects, a network of bundlers that aggregate UserOperations into standard transactions, and a singleton EntryPoint contract that validates and executes them.

The ERC-4337 Architecture

UserOperation (off-chain, signed by authorized key)

Alt-mempool (separate from standard tx mempool)
    ↓ aggregated by
Bundler (any node running ERC-4337 bundler software)
    ↓ calls
EntryPoint.handleOps([UserOperation, ...])
    ↓ for each UserOperation:
    ├── Call account.validateUserOp() - account validates the operation
    ├── (optional) Call paymaster.validatePaymasterUserOp() - paymaster covers gas
    └── Call account.execute() - execute the operation if valid

UserOperation structure:

struct UserOperation {
    address sender;          // The smart account
    uint256 nonce;           // Anti-replay
    bytes initCode;          // For first deployment (empty if account exists)
    bytes callData;          // The actual operation to execute
    uint256 callGasLimit;
    uint256 verificationGasLimit;
    uint256 preVerificationGas;
    uint256 maxFeePerGas;
    uint256 maxPriorityFeePerGas;
    bytes paymasterAndData;  // Optional paymaster (covers gas for user)
    bytes signature;         // Signature over the UserOperation hash
}

The key innovation: validateUserOp() is a function on your smart account contract. You can write any validation logic there. Standard ECDSA signature check. Multi-signature check. Session key check. FIDO2 WebAuthn verification. On-chain oracle attestation. Anything the EVM can compute.

Session Keys: The Trading Desk Use Case

Session keys solve a specific custody problem: a trading system needs to sign many transactions per minute automatically, but you do not want to give it access to the master key.

Traditional approach: give the automated system a dedicated EOA with a limited balance. The EOA can spend only what it holds. But you have to manually top it up, and if the EOA private key is compromised, the attacker can drain the limited balance.

Session key approach: register a session key on your smart account with embedded policy constraints. The session key can sign UserOperations, but the account’s validateUserOp checks the session key’s policy before executing:

// SessionKeyModule.sol - module for ERC-4337 smart accounts
// (Compatible with Safe{Wallet}'s module system or Alchemy's LightAccount)

struct SessionKey {
    address key;                  // The session key's address
    uint256 validUntil;           // Expiry timestamp
    uint256 validAfter;           // Not valid before this timestamp
    uint256 maxValuePerTx;        // Max ETH value per transaction (in wei)
    uint256 maxValuePerPeriod;    // Max ETH value per period (cumulative)
    uint256 periodLength;         // Period length in seconds (e.g., 86400 for daily)
    address[] allowedTargets;     // Allowed destination contracts
    bytes4[] allowedFunctions;    // Allowed function selectors
    bool active;
}

contract SessionKeyModule {
    mapping(address => mapping(address => SessionKey)) public sessionKeys;
    // account => sessionKey => SessionKey

    mapping(address => mapping(address => uint256)) public periodSpend;
    // account => sessionKey => spend in current period

    mapping(address => mapping(address => uint256)) public periodStart;
    // account => sessionKey => start of current period

    function validateSessionKey(
        address account,
        address sessionKey,
        address target,
        uint256 value,
        bytes calldata data
    ) external view returns (bool) {
        SessionKey storage sk = sessionKeys[account][sessionKey];

        // Check active
        if (!sk.active) return false;

        // Check time bounds
        if (block.timestamp > sk.validUntil) return false;
        if (block.timestamp < sk.validAfter) return false;

        // Check per-transaction value limit
        if (value > sk.maxValuePerTx) return false;

        // Check target allowlist
        if (!_isAllowedTarget(sk, target)) return false;

        // Check function selector allowlist
        if (data.length >= 4 && !_isAllowedFunction(sk, bytes4(data[:4]))) return false;

        // Check period spend limit
        uint256 currentPeriodSpend = _getCurrentPeriodSpend(account, sessionKey, sk);
        if (currentPeriodSpend + value > sk.maxValuePerPeriod) return false;

        return true;
    }

    function registerSessionKey(
        address sessionKey,
        uint256 validUntil,
        uint256 maxValuePerTx,
        uint256 maxValuePerPeriod,
        uint256 periodLength,
        address[] calldata allowedTargets,
        bytes4[] calldata allowedFunctions
    ) external {
        // Only callable by the account itself (via UserOperation)
        sessionKeys[msg.sender][sessionKey] = SessionKey({
            key: sessionKey,
            validUntil: validUntil,
            validAfter: block.timestamp,
            maxValuePerTx: maxValuePerTx,
            maxValuePerPeriod: maxValuePerPeriod,
            periodLength: periodLength,
            allowedTargets: allowedTargets,
            allowedFunctions: allowedFunctions,
            active: true,
        });

        emit SessionKeyRegistered(msg.sender, sessionKey, validUntil);
    }
}

For a trading desk, the session key might have:

  • maxValuePerTx: $50,000 (no single automated transaction above this)
  • validUntil: 8 hours from now (trading session)
  • allowedTargets: [exchange_contract, bridge_contract, settlement_contract]
  • allowedFunctions: [swap(), deposit(), withdraw()] - no transfer() to arbitrary addresses

The session key can sign thousands of transactions per hour within these limits. If the session key is compromised, the attacker can only execute transactions within the policy bounds for the remaining session duration. When the session expires, the key is worthless.

Generating and using a session key:

// session-key-manager.ts
import { ethers } from 'ethers';
import type { SessionKeyModule } from './typechain';

interface SessionKeyConfig {
  maxValuePerTxUSD: number;
  maxValuePerDayUSD: number;
  allowedTargets: string[];
  durationHours: number;
}

async function createTradingSessionKey(
  smartAccount: string,
  module: SessionKeyModule,
  config: SessionKeyConfig,
  ethPriceUSD: number,
): Promise<{ sessionPrivateKey: string; sessionAddress: string }> {
  // Generate ephemeral session key
  const sessionWallet = ethers.Wallet.createRandom();

  const maxValuePerTxWei = ethers.parseEther(
    (config.maxValuePerTxUSD / ethPriceUSD).toFixed(18)
  );
  const maxValuePerDayWei = ethers.parseEther(
    (config.maxValuePerDayUSD / ethPriceUSD).toFixed(18)
  );
  const validUntil = Math.floor(Date.now() / 1000) + config.durationHours * 3600;

  // Register session key on the smart account
  // This UserOperation is signed by the master key
  const tx = await module.registerSessionKey(
    sessionWallet.address,
    validUntil,
    maxValuePerTxWei,
    maxValuePerDayWei,
    86400, // 24-hour period
    config.allowedTargets,
    ['0x...'],  // allowed function selectors
  );
  await tx.wait();

  return {
    sessionPrivateKey: sessionWallet.privateKey,
    sessionAddress: sessionWallet.address,
  };
}

Social Recovery: Eliminating the Seed Phrase Catastrophe

Traditional EOA custody has a binary outcome: you have the private key, or you have lost your funds permanently. BIP-39 mnemonics are a mitigation, but they introduce their own risks (physical theft, loss, miscopying).

Smart accounts can implement social recovery: a set of guardians (people or contracts) who can collectively rotate the account’s authorization keys if the original key is lost or compromised.

// SocialRecovery.sol
contract SocialRecovery {
    struct Recovery {
        address[] newOwners;
        uint256 guardiansApproved;
        uint256 executeAfter;  // Time lock
    }

    mapping(address => address[]) public guardians;  // account => guardians
    mapping(address => Recovery) public pendingRecoveries;
    mapping(address => mapping(address => bool)) public guardianApprovals;

    uint256 constant RECOVERY_TIMELOCK = 2 days;

    function initiateRecovery(
        address account,
        address[] calldata newOwners
    ) external {
        require(_isGuardian(account, msg.sender), "Not a guardian");

        pendingRecoveries[account] = Recovery({
            newOwners: newOwners,
            guardiansApproved: 1,
            executeAfter: block.timestamp + RECOVERY_TIMELOCK,
        });

        guardianApprovals[account][msg.sender] = true;
    }

    function approveRecovery(address account) external {
        require(_isGuardian(account, msg.sender), "Not a guardian");
        require(!guardianApprovals[account][msg.sender], "Already approved");

        guardianApprovals[account][msg.sender] = true;
        pendingRecoveries[account].guardiansApproved++;
    }

    function executeRecovery(address account) external {
        Recovery storage recovery = pendingRecoveries[account];

        uint256 threshold = _getGuardianThreshold(account);
        require(recovery.guardiansApproved >= threshold, "Not enough approvals");
        require(block.timestamp >= recovery.executeAfter, "Timelock not elapsed");

        // Execute: rotate the account's owner keys to recovery.newOwners
        // This depends on the specific account implementation
        _executeKeyRotation(account, recovery.newOwners);

        delete pendingRecoveries[account];
    }
}

The 2-day time lock is critical: it gives the legitimate owner time to cancel a fraudulent recovery attempt (if their guardians are targeted by social engineering). For institutional accounts, this time lock might be 7 days and require more guardians.

Gas Abstraction: Paymasters for Seamless Trading

Paymasters allow a third party to pay gas fees on behalf of a user. For trading infrastructure:

  • The exchange or protocol can sponsor gas for users (UX improvement)
  • Gas can be paid in ERC-20 tokens rather than ETH (users do not need ETH to trade)
  • Session keys can operate even if the smart account’s ETH balance is zero
// GasPaymaster.sol - example paymaster for trading session keys
contract TradingPaymaster is IPaymaster {

    function validatePaymasterUserOp(
        UserOperation calldata userOp,
        bytes32 userOpHash,
        uint256 maxCost
    ) external returns (bytes memory context, uint256 validationData) {
        // Verify the UserOperation is from a registered trading account
        require(_isRegisteredTrader(userOp.sender), "Unknown trader account");

        // Check the paymaster's deposit is sufficient
        require(_getDeposit() >= maxCost, "Insufficient paymaster deposit");

        // Approve gas sponsorship
        context = abi.encode(userOp.sender, maxCost);
        validationData = 0;  // No signature or time bounds needed here
    }

    function postOp(
        PostOpMode mode,
        bytes calldata context,
        uint256 actualGasCost
    ) external {
        (address trader, ) = abi.decode(context, (address, uint256));

        // Charge the trader's USDC balance for gas
        uint256 gasCostUSDC = _ethToUSDC(actualGasCost);
        _deductFromTraderBalance(trader, gasCostUSDC);
    }
}

Security Considerations

Bundler censorship. Bundlers are permissioned nodes that aggregate UserOperations. A bundler can choose to exclude your UserOperation. For censorship resistance, use a UserOperation broadcast mechanism that reaches multiple bundlers, or run your own bundler. For trading applications where latency matters, running a private bundler is the correct choice.

EntryPoint contract risk. All ERC-4337 accounts depend on the singleton EntryPoint contract. The EntryPoint has been audited extensively (multiple rounds by OpenZeppelin, Certik, others) but a critical vulnerability in EntryPoint would affect all smart accounts simultaneously. Mitigation: use the canonical EntryPoint deployment (immutable), monitor for governance proposals to upgrade it.

validateUserOp gas limit. The ERC-4337 spec limits the gas available to validateUserOp. Complex validation logic (e.g., on-chain signature verification, oracle checks) must fit within this limit. For session key validation, the implementation above is within typical limits. For more complex policies, benchmark gas costs carefully.

How This Breaks in Production

Failure 1: Session key not revoked after compromise. A trading bot’s session key is compromised. The key has 6 hours remaining on its validity window. The attacker drains the per-day limit ($50K) before the session expires. Fix: add a key revocation mechanism and wire it to your security monitoring. When unusual session key activity is detected (activity pattern deviating from normal bot behavior), auto-revoke the session key via a master key UserOperation.

Failure 2: Paymaster running out of EntryPoint deposit. The paymaster contract holds a deposit in the EntryPoint. Under a high-volume trading session, the deposit is depleted faster than expected. UserOperations start failing because the paymaster’s deposit is insufficient. Fix: monitor paymaster deposit levels continuously. Auto-top-up from a reserve when deposit drops below 24-hour expected usage.

Failure 3: Social recovery guardian unavailable for recovery. Your social recovery requires 3-of-5 guardians. An urgent recovery is needed (key compromise detected). Two guardians are unreachable (vacation, phone off). Recovery is blocked until they respond. Fix: include at least one institutional guardian (a trusted custody provider or a law firm) that has 24/7 availability for recovery approvals. Design the guardian set for availability, not just trust.

Failure 4: Bundler simulation not matching on-chain execution. The bundler simulates the UserOperation and accepts it into the alt-mempool. By the time it is included in a block, on-chain state has changed (price oracle updated, token balance moved). The validation that passed in simulation fails on-chain. The bundler includes the failed UserOperation in a bundle, paying gas for a failed operation. Fix: for time-sensitive validations (oracle price checks), add a slippage tolerance in the validation logic. Operations that depend on rapidly-changing on-chain state should validate conservatively.

Failure 5: Account upgrade vulnerability. Your smart account is upgradeable (UUPS or Transparent Proxy). An attacker who compromises one guardian initiates a recovery to rotate the owners, then uses the new owner key to upgrade the account implementation to a malicious contract that allows arbitrary transfers. Fix: account upgrades should require a higher threshold than regular operations and a longer time lock. Consider non-upgradeable accounts for cold storage - simplicity eliminates upgrade attack surface.

Failure 6: callData not validated in session key policy. The session key policy allows calls to the DEX contract. But the policy does not validate the callData beyond the function selector. The attacker crafts a valid-looking swap() callData that encodes a swap with an extremely unfavorable slippage tolerance - effectively gifting value to their MEV bot. Fix: session key policies for DEX interactions should validate callData fields beyond the function selector - specifically, minimum output amounts and maximum slippage parameters.


Related reading: AWS Nitro Enclaves for Wallet Signing covers how ZeroCopy’s TEE signing service integrates with session key management. Hot-Warm-Cold Wallet Tiering covers where ERC-4337 smart accounts fit in the broader tiering architecture.

Continue Reading

Enjoyed this?

Get one deep infrastructure insight per week.

Free forever. Unsubscribe anytime.

You're in. Check your inbox.