Skip to content

SimpleAccount

The ERC-4337 FORS account

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

EventEmitted 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 owner in validateUserOp is 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.