FORS+C Signer
This page specifies what an off-chain signer must produce so that its signatures recover the correct owner address in ForsVerifier and 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 vector in test/vectors/fors-reference-0.json.
Conventions
- Hash: Ethereum
keccak256(not NIST SHA3-256). - Truncation:
left16(h) = h[0:16](matches Soliditybytes16(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 owner, S_1 the next owner after the first UserOp, and so on.
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 inADRS encoding
ADRS is a 32-byte big-endian word. Implement these integer formulas exactly:
FORS_TYPE_FORS_TREE = 3
FORS_TYPE_FORS_ROOTS = 4
ADRS_LEAF(t, leafIdx) = uint256_be( (3 << 128) | ((t << A) | leafIdx) )
ADRS_NODE(t, cp, parentIdx) = uint256_be( (3 << 128) | (cp << 32) | ((t << (A - cp)) | parentIdx) ) // cp = 1..5
ADRS_ROOTS = uint256_be( 4 << 128 )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])
owner = last20(keccak256(pad32(pkSeed) ‖ pad32(pkRoot)))This owner 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
breakAssembling 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 owner 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.
First activation
A root-based account is deployed inactive (owner = address(0), initialSignerRoot set). The first UserOp uses an activation envelope that proves the chain-local first signer is committed in the root:
offset length field
0 1 activationVersion = 1
1 2 proofLen = uint16_be(number of proof siblings)
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. After activation, normal signatures are exactly FORS_SIG_LEN again. See SimpleAccountFactory → Multi-chain addresses.
Normal rotation & burn-before-sign
assert onchain owner == address(S_i)
callData = account_call ‖ bytes20(address(S_{i+1}))
signature = FORS_sign(S_i, EntryPoint.getUserOpHash(userOp))The signer should burn S_i 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).
Test vectors
Off-chain 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 should include mutation tests across every field and boundary.