Checking post-quantum signatures in Bitcoin Script
A small experiment. It ran on a private test network with an experimental fork. It is not production software and it does not make any coins quantum-safe.
Simple summary
The question
Bitcoin's signatures break if large quantum computers arrive. SHRINCS is a proposed replacement. It relies only on hash functions, which quantum computers do not break the same way.
Bitcoin would need a way to check these new signatures. There are two options:
- Add a new built-in instruction just for SHRINCS.
- Give Bitcoin Script enough general instructions that anyone can write the checker themselves.
The Script Restoration fork (based on BIP 440 and BIP 441) is option two. So we asked: if we write the SHRINCS checker using only that fork's general instructions, does it work, how big is it, and what does it cost to run?
What we did
- Wrote the checker in Bitcoin Script. It handles both kinds of SHRINCS signature and agrees with the official Python reference on every test, including all 255 tree depths and every kind of tampered input.
- Spent real test coins with it. On a private network running the fork, we locked coins behind the checker and spent them with SHRINCS signatures.
- Measured it. Program size, transaction size, and execution cost, with and without the fork's reusable-function feature.
- Made it repeatable. Anyone with the repository can rebuild everything from scratch and get the same programs and test results.
Transactions per second
If every transaction on the network used one scheme, how many transactions would fit? This follows a chart by Jonas Nick and its script: 4,000,000 weight units per block, one block every 600 seconds, an average transaction with 2.27 inputs and 2.64 outputs. The P2TR row reproduces his number. The other rows use the witness sizes we measured, which include the checker program because there is no native opcode.
Show as table
| Scheme | Tx per second | Tx per block | Inputs spent per block | vbytes per tx |
|---|---|---|---|---|
| P2TR key spend, today | 6.55 | 3,929 | 8,918 | 254 |
| Native opcode, hypothetical | 2.81 | 1,684 | 3,822 | 594 |
| Script, stateful, shared | 0.53 | 315 | 715 | 3,173 |
| Script, stateless, shared | 0.14 | 87 | 197 | 11,534 |
| Script, stateful, inline | 0.03 | 18 | 41 | 54,745 |
| Script, stateless, inline | 0.02 | 13 | 29 | 79,272 |
The native-opcode row is not measured. It assumes a consensus change that checks the same 660-byte signature with nothing else in the witness. His SHRINCS point sits higher, at 4.13, because it uses a 324-byte signature from a different parameter set. Our signature comes from the pinned specification and is 660 bytes for this key.
The checker program, not the signature, sets the throughput. Moving it into the node would raise the stateful figure about five times. "Inline" means the same checker written out without shared functions. Shared functions raise the stateful figure about seventeen times over that.
How big is a SHRINCS transaction?
One input, two outputs, one SHRINCS signature, using the compact checker with shared functions. These are the real transactions the test network mined.
- Signature
- Checker program
- Control block
- Rest of the transaction
Show as table
| Part | Stateful, bytes | Stateless, bytes |
|---|---|---|
| Signature | 660 | 5,777 |
| Checker program | 4,476 | 14,091 |
| Control block | 65 | 65 |
| Rest of the transaction | 123 | 123 |
| Total on the wire | 5,324 | 20,056 |
| Virtual size | 1,416 | 5,099 |
The program is the biggest part, not the signature. Fees are paid per vbyte, and witness bytes count for a quarter, so the stateful spend costs about ten times an ordinary payment of around 150 vbytes. Without shared functions the same spends are 24,135 and 34,940 vbytes.
Shared functions make it small
The fork lets a program define a function once and call it many times. Without that, every repeated step must be written out in full.
- With shared functions
- Without
Show as table
| Signature type | With functions | Without |
|---|---|---|
| Stateful | 4,476 | 95,350 |
| Stateless | 14,091 | 133,452 |
What "with shared functions" means. The fork has two opcodes, OP_DEFINE and OP_INVOKE, that let a program store a piece of code once and run it many times. The checker repeats a few steps thousands of times: one hash-chain step, one Merkle-tree step, one WOTS check. "With shared functions" defines each of those once and calls it. "Without" writes every repetition out in full. Both programs do the same work and accept exactly the same signatures.
Two things differ between the bars, not one. The "with" version also uses OP_BYTEREV, a byte-reversal opcode the fork adds; the "without" version builds byte reversal from slices. Measured on the standalone stateful checker: 95,209 bytes with neither, 52,038 bytes with byte reversal alone, 4,335 bytes with byte reversal and shared functions. So reversal accounts for a bit under half of the baseline size, and functions for nearly all of the rest. The bars above add the 141-byte spending policy to those figures.
The "without" checker itself uses only the restoration opcodes from BIP 440 and BIP 441. The spending policy around it, in both versions, also needs the fork's OP_TX opcode to read the transaction being signed. Every spend on this page therefore depends on OP_TX; only the verifier inside the "without" policy is restoration-only.
- With shared functions
- Without
Show as table
| Signature type | With functions | Without |
|---|---|---|
| Stateful | 1,416 | 24,135 |
| Stateless | 5,099 | 34,940 |
For scale: an ordinary Bitcoin payment today is about 150 vbytes. The stateful spend is about ten times that. The signature itself is 660 bytes; the rest is the checker program and the transaction.
The stateful spend uses about 36% of the execution budget the fork allows for a transaction of its size. The stateless spend uses about 60%. Both fit without any tricks.
Where the cost goes
The fork charges every instruction a fixed price: 1,250 units for most, 3,000 for comparisons and arithmetic, 4,000 for a function call. On top of that it charges for data-dependent work, such as 50 units per byte hashed. We expected hashing to dominate. It does not.
- Fixed charge per instruction, 90%
- Data-dependent: hashing, copying, arithmetic, 10%
- Function body copying, 0.3%
Show as table
| Part | Share |
|---|---|
| Fixed charge per instruction | 90.1% |
| Data-dependent charges. Hashing is at most 8 points of this. | 9.6% |
| Function body copying | 0.3% |
Almost all of the cost is bookkeeping: moving bytes around and branching. The cryptography is a small slice. Two things follow:
- Byte reversal and shared functions together shrink the stateful program by 95% (95,350 to 4,476 bytes) but reduce the charged work by only 15% (24,150,510 to 20,577,528 units). They compress code, not execution.
- Programs with lots of skipped code run slower per charged unit than the cost table predicts. Parsing skipped code is not charged. This is a real input to recalibrating the fork's prices, and we built a tool to measure it.
The numbers behind the charts
One row per program. "Shared" uses shared functions, "inline" writes everything out, and "unified" is one program that accepts both signature types. Executed opcodes and calls are for one spend. The fixed charge is the sum of each executed opcode's fixed price. Charged units are what the fork actually deducted, which includes the data-dependent part.
| Program | Script bytes | Of which function bodies | Executed opcodes | Function calls | SHA256 calls | Fixed charge | Charged units | Fixed share | Budget used |
|---|---|---|---|---|---|---|---|---|---|
| Stateful, shared | 4,476 | 4,147 | 13,701 | 62 | 253 | 18,542,750 | 20,577,528 | 90% | 36.3% |
| Stateful, inline | 95,350 | 0 | 16,357 | 0 | 253 | 22,182,250 | 24,150,510 | 92% | 2.5% |
| Stateless, shared | 14,091 | 13,790 | 78,562 | 357 | 1,469 | 106,357,500 | 119,994,389 | 89% | 58.8% |
| Stateless, inline | 133,452 | 0 | 92,704 | 0 | 1,709 | 123,972,000 | 138,826,875 | 89% | 9.9% |
| Unified, shared | 17,755 | 17,386 | 13,720 | 62 | 253 | 18,566,500 | 20,642,955 | 90% | 10.9% |
| Unified, inline | 228,549 | 0 | 24,767 | 0 | 253 | 32,694,750 | 34,665,006 | 94% | 1.5% |
"Budget used" is charged units divided by the allowance of 10,000 units per weight unit of the transaction. A smaller program gets a smaller allowance, which is why the compact programs use a larger share even though they do less work.
Every opcode executed by the stateful spend, with and without shared functions, sorted by how often it runs. The last two columns multiply the count by the opcode's fixed price.
Show all 51 opcodes
| Opcode | Count, with functions | Count, without | Fixed price | Fixed charge, with | Fixed charge, without |
|---|---|---|---|---|---|
| OP_PICK | 2,104 | 2,257 | 1,250 | 2,630,000 | 2,821,250 |
| OP_CAT | 1,657 | 1,881 | 1,250 | 2,071,250 | 2,351,250 |
| OP_0 | 848 | 939 | 1,250 | 1,060,000 | 1,173,750 |
| OP_1 | 789 | 884 | 1,250 | 986,250 | 1,105,000 |
| OP_DROP | 892 | 752 | 1,250 | 1,115,000 | 940,000 |
| OP_3 | 806 | 837 | 1,250 | 1,007,500 | 1,046,250 |
| OP_ELSE | 500 | 990 | 1,250 | 625,000 | 1,237,500 |
| OP_IF | 500 | 990 | 1,250 | 625,000 | 1,237,500 |
| OP_ENDIF | 500 | 990 | 1,250 | 625,000 | 1,237,500 |
| push 1 to 75 bytes | 493 | 785 | 1,250 | 616,250 | 981,250 |
| OP_2 | 523 | 488 | 1,250 | 653,750 | 610,000 |
| OP_LESSTHANOREQUAL | 481 | 481 | 3,000 | 1,443,000 | 1,443,000 |
| OP_ROLL | 460 | 440 | 1,250 | 575,000 | 550,000 |
| OP_16 | 431 | 420 | 1,250 | 538,750 | 525,000 |
| OP_4 | 367 | 355 | 1,250 | 458,750 | 443,750 |
| OP_LEFT | 299 | 355 | 1,250 | 373,750 | 443,750 |
| OP_SHA256 | 253 | 253 | 1,250 | 316,250 | 316,250 |
| OP_9 | 71 | 325 | 1,250 | 88,750 | 406,250 |
| OP_SUBSTR | 125 | 225 | 1,250 | 156,250 | 281,250 |
| OP_LESSTHAN | 48 | 291 | 3,000 | 144,000 | 873,000 |
| OP_8 | 125 | 178 | 1,250 | 156,250 | 222,500 |
| OP_VERIFY | 144 | 144 | 1,250 | 180,000 | 180,000 |
| OP_5 | 159 | 73 | 1,250 | 198,750 | 91,250 |
| OP_NUMEQUAL | 105 | 105 | 3,000 | 315,000 | 315,000 |
| OP_SIZE | 104 | 104 | 1,250 | 130,000 | 130,000 |
| OP_SWAP | 104 | 104 | 1,250 | 130,000 | 130,000 |
| OP_7 | 81 | 98 | 1,250 | 101,250 | 122,500 |
| OP_6 | 94 | 57 | 1,250 | 117,500 | 71,250 |
| OP_11 | 41 | 103 | 1,250 | 51,250 | 128,750 |
| OP_RSHIFT | 31 | 88 | 3,000 | 93,000 | 264,000 |
| OP_ADD | 52 | 61 | 1,250 | 65,000 | 76,250 |
| OP_FROMALTSTACK | 63 | 43 | 1,250 | 78,750 | 53,750 |
| OP_TOALTSTACK | 63 | 43 | 1,250 | 78,750 | 53,750 |
| OP_10 | 38 | 44 | 1,250 | 47,500 | 55,000 |
| OP_12 | 42 | 39 | 1,250 | 52,500 | 48,750 |
| OP_14 | 37 | 38 | 1,250 | 46,250 | 47,500 |
| OP_13 | 35 | 36 | 1,250 | 43,750 | 45,000 |
| OP_INVOKE | 62 | 0 | 4,000 | 248,000 | 0 |
| OP_BYTEREV | 49 | 0 | 1,250 | 61,250 | 0 |
| OP_MOD | 24 | 24 | 3,000 | 72,000 | 72,000 |
| OP_MIN | 26 | 2 | 1,250 | 32,500 | 2,500 |
| OP_15 | 4 | 21 | 1,250 | 5,000 | 26,250 |
| OP_MUL | 21 | 1 | 3,000 | 63,000 | 3,000 |
| OP_SUB | 16 | 2 | 1,250 | 20,000 | 2,500 |
| OP_DEFINE | 13 | 0 | 1,250 | 16,250 | 0 |
| OP_PUSHDATA | 10 | 0 | 1,250 | 12,500 | 0 |
| OP_TX | 5 | 5 | 1,250 | 6,250 | 6,250 |
| OP_EQUAL | 3 | 3 | 1,250 | 3,750 | 3,750 |
| OP_DEPTH | 1 | 1 | 1,250 | 1,250 | 1,250 |
| OP_LSHIFT | 1 | 1 | 3,000 | 3,000 | 3,000 |
| OP_DIV | 1 | 1 | 3,000 | 3,000 | 3,000 |
| Total | 13,701 | 16,357 | 18,542,750 | 22,182,250 |
The three most common opcodes, PICK, CAT, and DROP, move data around. Together with the small constant pushes and the IF, ELSE, and ENDIF triple they account for most of the executed instructions. SHA256 runs 253 times in both versions. Shared functions cut the executed instruction count by about a sixth, mostly by removing skipped branches. They do not change the number of hashes.
OP_MULTI was not needed
The fork has an instruction called OP_MULTI that applies one operation to a variable number of values. The SHRINCS checker never used it. The one real use we found in the fork's own tests, summing all the output amounts of a transaction, can be done with an existing simpler instruction.
Show as table
| Construction | Program bytes | Charged units |
|---|---|---|
| Existing instruction | 31 | 25,698 |
| OP_MULTI | 34 | 66,593 |
| Unrolled loop | 296 | 393,961 |
The cost difference is small in practice. The real argument is simpler: OP_MULTI is about 230 lines of interpreter code with several different behaviours hidden behind one instruction, and nothing we found needs it. We recommend deferring it.
What we did not do
- No comparison with Simplicity. Simplicity is a rival approach, and a recent post put its SHRINCS checker at about 50 kilobytes. The existing Simplicity implementation uses different SHRINCS parameters, so its size cannot be compared with ours yet. Someone has to build both to the same spec first.
- The coins are not quantum-safe. Our outputs use ordinary Taproot, which keeps an old-style key path. Removing that path is a separate consensus change.
- No wallet, no signer, no security proof. This is a checker, running in a lab.
Check it yourself
Everything is in the repository. It is private for now, so the links need access. Start with the README's reading guide. The code, the test inputs, the transactions, and the measurements are all there, and a clean build on GitHub reproduces the programs and test results byte for byte.
Figures on this page come from the measured results at tag baseline-2026-09-18 and the later evidence refresh. Execution cost is measured in the fork's own charged units, not seconds.