Skip to content

Signers & devices

Per-key auth state, rotation, and multi-device enrollment

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)-->  BURNED

There 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:

LengthAccount statePath
2,448 (FORS_SIG_LEN)activatedFORS+C: recover() must return an AUTH_ACTIVE key; that key is burned and nextOwner activated
2,451 + 32·proofLen!activatedActivation envelope: FORS+C signature plus a Merkle proof against initialSignerRoot
3,688 (SPHINCS_SIG_LEN)eitherSPHINCS: 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_ACTIVE

The 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

EventEmitted 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 not AUTH_ACTIVE, the local state is desynced (a dropped or replaced UserOp is the usual cause) and the wallet should reconcile against OwnerRotated events 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 nextOwner that is already active or burned makes validation revert, so the wallet must never recycle an address across devices or after recovery.