Skip to content

SPHINCS-only Account

The stateless alternative to rotation

src/SphincsAccount.sol is a second, independent ERC-4337 account family. It is authenticated solely by a standard SPHINCS signature: one (pkSeed, pkRoot) signs every UserOp for the account's whole life. There is no FORS+C, no rotation, no activation and no backup key. It is deployed by its own factory and never shares an address, an implementation or a salt domain with SimpleAccount.

Why it is so much simpler

Everything in SimpleAccount that tracks keys exists because FORS+C is a few-time scheme whose key must be burned and replaced on every use: authState, activated, initialSignerRoot, the Merkle activation envelope, the [currentKey][nextOwner] calldata tail, addSigner. SPHINCS is stateless and many-time, so none of it applies. SphincsAccount has:

  • no rotation and no per-signer state,
  • no activation step and no Merkle tree,
  • no trailing key material in callData,
  • exactly one signer type, hence no length dispatch.

Replay protection comes entirely from the EntryPoint nonce: userOpHash binds sender, nonce, chain id and calldata, so a signature is valid for exactly one UserOp on exactly one chain. Validation is a view function.

contract SphincsAccount is BaseAccount, TokenCallbackHandler, Initializable {
    bytes32 public pkSeed;                        // set once at initialize, never rotated
    bytes32 public pkRoot;
    IEntryPoint public immutable ENTRY_POINT;
    ISphincsVerifier public immutable VERIFIER;   // SphincsStandardVerifier
 
    function initialize(bytes32 _pkSeed, bytes32 _pkRoot) external;   // factory only
 
    function _validateSignature(PackedUserOperation calldata userOp, bytes32 userOpHash)
        internal view override returns (uint256)
    {
        if (userOp.signature.length != SPHINCS_STANDARD_SIG_LEN) return SIG_VALIDATION_FAILED;   // 6,176
        if (!VERIFIER.verify(pkSeed, pkRoot, userOpHash, userOp.signature)) return SIG_VALIDATION_FAILED;
        return SIG_VALIDATION_SUCCESS;
    }
}

The length check comes first on purpose: the verifier reverts on a wrong length, and a revert inside validation is a bundler-level rejection rather than the clean signature failure ERC-4337 expects. Because initialize rejects non-canonical keys, past that check verify can only return a boolean.

Signature

userOp.signature is the raw 6,176-byte blob (SPHINCS_STANDARD_SIG_LEN). No envelope, no type tag.

PropertyValue
Parametersn=16, h=20 (d=5 layers of height h'=4), a=9, k=19, log w=4 (w=16), l=35
FORSstandard, not FORS+C: all 19 trees carry a secret and a full 9-node auth path, no forced-zero tree, no grinding
WOTS+standard: 32 message chains + 3 base-16 checksum chains, no per-layer counter
LayoutR (16) ‖ 19 secrets (304) ‖ 19 auth paths (2,736) ‖ 5 × [35 chains (560) ‖ 4 auth nodes (64)]
Verify gasnot yet measured for this profile
Calldata6,176 bytes, about 99k gas at 16 gas/byte
Budgetabout 10^6 signatures per key (2^20 FORS instances)

Because both layers are the plain constructions, a standard SLH-DSA-style signer can produce these signatures with only the hash function swapped to Keccak-256 and the digit order mirrored. Contrast this with the backup verifier used inside SimpleAccount, which is FORS+C under WOTS+C, 3,688 bytes, and needs grinding on both layers.

A d=6 variant of the same profile (six layers of height 4, h=24) was also tried for a larger budget: 2^24 FORS instances raise the per-key budget about sixteenfold, at the cost of one more WOTS+ layer, so 624 extra bytes for a 6,800-byte signature and one more layer of chain hashing at verify time. The committed profile is d=5.

The 6,176-byte class is disjoint from FORS+C (2,448), the activation envelope (2,451 + 32·n) and the backup SPHINCS blob (3,688), which a test asserts. The disjointness is not load-bearing for SphincsAccount itself, which accepts only one length, but it keeps the two account families from ever being confused by tooling.

Factory and key binding

src/SphincsAccountFactory.sol mirrors SimpleAccountFactory: EIP-1167 clones over one implementation deployed in the factory constructor, idempotent createAccount, counterfactual getAddress.

function createAccount(bytes32 pkSeed, bytes32 pkRoot, uint256 salt) external returns (address);
function getAddress(bytes32 pkSeed, bytes32 pkRoot, uint256 salt) external view returns (address);
 
salt = keccak256(abi.encode(
    keccak256("NiceTrySphincsAccountSalt:v1(bytes32 pkSeed,bytes32 pkRoot,uint256 salt)"),
    pkSeed, pkRoot, userSalt
));

The public key is folded into the CREATE2 salt, so the address commits to the key and a deploy race cannot install a different key at the same address. The typehash is distinct from InitialSignerCommitment's, so a SPHINCS-only salt can never collide with a SimpleAccount salt. Both the factory and initialize reject zero or non-canonical keys (low 128 bits must be zero), because a stored non-canonical key would make the verifier revert forever and brick the account.

Multichain comes for free. The key is chain-independent, so the same (pkSeed, pkRoot, salt) yields the same address on every chain where the factory sits at the same address. No Merkle root, no per-chain first signer, no activation proof.

Budget, cost and recovery

  • Budget. h=20 gives 2^20 FORS instances, selected pseudo-randomly per message. Stateless security degrades as an instance is reused: the forgery probability for an instance used γ times is about (γ/512)^19, which holds a ~128-bit margin while γ ≤ 5. Balls-in-bins puts the expected maximum load near that around 2^20 signatures, so treat about a million signatures per key as the budget and count offchain. Nothing enforces it onchain.
  • Cost. Calldata alone is about 99k gas; verify gas for this profile has not been measured yet, and with w=16 the WOTS+ chains are longer than in the earlier w=4 profile, so expect it to dominate. Set verificationGasLimit from a measured figure and confirm the bundler accepts a signature field this large.
  • Recovery. There is none. Exactly one key, fixed at initialize, no second signer, no rotation, no addSigner. Loss or compromise of the key is terminal for the account.

Choosing between the two families

SimpleAccountSphincsAccount
Per-op signerFORS+C, rotated every opone SPHINCS key, never rotated
Per-op cost~35k verify, 2,448 B calldataverify unmeasured, 6,176 B calldata
Onchain state per optwo SSTOREs (burn + activate)none
Wallet complexitykey streams, two-forest cache, reuse budget, activation proofssign and send
Multi-deviceyes, via addSignerno (one key, share it or not)
RecoverySPHINCS backup re-seeds the chainnone
Cross-chainMerkle root of first signers, or backup bootstrapsame address everywhere by construction
Key exposureeach FORS+C key public once, then deadone long-lived public key, safe because hash-based

SimpleAccount is the ephemeral-keys design this project exists for: cheap per-operation verification with a durable backup. SphincsAccount is the fallback for wallets that cannot maintain rotation state (no persistent storage, no reliable device sync) and are willing to pay substantially more gas per operation for a stateless signer.

Deploy

script/DeploySphincsAccount.s.sol deploys SphincsStandardVerifier and then SphincsAccountFactory through the canonical CREATE2 deployer, skipping anything already deployed, and asserts the wiring plus that the implementation is locked against direct initialisation. It does not touch the SimpleAccount family.

forge script script/DeploySphincsAccount.s.sol --rpc-url <RPC_URL> --broadcast

The deploy salts are overridable via STANDARD_VERIFIER_SALT and SPHINCS_ACCOUNT_FACTORY_SALT; ENTRYPOINT defaults to v0.7.

Design note in the repo: docs/sphincs-account.md.