Skip to content

SphincsVerifier

Stateless SPHINCS verifier

src/Verifiers/SphincsVerifier.sol is the onchain verifier for the SPHINCS backup signer inside SimpleAccount. Like the FORS+C verifier it is stateless and pure, deployed once, and shared by every account, which calls it through staticcall. It is not the verifier used by SphincsAccount; that family uses the larger, grinding-free SphincsStandardVerifier.

Interface

uint256 constant SPHINCS_SIG_LEN = 3688;
 
interface ISphincsVerifier {
    /// @param pkSeed  SPHINCS public seed (16 bytes, left-aligned in bytes32, low 128 bits zero)
    /// @param pkRoot  SPHINCS public root (same layout)
    /// @param message 32-byte digest being verified (the userOpHash)
    /// @param sig     SPHINCS_SIG_LEN-byte signature blob, no prefix
    /// @return valid  true iff the signature verifies under (pkSeed, pkRoot)
    function verify(bytes32 pkSeed, bytes32 pkRoot, bytes32 message, bytes calldata sig)
        external view returns (bool valid);
}

The concrete contract implements verify as pure; the interface declares it view so callers can staticcall it. Unlike ForsVerifier.recover, which returns the signer, verify takes the expected public key as input and returns a boolean. The account passes its stored backupPkSeed / backupPkRoot.

Behaviour

InputResult
sig.length != 3688reverts Invalid sig length
pkSeed or pkRoot with nonzero low 128 bitsreverts Invalid public key
well-formed but invalid signaturereturns false
valid signaturereturns true

The two revert cases can never be reached from SimpleAccount: the account only calls verify when the signature length is exactly SPHINCS_SIG_LEN, and initialize rejects non-canonical keys, so in the account path verify always returns a boolean.

What it computes

  1. digest = keccak256(pkSeed ‖ pkRoot ‖ R ‖ message ‖ 0xFF…FF), a 160-byte domain-separated Hmsg. The 0xFF…FF pad is distinct from FORS+C's 0xFF…FD. There is no separate grinding counter: the signer grinds the randomizer R itself.
  2. Extract the 22-bit hypertree leaf index and the seven 19-bit FORS indices from digest; if the seventh index is nonzero (the +C constraint) return false.
  3. Recompute the FORS+C public root: six trees are opened from a leaf secret plus a 19-level auth path each; the seventh, forced-zero tree contributes only its revealed leaf secret hashed as leaf 0. The seven roots are compressed with a FORS_ROOTS ADRS keyed by the hypertree leaf.
  4. Walk the two-layer hypertree (h = 22, d = 2, subtrees of height 11). Per layer: hash the current node with a 4-byte WOTS+C counter, require the 43 base-8 digits to sum to 208 (else return false), finish each of the 43 chains, compress them into the WOTS+ public key, and climb the 11-level XMSS auth path.
  5. Return true iff the final root equals pkRoot.

The signature layout that falls out of this is R (16) ‖ 7 FORS secrets (112) ‖ 6 FORS auth paths (6 × 304) ‖ 2 × [43 chains (688) ‖ counter (4) ‖ 11 auth nodes (176)] = 3,688 bytes.

All tweakable hashes have the shape keccak256(seed32 ‖ adrs32 ‖ payload) with the FIPS 205 §4.2 uncompressed ADRS layout (layer address, 96-bit tree address, type, then three type-dependent words). The implementation is hand-written Yul with a branchless Merkle swap and a hoisted chain-address base; it uses the free-memory-pointer and zero slots as scratch and exits only through in-assembly return / revert, which is why it is not annotated memory-safe.

Parameters

ParameterValue
n16 bytes (128-bit)
h / d22 / 2
a / k19 / 7
w / l8 / 43
target checksum sum208
signature3,688 bytes
budget2^22 signatures per key
verify gas~105k

The bit offsets inside the verifier are derived from these (for example the hypertree index sits at digest >> 133 because k · a = 133). Retuning requires a matching signer; the runtime-parameterised SphincsParamVerifier under development on the multiSphincs_account branch is intended to make that easier.

Test vector

test/SphincsVerifier.t.sol always runs the two revert-guard tests. The happy-path, tampered and wrong-message tests read test/vectors/sphincs-reference-0.json, which is produced by scripts/sphincs_reference.py from the upstream signer and is not committed to the repository.