SimpleAccount
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 ACTIVEinitialize 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 onlyMoves 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 (upstreamBaseAccountusesexecuteBatch(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
| Event | Emitted 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
authStateinvalidateUserOpis 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 singleowner; tooling should now queryauthState(addr)andactivated()instead, and followOwnerRotatedevents. - 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.