SphincsVerifier
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
| Input | Result |
|---|---|
sig.length != 3688 | reverts Invalid sig length |
pkSeed or pkRoot with nonzero low 128 bits | reverts Invalid public key |
| well-formed but invalid signature | returns false |
| valid signature | returns 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
digest = keccak256(pkSeed ‖ pkRoot ‖ R ‖ message ‖ 0xFF…FF), a 160-byte domain-separatedHmsg. The0xFF…FFpad is distinct from FORS+C's0xFF…FD. There is no separate grinding counter: the signer grinds the randomizerRitself.- 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) returnfalse. - 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_ROOTSADRS keyed by the hypertree leaf. - 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 returnfalse), finish each of the 43 chains, compress them into the WOTS+ public key, and climb the 11-level XMSS auth path. - Return
trueiff the final root equalspkRoot.
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
| Parameter | Value |
|---|---|
n | 16 bytes (128-bit) |
h / d | 22 / 2 |
a / k | 19 / 7 |
w / l | 8 / 43 |
| target checksum sum | 208 |
| signature | 3,688 bytes |
| budget | 2^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.