Skip to content

SimpleAccount

The ERC-4337 account

src/SimpleAccount.sol is the ERC-4337 smart account that uses standalone FORS+C as its primary signer and SPHINCS as a durable, co-equal backup. It extends the account-abstraction BaseAccount, routes each UserOp signature to the right verifier by its length, and burns the signing key and activates the next one on every successful validation.

contract SimpleAccount is BaseAccount, TokenCallbackHandler, Initializable {
    mapping(address => uint8) public authState;   // 0 = NONE, 1 = ACTIVE, 2 = BURNED
    bool public activated;                        // false until first activation / bootstrap
    bytes32 public initialSignerRoot;             // Merkle root of permitted first signers
    bytes32 public backupPkSeed;                  // SPHINCS backup key (never rotated)
    bytes32 public backupPkRoot;
    IEntryPoint public immutable ENTRY_POINT;
    ISignatureVerifier public immutable VERIFIER;          // ForsVerifier
    ISphincsVerifier public immutable SPHINCS_VERIFIER;    // SphincsVerifier
}

It is deployed once as an implementation and used through minimal-proxy clones created by the factory. (For the stateless sibling with a single SPHINCS key and none of the machinery below, see SPHINCS-only account.) The constructor sets the immutables, checks the length-dispatch disjointness invariant, and calls _disableInitializers(), so only clones can be initialised.

Lifecycle

clone created  -> initialize(root, backupPkSeed, backupPkRoot) -> activated = false, no active key
first UserOp   -> activation envelope (committed chain)        -> activated = true, nextOwner ACTIVE
               -> or SPHINCS bootstrap (any chain)            -> activated = true, nextOwner ACTIVE
every UserOp   -> FORS+C or SPHINCS                            -> current BURNED, nextOwner ACTIVE

initialize is called once by the factory. It rejects a zero root, a zero backup key, and a non-canonical backup key (low 128 bits must be zero, matching the verifier's N_MASK), then stores all three and emits AccountInitialized. Because the backup key is also in the CREATE2 salt, a clone at a given address can only ever be initialised with the key that address commits to.

Validation

_validateSignature first reads the last 20 bytes of userOp.callData as nextOwner (calldata shorter than 24 bytes reverts), then branches on signature length:

if (userOp.signature.length == SPHINCS_SIG_LEN)          // 3,688
    return _validateSphincsSignature(...);
if (!activated)
    return _validateActivationSignature(...);            // 2,451 + 32·proofLen
if (userOp.signature.length != FORS_SIG_LEN)             // 2,448
    return SIG_VALIDATION_FAILED;

FORS+C path (activated)

address recovered = VERIFIER.recover(userOp.signature, userOpHash);
if (recovered == address(0) || authState[recovered] != AUTH_ACTIVE) return SIG_VALIDATION_FAILED;
_rotate(recovered, nextOwner);
return SIG_VALIDATION_SUCCESS;

The signature is exactly a 2,448-byte FORS+C blob over userOpHash. Any key that is AUTH_ACTIVE may sign, which is what allows several devices to run independent chains. On success the recovered key is burned and nextOwner activated inside validation, so the spent key is retired regardless of whether execution later reverts.

Activation path (!activated)

The first UserOp on a committed chain proves the chain-local first signer is in initialSignerRoot:

[ version=1 (1 byte) ][ proofLen (2 bytes, ≤ 64) ][ Merkle proof (32·proofLen) ][ FORS+C signature (2,448) ]

The account checks the version and proof length, recovers the signer from the inner FORS+C blob, rebuilds the leaf with block.chainid, and verifies the proof against the stored root:

bytes32 leaf = InitialSignerCommitment.initialSignerLeaf(block.chainid, recovered);
if (!MerkleProofLib.verify(proof, initialSignerRoot, leaf)) return SIG_VALIDATION_FAILED;
require(nextOwner != address(0) && authState[nextOwner] == AUTH_NONE);
activated = true;
authState[nextOwner] = AUTH_ACTIVE;
emit AccountActivated(initialSignerRoot, recovered, nextOwner);
emit OwnerRotated(address(0), nextOwner);

The first signer is consumed by activation and never becomes active itself. Including chainid in the leaf prevents cross-chain proof reuse. See Multichain addresses.

SPHINCS path (either state)

require(callData.length >= 44);                                   // 4 selector + 20 current + 20 next
address currentKey = address(bytes20(callData[callData.length - 40 : callData.length - 20]));
if (!SPHINCS_VERIFIER.verify(backupPkSeed, backupPkRoot, userOpHash, signature)) return SIG_VALIDATION_FAILED;
emit BackupSignerUsed(userOpHash);
if (!activated) activated = true;
_rotate(currentKey, nextOwner);
return SIG_VALIDATION_SUCCESS;

A valid backup signature authorises the op and re-seeds the FORS+C chain: currentKey is burned even though it never signed, and nextOwner is activated. The backup key is never rotated. Because the length is guaranteed and the stored key is canonical, verify returns a boolean here and can never revert. Flows: Recovery & bootstrap.

Rotation

function _rotate(address current, address next) internal {
    require(next != address(0), "SimpleAccount: zero next owner");
    require(authState[next] == AUTH_NONE, "SimpleAccount: next owner not fresh");
    authState[current] = AUTH_BURNED;
    authState[next] = AUTH_ACTIVE;
    emit OwnerRotated(current, next);
}

Two SSTOREs. The freshness check is what makes the state machine one-way: a burned key can never be re-activated, and an active key cannot be clobbered. Deriving nextOwner correctly is the wallet's responsibility (Signer).

Device enrollment

function addSigner(address newSigner) external;   // EntryPoint or self only

Moves newSigner from NONE to ACTIVE without burning or rotating anything, and flips activated if needed. In practice it is called by the account on itself through execute, inside any validated UserOp. See Signers & devices.

Execution

execute and executeBatch are gated by _requireForExecute(), which this account resolves to EntryPoint only (its _requireFromEntryPoint override requires msg.sender == ENTRY_POINT):

  • 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 (upstream BaseAccount uses executeBatch(Call[])).

Calls the account makes to itself from inside execute (such as addSigner) pass the EntryPoint-or-self guards.

Deposit management

getDeposit(), addDeposit(), and withdrawDepositTo(...) wrap the account's EntryPoint stake. addDeposit / withdrawDepositTo are restricted to the EntryPoint or the account itself; _payPrefund forwards any missingAccountFunds to the EntryPoint during validation. The account also accepts plain ETH through receive().

Events

EventEmitted when
AccountInitialized(entryPoint, initialSignerRoot, verifier)clone initialised
AccountActivated(initialSignerRoot, initialOwner, nextOwner)Merkle-gated activation succeeds
OwnerRotated(previousOwner, newOwner)every rotation, including activation (previousOwner = 0) and SPHINCS re-seeding
BackupSignerUsed(userOpHash)a SPHINCS signature authorised an op
SignerAdded(signer)a device was enrolled via addSigner

Notes & deviations

  • State write during validation. Writing authState 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 owner() getter. Earlier versions exposed a single owner; tooling should now query authState(addr) and activated() instead, and follow OwnerRotated events.
  • No EIP-1271. Signature verification mutates state (the rotation), so it cannot be exposed as the view function EIP-1271 requires. See Standards.
  • Storage discipline. Validation touches only this account's own storage plus staticcalls into the two stateless verifiers, satisfying ERC-7562.