SPHINCS Backup Signer
NiceTry's primary signer, FORS+C, is a few-time scheme operated under the ephemeral-keys model: a key is active once, signs once, and is burned. The failure mode of that model is bricking. If the rotation chain breaks (a lost device, a UserOp that never lands and desyncs local state, a wallet bug that skips a key), no active key remains and the account is unrecoverable.
The SPHINCS backup closes that gap. It is a durable, cold, many-time hash-based key: a stateless, EVM-optimised variant of the SPHINCS+ / SLH-DSA family from the SPHINCS reference implementation (see the ethresear.ch writeup). It is a co-equal signer: it can sign any operation, recover a stuck account, or bootstrap the account on a chain that was never committed in the activation tree, without the one-shot fragility of FORS+C.
The variant
SPHINCS keeps the SPHINCS+ structure (a FORS few-time signature at the bottom, a WOTS+ hypertree above it) but retunes it for a bounded signature budget and cheap EVM verification. The NiceTry deployment uses:
| Property | Value |
|---|---|
| Hash primitive | Keccak-256, native on the EVM (no precompile), n = 16 (128-bit) |
| Address layout | FIPS 205 §4.2 / §11.2.2 uncompressed 32-byte ADRS |
| Hypertree | height h = 22, d = 2 layers of XMSS (11 each) |
| FORS+C inside | k = 7 trees of height a = 19; the last index is forced to zero by grinding R, so its auth path is omitted |
| WOTS+C | w = 8, l = 43 chains, target digit sum 208 (per-layer 4-byte counter, no checksum chains) |
| Signature size | 3,688 bytes (no prefix) |
| Public key | (pkSeed, pkRoot): two 16-byte values, each left-aligned in a bytes32 |
| Verify gas | ~105k |
| Signature budget | 2^22 (about 4.2 million) signatures per key |
Hmsg domain pad | 0xFF…FF |
The domain pad is deliberately distinct from FORS+C's 0xFF…FD, so a FORS+C and a SPHINCS signature can never collide on the same digest even though both use Keccak-256 and FIPS 205 addressing.
Why not the NIST parameters
Standard SLH-DSA-128s is built for up to 2^64 signatures under one key: a 7,856-byte signature that costs roughly 361k gas to verify onchain. A wallet backup key signs a handful of times in its lifetime, so most of that capacity is wasted. Cutting the budget to 2^22 while keeping n = 16 (the security level is the parameter that must not be cut) is what brings the signature to 3,688 bytes and verification to about 105k gas. The trade-off space is discussed in the repo's docs/sphincs-parameter-selection.md.
Public key canonical form
A SPHINCS public key word is canonical when its low 128 bits are zero:
pkSeed = seed16 ‖ 16 zero bytes
pkRoot = root16 ‖ 16 zero bytesThe verifier reverts on non-canonical keys rather than silently returning false. To make sure that can never brick an account, SimpleAccount.initialize and the factory both reject zero or non-canonical backup keys up front, so the stored key is always one the verifier accepts.
How the account uses it
The backup key is passed explicitly at account creation and committed into the account address through the CREATE2 salt:
backupSignerLeaf = keccak256(abi.encode(BACKUP_SIGNER_LEAF_TYPEHASH, pkSeed, pkRoot))
accountSalt = keccak256(abi.encode(ACCOUNT_SALT_TYPEHASH_v2, initialSignerRoot, backupSignerLeaf, userSalt))It is stored at initialize() as backupPkSeed / backupPkRoot and is never rotated: it is the intentionally static parallel authority. A UserOp whose signature is exactly 3,688 bytes is routed to it regardless of account state:
if (!SPHINCS_VERIFIER.verify(backupPkSeed, backupPkRoot, userOpHash, signature)) return SIG_VALIDATION_FAILED;
emit BackupSignerUsed(userOpHash);
if (!activated) activated = true;
_rotate(currentKey, nextOwner); // currentKey, nextOwner = last 40 bytes of callDataA valid SPHINCS signature therefore does two things: it authorises the op's calldata like any signer would, and it re-seeds the FORS+C chain by burning currentKey and activating a fresh nextOwner. The flows built on that (recovery, cross-chain bootstrap, device enrollment) are on the Recovery & bootstrap page.
Replay is bounded by ERC-4337: userOpHash commits to sender, nonce, chain id and callData, so a SPHINCS signature is valid for exactly one account, nonce, chain and action.
Security notes
- 128-bit preimage security (
n = 16), NIST Level 1. Security reduces to Keccak preimage resistance and does not erode with public-key exposure, which is what makes the key safe to keep for a lifetime. - Blast radius. The backup is co-equal, so a compromised backup key can drain the account directly and repeatedly. Keep it offline. Wallets should surface
BackupSignerUsedevents prominently. A backup-authorisedrotateBackupKeyfor post-compromise hygiene is a possible future hardening; today the key is fixed for the account's lifetime. - Signature budget. Stay well below 2^22 signatures. A handful of uses in a lifetime trivially satisfies this, but the wallet should still count them offchain.
- Parameters are provisional. Like FORS+C, the SPHINCS parameter set is still being tuned. A runtime-parameterised
SphincsParamVerifierand a multi-backup-signer account are in progress on themultiSphincs_accountbranch.
Test vector
scripts/sphincs_reference.py drives the upstream SPHINCS signer to produce a real (pkSeed, pkRoot, message, sig) tuple and writes test/vectors/sphincs-reference-0.json. test/SphincsVerifier.t.sol asserts verify(...) == true on it, plus tamper and wrong-message negatives. The vector is not committed; until it is generated locally only the revert-guard tests run. Generating it requires the external signer (slow in pure Python, fast through the upstream Rust binary).