SimpleAccount
src/SimpleAccount.sol is the ERC-4337 smart account that uses standalone FORS+C as its primary signer. It extends the account-abstraction BaseAccount, verifies each UserOp through the ForsVerifier, and rotates its owner on every successful validation.
contract SimpleAccount is BaseAccount, TokenCallbackHandler, Initializable {
address public owner; // current signer commitment (20-byte FORS address)
bytes32 public initialSignerRoot; // Merkle root of permitted first signers
IEntryPoint public immutable ENTRY_POINT;
ISignatureVerifier public immutable VERIFIER;
}It is deployed once as an implementation and used through minimal-proxy clones created by the factory. The constructor sets the immutables and calls _disableInitializers(), so only clones can be initialized.
Lifecycle
clone created -> initialize(root) -> owner = address(0) (inactive)
first UserOp -> activation path -> owner = S_1 (active)
every UserOp -> normal path -> owner = nextOwner (rotates)initialize(bytes32 initialSignerRoot) is called once by the factory: it stores the root and sets owner = address(0), which is the "awaiting activation" state, not a usable owner.
Validation
Both paths share one rule first: the UserOp's calldata must end with the next owner.
require(userOp.callData.length >= 24, "SimpleAccount: missing next owner"); // 4-byte selector + 20-byte addr
address nextOwner = address(bytes20(userOp.callData[userOp.callData.length - 20:]));Normal path (owner != 0)
if (userOp.signature.length != FORS_SIG_LEN) return SIG_VALIDATION_FAILED;
address recovered = VERIFIER.recover(userOp.signature, userOpHash);
if (recovered == address(0) || recovered != owner) return SIG_VALIDATION_FAILED;
_rotateOwner(nextOwner);
return SIG_VALIDATION_SUCCESS;The signature is exactly a 2,448-byte FORS+C blob over userOpHash. On success the owner advances inside validation, so the spent key is retired regardless of whether execution later reverts.
Activation path (owner == 0)
The first UserOp proves the chain-local first signer is committed in initialSignerRoot. Its signature is an envelope:
[ version=1 (1 byte) ][ proofLen (2 bytes) ][ Merkle proof (32·proofLen) ][ FORS signature (2448) ]The account checks version == 1 and proofLen ≤ 64, recovers the signer from the inner FORS blob, rebuilds the leaf with block.chainid, and verifies the Merkle proof against the stored root before rotating:
bytes32 leaf = InitialSignerCommitment.initialSignerLeaf(block.chainid, recovered);
if (!MerkleProofLib.verify(proof, initialSignerRoot, leaf)) return SIG_VALIDATION_FAILED;
emit AccountActivated(initialSignerRoot, recovered, nextOwner);
_rotateOwner(nextOwner);Including chainid in the leaf prevents cross-chain proof reuse. See Multi-chain consistent addresses.
Rotation
function _rotateOwner(address nextOwner) internal {
require(nextOwner != address(0), "SimpleAccount: zero next owner");
address previous = owner;
owner = nextOwner;
emit OwnerRotated(previous, nextOwner);
}A single SSTORE to owner. The contract's only requirement on nextOwner is that it be nonzero: deriving it correctly is the wallet's responsibility (Signer).
Execution
Execution is gated by _requireForExecute(), inherited from BaseAccount. By default it permits only the EntryPoint (this account does not override it to allow self-calls):
execute(address target, uint256 value, bytes calldata data): single call.executeBatch(address[] targets, uint256[] values, bytes[] datas): a backward-compatible batch ABI kept for existing callers.
Deposit management
getDeposit(), addDeposit(), and withdrawDepositTo(...) wrap the account's EntryPoint stake. addDeposit / withdraw are restricted to the EntryPoint or the account itself; _payPrefund forwards any missingAccountFunds to the EntryPoint during validation.
Events
| Event | Emitted when |
|---|---|
AccountInitialized(entryPoint, initialSignerRoot, verifier) | clone initialized |
AccountActivated(initialSignerRoot, initialOwner, nextOwner) | first activation succeeds |
OwnerRotated(previousOwner, newOwner) | every rotation |
Notes & deviations
- State write during validation. Writing
ownerinvalidateUserOpis intentional and ERC-4337-conformant, but means a failed inner call still consumes a key (recoverable under the FORS+C reuse budget), and bundlers must include at most one pending UserOp per sender. See Standards → ERC-4337. - No EIP-1271. Signature verification mutates state (the rotation), so it cannot be exposed as the view function EIP-1271 requires. See Standards → EIP-7702 & related.
- Storage discipline. Validation touches only this account's own storage, satisfying ERC-7562.