Any CKB contract that authorises an owner by lock hash, and accepts any lock there, can have post-quantum owners today with no contract change. The stored owner only has to be the hash of a lock under Nervos’s quantum-resistant lock script, which verifies SLH-DSA (SPHINCS+, NIST FIPS 205) signatures.
We build Cells, a naming protocol on CKB, and did this there: .cell names on mainnet can be owned this way since September 2026. These are our notes: why it works, what we checked about the lock, what the chain refused when we attacked it, and two things we learned about CCC on the way.
Why it needs no contract change
A type script that checks ownership this way never verifies a signature itself. It stores the owner as a lock hash, and accepts an owner action when the same transaction spends a cell under that lock. Whether the lock runs secp256k1, a multisig or SPHINCS+ is the lock’s business. Moving an owner to a post-quantum key is therefore an ordinary transfer, to an address under the SPHINCS+ lock.
In Cells the check is require_owner: a name’s cell stores the first 20 bytes of its owner’s lock hash, and the transaction must spend a cell whose lock hash starts with them, under a lock that needs its holder to sign. The deployed code is that published source; cellula.id/api/verify compares the code cells on mainnet with the hashes a reproducible build gives, and answers verified: true.
The lock, checked
Before pointing anyone at it, we checked the mainnet deployment of c-sphincs-all-in-one-lock against upstream and against the chain. Cells uses the SLH-DSA-SHA2-128s parameter set. Each line names what it was checked against.
| Value | Checked against | |
|---|---|---|
| Standard | NIST FIPS 205 (SLH-DSA); one lock serves all 12 parameter sets | upstream README |
| Parameter set | SHA2-128s: 32-byte public key, 7,856-byte signature | the SDK’s constants; a test signature made and verified with @noble/post-quantum |
args |
blake2b-256, personalization ckb-sphincs+-sct, over 80 01 01 01 62 and the public key |
upstream format notes; the SDK |
| Witness lock | 7,893 bytes: prefix 80 01 01 01 63, public key, signature |
upstream format notes; a mainnet witness, tx 0x06ee5bb5… (block 20,543,731) |
| Mainnet script | code_hash 0x302d3598…, hash_type type, dep 0x4598d00d…:0 |
upstream README; the code cell’s own type hash on chain |
| Mainnet code | 211,296 bytes, SHA-256 59a4c25e… |
the cell on chain; upstream checksums.txt for c-sphincs-all-in-one-lock |
| Audit | ScaleBit, 16 December 2025 | upstream README; the report itself |
| Verification cost | about 11.5 M cycles for SHA2-128s in C | upstream README (their measurement) |
We also rebuilt the lock from source, in the project’s own pinned build image, and got the mainnet bytes exactly.
Keeping daily use on an ordinary wallet
A key made from a phrase kept offline is awkward for everyday actions. If your contract separates an owner from a manager, the post-quantum key can own while an ordinary wallet manages. In Cells that takes two transactions, and the second is signed by the new key alone:
- The wallet transfers the name to the key’s address, and sends that address 100 CKB for the key’s own fees.
- The key signs a transaction that makes the wallet the manager again.
It takes two because Cells resets the manager to the new owner on every transfer, so no delegation survives a change of owner (source). The manager edits the records; giving the name away, selling it and changing the manager stay with the owner.
The key is derived in the browser from a 24-word BIP39 phrase (256 bits of entropy): the phrase’s BIP39 seed, cut to 48 bytes, is the SLH-DSA key seed. On mainnet, one such delegation, tx 0x06ee5bb5…, cost the key 0.00066 CKB for a 33,237-byte transaction.
When an asset stands for ownership
Some contracts let an asset stand for ownership: the owner is input-type-proxy-lock over the asset’s type hash, and whoever spends the asset acts as owner. In Cells this is a deed, a Spore that stands for the name. The proxy lock (code_hash 0x51239089…, data1, the same value CCC ships as a known script) opens only when a cell of that type is among the inputs (unlock rule).
So the asset can go behind the post-quantum key whole. Move the Spore to the key’s address: every owner action then needs the key, and the manager’s edits need nothing more. Cells’ consents accepts the proxy only when the Spore is spent under a lock that consents in turn. On the Pudge testnet:
| Step | Transaction | Block |
|---|---|---|
| The Spore goes to the key | 0xb6730081… |
22,579,243 |
| The key acts as owner through the deed: sets the manager | 0xbcb8da9b… |
22,579,252 |
| The key lists the deed for sale whole | 0xb791cf73… |
22,579,260 |
| Second run: the wallet becomes the manager | 0x4f2ed22b… |
22,580,239 |
| The Spore and 100 CKB go to the key | 0xb350a49d… |
22,580,242 |
| The key brings the Spore back | 0x91d42709… |
22,580,249 |
Two runs on the same name, shopcheck20260925.cell, with the same Spore throughout; the name never left the deed.
What the chain refused
Every attack on a deed behind a quantum key was refused on chain, by a script or by the node itself, never by our own code. Each one is a valid transaction built by the SDK, changed in one place, and put to a Pudge node with test_tx_pool_accept, which runs every script and commits nothing. Each template was first accepted unchanged, so the refusal comes from the change; the first one was accepted again after the attacks made from it.
| Attack | Refused by | Code |
|---|---|---|
| The manager acts as owner with the deed’s proxy cell and no Spore | input-type-proxy-lock |
6, InvalidUnlock |
| The manager opens the deed with another Spore it holds | input-type-proxy-lock |
6, InvalidUnlock |
| The manager spends the Spore without the key’s signature | SPHINCS+ lock | 106, ERROR_SPHINCSPLUS_WITNESS |
| The manager reuses the key’s signature on a changed transaction | SPHINCS+ lock | 107, ERROR_SPHINCSPLUS_VERIFY |
| A stranger sends the Spore to itself | SPHINCS+ lock | not recorded |
| A manager the key has revoked edits the records | account-cell-type |
29, Unauthorized |
| A buyer pays 100 CKB for a deed listed at 500 | the sale lock | 3, Unpaid |
| A transaction the key signed earlier is sent again | the node: its inputs are spent | none |
The code names come from each script’s source: the proxy lock, the SPHINCS+ lock, and our contracts. The run passed 22 checks of 22, the controls included.
Two things we learned about CCC
Both apply to any signer with a large witness, and to any test that changes a CCC transaction.
- Size the fee with the witness in place. CCC’s fee loop sizes the transaction it holds and ignores what
prepareTransactionreturns. A signer that clones the transaction there has its placeholder dropped, and a 7,893-byte witness does not fit in a normal fee margin: the node refused ours withLowFeeRate.PqSignerwrites the placeholder on the object it is handed. - Clone a template before changing it.
Transaction.from(tx)returnstxitself when it is already aTransaction, the same mechanism as ccc#570 forCellOutput. Our first attack suite changed a shared template in place, so each attack carried the previous one’s changes;tx.clone()fixed it.
What the key protects, in Cells
The key protects who can move the name: giving it away, selling it and changing its manager. It does not move money. Payments go to the addresses the name publishes, which are ordinary keys, and the manager can change them.
- The 24 words are the only copy. Without them the name can never be given away, sold or re-delegated. Renewing needs nobody’s signature, so anyone, the manager included, can keep it alive.
- A lost managing wallet is recoverable. The words appoint a new manager.
- The badge proves the positive only. The resolver shows “quantum-safe owner” when it finds the owner among live cells under the SPHINCS+ lock. A name without the badge is not shown to be protected, never shown not to be.
Trying it
A .cell name can be put behind a quantum key from its page at cellula.id. For code, cellula-sdk 0.2.1 has PqSigner, protect, protectDeed and returnDeed, with @ckb-ccc/core (1.19.1 or later) and @ckb-ccc/spore as peer dependencies, and the resolver answers quantumOwner on GET /api/resolve/<name>.
We would like this reviewed. If you see a gap in the reasoning or in the checks, or you are building on the SPHINCS+ lock too, please say so here.
Sources
Opened or queried on 30 September 2026.
- nervosnetwork/quantum-resistant-lock-script: README (deployments, audit, cycles), format notes,
checksums.txt, error codes - ScaleBit audit report, 16 December 2025
- NIST FIPS 205
- ckb-devrel/ckb-proxy-locks,
input-type-proxy-lock: unlock rule, error codes - LusoCryptoLabs/cells-contracts:
account-cell-type, the same file as the deployed build cellula-sdk0.2.1 on npm- The resolver:
/api/verify,/api/quantum - Mainnet, through
mainnet.ckb.dev: the lock’s code cell0x4598d00d…:0, 88 live cells under the SPHINCS+ lock, and tx0x06ee5bb5… - Pudge, through
testnet.ckb.dev: the six transactions in the deed table, all committed - ccc#570
