Skip to content

FORS+C Signer

Byte-level signing specification

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 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 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 in

ADRS 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
        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 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.