Signers & devices
Earlier NiceTry accounts stored a single owner address and overwrote it on every UserOp. The current SimpleAccount replaces that slot with a per-key authorisation state, which is what makes multiple devices, a co-equal backup signer, and recovery possible on one account.
Auth state
uint8 internal constant AUTH_NONE = 0; // never authorised
uint8 internal constant AUTH_ACTIVE = 1; // authorised, may sign exactly one UserOp
uint8 internal constant AUTH_BURNED = 2; // already used, never valid again
mapping(address => uint8) public authState;
bool public activated;Every FORS+C key is identified by its 20-byte address (last20(keccak256(pkSeed ‖ pkRoot)), the value the ForsVerifier recovers). A key moves through the states in one direction only:
NONE --(activation / rotation / addSigner)--> ACTIVE --(signs one UserOp)--> BURNEDThere is no way back: a burned key can never be re-activated, and a key that is already active cannot be "rotated into" again. Both are enforced by the next owner not fresh check in _rotate and addSigner.
activated is a separate flag: it is false from deployment until the first successful activation, SPHINCS bootstrap, or addSigner call. While it is false, plain FORS+C signatures are rejected and the account only accepts the activation envelope or a SPHINCS signature.
Rotation
Every successful validation ends in _rotate(current, next):
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);
}For a FORS+C op, current is the key the verifier recovered (it must be AUTH_ACTIVE) and next is the 20-byte nextOwner at the end of userOp.callData. The rotation is written inside validateUserOp, so the signing key is retired even if the inner call later reverts. Deriving a correct, fresh next key is the wallet's responsibility; the contract only checks that it is nonzero and has never been seen.
Signature dispatch
The account has no signature type tag. It routes purely on userOp.signature.length:
| Length | Account state | Path |
|---|---|---|
2,448 (FORS_SIG_LEN) | activated | FORS+C: recover() must return an AUTH_ACTIVE key; that key is burned and nextOwner activated |
| 2,451 + 32·proofLen | !activated | Activation envelope: FORS+C signature plus a Merkle proof against initialSignerRoot |
3,688 (SPHINCS_SIG_LEN) | either | SPHINCS: verified against the committed backup key; re-seeds the FORS chain |
The three length classes are disjoint, and a require in the SimpleAccount constructor plus a Foundry test keep them so: SPHINCS_SIG_LEN must differ from FORS_SIG_LEN and must not equal 2,451 + 32·k for any 0 ≤ k ≤ 64. A future change to any of the three sizes therefore cannot silently mis-route a signature to the wrong verifier.
The calldata tail differs by path:
FORS / activation : userOp.callData = [ any account call ][ 20 bytes nextOwner ]
SPHINCS : userOp.callData = [ any account call ][ 20 bytes currentKey ][ 20 bytes nextOwner ]ABI decoding ignores trailing bytes, so the appended addresses do not disturb the execute / executeBatch call in front of them, while userOpHash still commits to them.
Activation
A freshly deployed account has activated == false and no active keys. The first UserOp on a committed chain must carry the activation envelope:
offset length field
0 1 activationVersion = 1
1 2 proofLen = uint16_be(number of proof siblings), ≤ 64
3 32·proofLen Merkle proof siblings
3+32·n 2448 FORS+C signature over EntryPoint.getUserOpHash(userOp)The account recovers the FORS+C signer from the inner blob, rebuilds the leaf initialSignerLeaf(block.chainid, recovered), verifies the proof against initialSignerRoot, and then sets:
activated = true
authState[nextOwner] = AUTH_ACTIVEThe first signer is consumed by activation and never kept: it never becomes AUTH_ACTIVE, so it cannot sign a normal op afterwards. With the canonical 256-leaf tree, proofLen = 8 and the whole envelope is 2,707 bytes. How the root and proofs are built is on the Multichain addresses page.
On a chain that was not committed in the root there is no proof to give. The account is bootstrapped with a SPHINCS signature instead.
Multiple devices
Because state is per key, two devices can each run an independent rotation chain on the same account. Device A burns and activates keys from its own stream, device B from its own, and neither invalidates the other. The only cross-device rule is that no key address may ever be reused, which holds automatically when each device derives from its own salted key stream (see the protocol spec's multi-wallet section).
Enrolling a device
function addSigner(address newSigner) external {
_requireFromEntryPointOrSelf();
require(newSigner != address(0), "SimpleAccount: zero signer");
require(authState[newSigner] == AUTH_NONE, "SimpleAccount: signer not fresh");
authState[newSigner] = AUTH_ACTIVE;
if (!activated) activated = true;
emit SignerAdded(newSigner);
}addSigner only adds (NONE → ACTIVE). It never burns and never rotates an existing chain. It is callable by the EntryPoint or by the account itself, which in practice means: any validated UserOp (FORS+C or SPHINCS) can enroll a device by calling addSigner through its own execute.
Derive on the new device
Device B derives its first FORS+C keypair S^B_0 and shares its 20-byte address with device A (a QR code, a pairing message, anything: the address is public).
Enroll from an existing device
Device A sends a normal FORS+C UserOp whose call is execute(address(this), 0, abi.encodeCall(addSigner, (addr(S^B_0)))), with device A's own nextOwner appended as usual. Validation burns A's current key and activates A's next key; execution activates S^B_0.
Sign independently
Device B now signs its own UserOps with S^B_0, appending S^B_1, and so on. Device A keeps going with its own chain. The SignerAdded event records the enrollment.
Enrollment also flips activated, so a pre-activation SPHINCS op on an uncommitted chain can enroll the first device in the same UserOp.
Events
| Event | Emitted when |
|---|---|
AccountInitialized(entryPoint, initialSignerRoot, verifier) | clone initialised by the factory |
AccountActivated(initialSignerRoot, initialOwner, nextOwner) | Merkle-gated activation succeeds |
OwnerRotated(previousOwner, newOwner) | every rotation (activation emits it with previousOwner = 0) |
BackupSignerUsed(userOpHash) | a SPHINCS signature authorised an op |
SignerAdded(signer) | a device was enrolled via addSigner |
What a wallet must track
- Which of its keys is active. Read
authState(addr)before signing; if the expected key is notAUTH_ACTIVE, the local state is desynced (a dropped or replaced UserOp is the usual cause) and the wallet should reconcile againstOwnerRotatedevents before signing anything else. - Per-key reuse count. Re-signing a different digest with the same FORS+C key is allowed only inside the bounded reuse budget. Rebroadcasting the identical signed UserOp is always safe.
- Fresh next keys. A
nextOwnerthat is already active or burned makes validation revert, so the wallet must never recycle an address across devices or after recovery.