Skip to content

ForsVerifier

Stateless FORS+C verifier

src/Verifiers/ForsVerifier.sol is the onchain FORS+C verifier. It is stateless and pure: it takes a signature blob and a digest and returns the address that must have signed it. It holds no storage, knows nothing about accounts, and is shared by every account.

Interface

contract ForsVerifier is ISignatureVerifier {
    uint256 public constant N = 16;       // hash truncation (bytes)
    uint256 public constant K = 26;       // FORS trees (25 real under +C)
    uint256 public constant A = 5;        // tree height (32 leaves per tree)
    uint256 public constant SIG_LEN = 2448;
 
    function recover(bytes calldata sig, bytes32 digest) external pure returns (address);
}

The four parameters are ABI-readable so offchain tooling can confirm the deployed set. recover is the only entry point.

recover(sig, digest)

Returns the signer address, or address(0) on failure. It performs no comparison against any stored key: that is the account's job. The steps:

  1. Reject if sig.length != SIG_LEN (2,448).
  2. Read R, pkSeed, counter (each a 16-byte value in the top half of a 32-byte word) and recompute dVal = keccak256(pkSeed ‖ R ‖ digest ‖ DOM ‖ counter).
  3. +C grinding check: if the K-th A-bit field of dVal is nonzero, return address(0).
  4. For each of the 25 real trees, extract the leaf index md[t] = (dVal >> 5·t) & 31, recompute the leaf hash from the revealed sk, and climb the 5-node auth path to a root.
  5. Compress the 25 roots into pkRoot with one T call.
  6. Return last20(keccak256(pad32(pkSeed) ‖ pad32(pkRoot))).
address recovered = VERIFIER.recover(userOp.signature, userOpHash);
// authState[recovered] == ACTIVE  -> authorised, key is burned and nextOwner activated
// recovered == 0                  -> malformed length or failed grinding check
// anything else                   -> not an active key, validation fails

Implementation notes

  • Single primitive. Every hash is truncated keccak256 (N = 16); the five roles (PRF/F/H/T/Hmsg) are separated by the 32-byte ADRS mixed into each input. See FORS+C → Overview.
  • FIPS 205 address layout. Since July 2026 the ADRS follows the FIPS 205 §4.2 field layout, with the type word at bytes 16..20 (3 << 96 for FORS_TREE, 4 << 96 for FORS_ROOTS) and the tree/leaf indices in the low words. This is the same layout the SphincsVerifier uses. Signers written against the earlier layout (type at << 128) produce signatures that recover the wrong address. The exact formulas are in the Signer spec.
  • Domain separation. DOM = 0xFF…FD keeps standalone FORS+C distinct from spec SPHINCS+ (0xFF…FF, also used by the SPHINCS backup verifier) and the SLH-DSA-Keccak family (0xFF…FE).
  • Hand-tuned assembly. The verifier body is memory-safe inline assembly, with the inner Merkle climb unrolled for A = 5 and a fixed scratch layout after the roots buffer. This is what brings the cost down to ~34.1k gas warm / ~38.1k cold.
  • Specialised, by design. Compile-time guards (FORS_A_UNROLL_GUARD, the scratch-alignment guards) fail the build if A or the memory layout changes without updating the unrolled block. Retuning K/A is described in Parameters → Retuning.

Gas

CallGas (measured at K=26, A=5)
warm~34.1k
cold~38.1k

For comparison, the SPHINCS backup verifier costs ~105k, a NIST-parameter SLH-DSA-128s verify about 361k, and the archived WOTS+C verifier ~93k. Standalone FORS (no XMSS hypertree) is exactly what makes per-transaction verification this cheap.