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.
- Wallet state
Owner, nonce, and an EntryPoint reference. - validateUserOp
Check nonce, signature, and emit logs. - execute
Run arbitrary calls from EntryPoint; only after validation. - Paymaster
Allowlist users, cap sponsorship per request, and charge a fee. - 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.