Skip to content

FORS+C Signer

Byte-level signing specification

This page specifies what an offchain signer must produce so that its signatures recover the correct key address in ForsVerifier and are accepted by SimpleAccount. It is the contract-visible transcript, not a wallet UX. The canonical source is docs/signing-spec.md, with an executable reference in scripts/signing_reference.py and a committed vector in test/vectors/fors-reference-0.json.

Conventions

  • Hash: Ethereum keccak256 (not NIST SHA3-256).
  • Truncation: left16(h) = h[0:16] (matches Solidity bytes16(keccak256(...))).
  • Address: last20(h) = h[12:32].
  • Padding: a 16-byte value inside a hash transcript occupies the first 16 bytes of a 32-byte slot: pad32(x16) = x16 ‖ 16 zero bytes.
  • Integers: big-endian. The grinding counter is a uint128 (pad32(uint128_be(counter)) in transcripts).
  • No ABI dynamic-length prefixes appear inside the hash transcript: every input is raw byte concatenation.

Key derivation (recommended v1)

The verifier never sees how keys are derived, only pkSeed and signature bytes. For interoperability and reproducible vectors, derive deterministically from the user's 32-byte master secret. Do not use ECDSA signing in the derivation; use the scalar bytes only as KDF input.

masterSecret    = uint256_be(scalar)
skSeed(index)   = left16(keccak256("NiceTry/FORS/skSeed/v1" ‖ masterSecret ‖ uint64_be(index)))
pkSeed(index)   = left16(keccak256("NiceTry/FORS/pkSeed/v1" ‖ masterSecret ‖ uint64_be(index)))

index is the signer sequence number: S_0 is the first key, S_1 the next key after the first UserOp, and so on.

Because the account tracks keys by address and a key can never be re-activated, two devices must never derive the same key. Each device (and each chain's first signer) should use a non-overlapping key stream, for example by mixing a device-specific salt into the derivation, as discussed in the protocol spec's multi-wallet section. The Solidity tests use simpler toy derivations that must not be copied into production signers.

Hash primitives

FORS_DOM = 0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffd

PRF(skSeed, adrs)            = left16(keccak256(pad32(skSeed) ‖ adrs))                      // 64 B in
F(pkSeed, adrs, sk)          = left16(keccak256(pad32(pkSeed) ‖ adrs ‖ pad32(sk)))          // 96 B in
H(pkSeed, adrs, left, right) = left16(keccak256(pad32(pkSeed) ‖ adrs ‖ pad32(left) ‖ pad32(right))) // 128 B in
Hmsg(pkSeed, R, digest, ctr) = keccak256(pad32(pkSeed) ‖ pad32(R) ‖ digest ‖ uint256_be(FORS_DOM) ‖ pad32(uint128_be(ctr))) // 160 B in, NOT truncated
T_roots(pkSeed, roots[0..24])= left16(keccak256(pad32(pkSeed) ‖ ADRS_ROOTS ‖ pad32(roots[0]) ‖ … ‖ pad32(roots[24]))) // 864 B in

ADRS encoding

ADRS is a 32-byte big-endian word following the FIPS 205 §4.2 field layout: layer address at bytes 0..4, tree address at bytes 4..16, type at bytes 16..20, then three type-dependent words at bytes 20..24, 24..28 and 28..32. For standalone FORS+C the layer, tree and keypair (word 1) fields are always zero, since there is no hypertree above and no enclosing WOTS+ leaf. Implement these integer formulas exactly:

FORS_TYPE_FORS_TREE  = 3
FORS_TYPE_FORS_ROOTS = 4

ADRS_LEAF(t, leafIdx)          = uint256_be( (3 << 96) | ((t << A) | leafIdx) )
ADRS_NODE(t, cp, parentIdx)    = uint256_be( (3 << 96) | (cp << 32) | ((t << (A - cp)) | parentIdx) )   // cp = 1..5
ADRS_ROOTS                     = uint256_be( 4 << 96 )

In FIPS 205 terms: word 2 (bytes 24..28) is the tree height cp, and word 3 (bytes 28..32) is the tree index, with the K FORS trees folded into one index space as (t << (A - cp)) | idx.

Tree construction

For each real tree t = 0..24, build 32 leaves then climb to a root:

for leafIdx in 0..31:
    adrs            = ADRS_LEAF(t, leafIdx)
    sk_leaf         = PRF(skSeed, adrs)
    level[0][leafIdx] = F(pkSeed, adrs, sk_leaf)

for cp in 1..5:
    for parentIdx in 0..(2^(5-cp) - 1):
        adrs = ADRS_NODE(t, cp, parentIdx)
        level[cp][parentIdx] = H(pkSeed, adrs, level[cp-1][2·parentIdx], level[cp-1][2·parentIdx + 1])

root[t] = level[5][0]

Then compress and derive the address:

pkRoot = T_roots(pkSeed, root[0..24])
key    = last20(keccak256(pad32(pkSeed) ‖ pad32(pkRoot)))

This key is the onchain address for this signer, and the nextOwner that the previous signature commits to.

Message indices and grinding

Pick a 16-byte randomizer (deterministic form: R = left16(keccak256("NiceTry/FORS/R/v1" ‖ skSeed ‖ digest))), then search a counter:

for counter in 0..2^128-1:
    dVal  = Hmsg(pkSeed, R, digest, counter)
    md[t] = (uint256(dVal) >> (5·t)) & 31,  for t in 0..25
    if md[25] == 0:            // the +C grinding constraint on the omitted K-th tree
        break

Assembling the signature

For each real tree, emit one 96-byte opening (selected sk + bottom-up auth path):

for t in 0..24:
    leafIdx = md[t]
    sk      = PRF(skSeed, ADRS_LEAF(t, leafIdx))
    idx     = leafIdx
    for cp in 0..4:
        auth[cp] = level[cp][idx ^ 1]
        idx      = idx >> 1
    openTree(t) = sk ‖ auth[0] ‖ auth[1] ‖ auth[2] ‖ auth[3] ‖ auth[4]

signature = R ‖ pkSeed ‖ openTree(0) ‖ … ‖ openTree(24) ‖ uint128_be(counter)

The result is exactly 2,448 bytes. See Parameters → Signature layout for the byte map.

Account-level procedure

Digest binding

The signed digest is the raw ERC-4337 userOpHash. The signer must append the next key to the calldata so the hash commits to it:

userOp.callData = account_call ‖ bytes20(nextOwner)
digest          = EntryPoint.getUserOpHash(userOp)
signature       = FORS_sign(currentSigner, digest)

Putting nextOwner only in the signature is invalid: userOpHash commits to callData, not to the signature. nextOwner must be a key the account has never seen (authState == 0), or validation reverts.

First activation

A freshly created account has activated == false and no active key. On a chain committed in initialSignerRoot, the first UserOp uses an activation envelope that proves the chain-local first signer is in the root:

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 signature over EntryPoint.getUserOpHash(userOp)

The activation leaf is keccak256(abi.encode(keccak256("NiceTryInitialSignerLeaf:v1(uint256 chainId,address signer)"), chainId, signer)), verified onchain with sorted-pair Keccak via Solady MerkleProofLib. With the canonical 256-leaf tree, proofLen = 8 and the header is 0x010008. On success the account sets activated = true and marks nextOwner active; the first signer is consumed and never becomes active itself. After activation, normal signatures are exactly FORS_SIG_LEN again. How to build the tree: Multichain addresses. On a chain that is not in the root, activate with a SPHINCS signature instead (Recovery & bootstrap).

Normal rotation & burn-before-sign

assert authState(address(S_i)) == 1        // ACTIVE
assert authState(address(S_{i+1})) == 0    // fresh
callData  = account_call ‖ bytes20(address(S_{i+1}))
signature = FORS_sign(S_i, EntryPoint.getUserOpHash(userOp))

The signer should burn S_i locally before releasing any signature bytes, then begin preparing S_{i+2}. If a signed UserOp is dropped, rebroadcasting the same signature is safe (no new material leaks); producing a new signature with S_i counts as reuse and is permitted only inside the bounded budget (see reuse budget). If the local chain is lost entirely, recover with the SPHINCS backup.

SPHINCS ops

A SPHINCS signature is not a FORS+C signature, but it drives the same rotation, so a FORS+C signer must know the calldata shape:

userOp.callData  = account_call ‖ bytes20(currentKey) ‖ bytes20(nextOwner)
userOp.signature = SPHINCS_sign(backupKey, EntryPoint.getUserOpHash(userOp))   // 3,688 bytes

currentKey is burned and nextOwner activated exactly as in a FORS+C op, so the FORS+C pipeline resumes from nextOwner. Details: SPHINCS backup.

Test vectors

Offchain signers should emit JSON vectors with at least:

{
  "scheme": "FORS+C",
  "params": { "n": 16, "k": 26, "a": 5 },
  "index": 0,
  "digest": "0x...", "skSeed": "0x...", "pkSeed": "0x...",
  "R": "0x...", "counter": "0x...", "dVal": "0x...",
  "md": [0, 1, 2], "pkRoot": "0x...", "address": "0x...",
  "signature": "0x..."
}

Foundry tests assert verifier.recover(signature, digest) == expectedAddress and include mutation tests across every field and boundary. test/ForsReferenceVector.t.sol checks the committed Python-generated vector against the deployed verifier.