Advanced

Account Abstraction (ERC-4337) — Project — minimal AA wallet + paymaster

Build smart wallets.

Developer Advanced
7/8 — Account Abstraction (ERC-4337) — Project — minimal AA wallet + paymaster

What to writeImplement a simple smart account and paymaster that work with EntryPoint.

Blockchains solve coordination problems, not just financial ones.
— Vitalik Buterin
  1. Wallet state
    Owner, nonce, and an EntryPoint reference.
  2. validateUserOp
    Check nonce, signature, and emit logs.
  3. execute
    Run arbitrary calls from EntryPoint; only after validation.
  4. Paymaster
    Allowlist users, cap sponsorship per request, and charge a fee.
  5. Withdrawals
    Let owner pull sponsored funds; prevent grief.

Start with the wallet core: state, nonce, and validation that protects replay and bad signatures.

contract MiniAAWallet is IAccount {
    using ECDSA for bytes32;
    address public owner;
    uint256 public nonce;
    IEntryPoint public immutable entryPoint;

    constructor(address _owner, address _ep) { owner = _owner; entryPoint = IEntryPoint(_ep); }

    function validateUserOp(UserOperation calldata op, bytes32 opHash, uint256 /*missingFunds*/ )
        external override returns (uint256 validationData)
    {
        require(msg.sender == address(entryPoint), "only ep");
        require(op.nonce == nonce, "bad nonce");
        bytes32 digest = keccak256(abi.encode(opHash, op.nonce)).toEthSignedMessageHash();
        address signer = digest.recover(op.signature);
        require(signer == owner, "bad sig");
        nonce++;
        emit Validated(opHash, op.nonce, signer);
        return 0;
    }

    function execute(address to, uint256 value, bytes calldata data) external {
        require(msg.sender == address(entryPoint), "only ep");
        (bool ok,) = to.call{value:value}(data);
        require(ok, "exec fail");
    }

    event Validated(bytes32 opHash, uint256 nonce, address signer);
}

Layer in the paymaster: sponsorship rules, caps, and withdrawal controls.

contract SimplePaymaster is IPaymaster {
    IEntryPoint public immutable entryPoint;
    mapping(address => bool) public allowlist;
    uint256 public maxSponsorship = 0.01 ether;
    address public owner;

    constructor(address _ep) { entryPoint = IEntryPoint(_ep); owner = msg.sender; }
    modifier onlyOwner(){ require(msg.sender == owner, "owner"); _; }

    function setAllow(address user, bool ok) external onlyOwner { allowlist[user] = ok; }
    function setMax(uint256 m) external onlyOwner { maxSponsorship = m; }
    function withdraw(address to) external onlyOwner { payable(to).transfer(address(this).balance); }

    function validatePaymasterUserOp(UserOperation calldata op, bytes32, uint256 maxCost)
        external view override returns (bytes memory context, uint256 validationData)
    {
        require(msg.sender == address(entryPoint), "only ep");
        require(allowlist[op.sender], "not allowed");
        require(maxCost <= maxSponsorship, "too expensive");
        return (abi.encode(op.sender), 0);
    }

    receive() external payable {}
}

Shows basic validation (signature + nonce) and a guarded paymaster with spend caps.

Full combined code (wallet + paymaster) for copy/paste:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import {IEntryPoint, UserOperation} from "@account-abstraction/contracts/interfaces/IEntryPoint.sol";
import {IAccount} from "@account-abstraction/contracts/interfaces/IAccount.sol";
import {IPaymaster} from "@account-abstraction/contracts/interfaces/IPaymaster.sol";

contract MiniAAWallet is IAccount {
    using ECDSA for bytes32;
    address public owner;
    uint256 public nonce;
    IEntryPoint public immutable entryPoint;

    event Validated(bytes32 opHash, uint256 nonce, address signer);

    constructor(address _owner, address _ep) { owner = _owner; entryPoint = IEntryPoint(_ep); }

    function validateUserOp(UserOperation calldata op, bytes32 opHash, uint256 /*missingFunds*/ )
        external override returns (uint256 validationData)
    {
        require(msg.sender == address(entryPoint), "only ep");
        require(op.nonce == nonce, "bad nonce");
        bytes32 digest = keccak256(abi.encode(opHash, op.nonce)).toEthSignedMessageHash();
        address signer = digest.recover(op.signature);
        require(signer == owner, "bad sig");
        nonce++;
        emit Validated(opHash, op.nonce, signer);
        return 0; // valid
    }

    function execute(address to, uint256 value, bytes calldata data) external {
        require(msg.sender == address(entryPoint), "only ep");
        (bool ok,) = to.call{value:value}(data);
        require(ok, "exec fail");
    }

    function entryPointView() external view returns (address) { return address(entryPoint); }
}

contract SimplePaymaster is IPaymaster {
    IEntryPoint public immutable entryPoint;
    mapping(address => bool) public allowlist;
    uint256 public maxSponsorship = 0.01 ether;
    address public owner;

    constructor(address _ep) { entryPoint = IEntryPoint(_ep); owner = msg.sender; }
    modifier onlyOwner(){ require(msg.sender == owner, "owner"); _; }

    function setAllow(address user, bool ok) external onlyOwner { allowlist[user] = ok; }
    function setMax(uint256 m) external onlyOwner { maxSponsorship = m; }
    function withdraw(address to) external onlyOwner { payable(to).transfer(address(this).balance); }

    function validatePaymasterUserOp(UserOperation calldata op, bytes32, uint256 maxCost)
        external view override returns (bytes memory context, uint256 validationData)
    {
        require(msg.sender == address(entryPoint), "only ep");
        require(allowlist[op.sender], "not allowed");
        require(maxCost <= maxSponsorship, "too expensive");
        return (abi.encode(op.sender), 0);
    }

    receive() external payable {}
}

Try itTests: bad sig/nonce reverts; replay blocked; paymaster overspend blocked; allowlist enforced; batch execute works.

← Previous section
Next section →