Last active
April 1, 2026 13:44
-
-
Save jonasnick/0e7a3c4a41063e39afe8a16f70662fd3 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| TPS vs Signature Size for Bitcoin Post-Quantum Signature Schemes. | |
| Generates two plots comparing throughput (transactions per second) for: | |
| - P2TR keyspend (Schnorr, BIP-340) | |
| - SHRINCS stateful OTS (unbalanced XMSS + WOTS/WOTS+C) | |
| - SHRIMPS compact path (SPHINCS+ with small q_s) | |
| - Mixed deployment (60% SHRINCS + 30% SHRIMPS + 10% fallback) | |
| - Optimized SPHINCS+ (WOTS+C, PORS+FP, q_s = 2^40) | |
| - SLH-DSA (NIST FIPS 205, q_s = 2^64) | |
| Two optimization modes: | |
| Plot 1: size-optimized (verification time/byte does not exceed Schnorr) | |
| Plot 2: verify-optimized (verification time/byte does not exceed SLH-DSA) | |
| SPHINCS+ parameters can be cross-checked with: | |
| sage costs.sage --params <scheme> <q_s> <k> <a> <h> <d> <w> <swn> | |
| from https://github.com/BlockstreamResearch/SPHINCS-Parameters (commit f2ea2a2). | |
| Assumptions: | |
| - 4 MWU blocks (4,000,000 weight units) | |
| - 600 s block interval | |
| - 2.27 inputs/tx, 2.64 outputs/tx (transactionfee.info 90-day avg, 2026-04-01) | |
| - Output commits directly to PQ public key (no tapscript overhead) | |
| - Signing cost <= 5x SLH-DSA (11.4M SHA-256 compressions) | |
| - Schnorr verification: 28 us/sig, 0.438 us/byte (libsecp256k1, Intel i7 4.5 GHz) | |
| - SHA-256 compression: 0.135 us (-march=native, same CPU) | |
| - Schnorr-equivalent threshold: 0.438/0.135 = 3.24 SHA-256 compr./byte | |
| - All schemes target 128-bit security (NIST level 1) | |
| """ | |
| import math | |
| import numpy as np | |
| import matplotlib.pyplot as plt | |
| import matplotlib.ticker as ticker | |
| # ============================================================================= | |
| # Hash-based signature cost model (ported from costs.sage) | |
| # ============================================================================= | |
| # | |
| # Constants from SPHINCS-Parameters/costs.sage lines 38-49. | |
| # These count SHA-256 compression function invocations per operation. | |
| HASHBYTES = 16 # 128-bit hash output (NIST level 1) | |
| COUNTER_SIZE = 4 # 32-bit counter for WOTS+C | |
| RANDOMNESS_SIZE = 32 # 256-bit randomizer R | |
| # Compression calls per hash primitive invocation | |
| C_Th1 = 1 # Tweakable hash, 1-block: PKseed(128) + Tweak(96) + m(128) = 352 < 512 | |
| C_Th1c = 1 # Tweakable hash, 1-block + counter: PKseed + Tweak + m + counter(32) = 384 < 512 | |
| C_Th2 = 2 # Tweakable hash, 2-block: PKseed + Tweak + m1(128) + m2(128) = 480+65 > 512 | |
| C_Hmsg = 2 # Message hash: PKseed + PKroot(128) + R(256) + m(256) | |
| C_PRFmsg = 2 # Message PRF | |
| C_PRF = 1 # PRF for secret key derivation | |
| # SLH-DSA reference (SPHINCS+-128f, q_s = 2^64) | |
| SLH_DSA_SIGN_COMPR = 2_280_000 # from costs.sage table | |
| MAX_SIGN_COMPR = 5 * SLH_DSA_SIGN_COMPR # 11.4M — our constraint | |
| # Schnorr (BIP-340) verification benchmark | |
| SCHNORR_VERIFY_US = 28.0 # microseconds per signature | |
| SCHNORR_US_PER_BYTE = SCHNORR_VERIFY_US / 64 # 0.4375 us/byte | |
| SHA256_COMPR_US = 0.135 # microseconds per compression (-march=native) | |
| SCHNORR_COMPR_PER_BYTE = SCHNORR_US_PER_BYTE / SHA256_COMPR_US # ~3.24 | |
| def compute_Th(n_values): | |
| """Compression calls for tweakable hash of n_values hash-sized inputs. | |
| Input layout: PKseed(128) + Tweak(96) + n*128 bits + padding(65 bits). | |
| SHA-256 block = 512 bits. | |
| Matches costs.sage compute_Th() (line 52). | |
| """ | |
| return math.ceil((128 + 96 + 128 * n_values + 65) / 512) | |
| def compute_wots_l(w, use_wotsc): | |
| """Number of WOTS chains. | |
| WOTS+C: l = l1 (no checksum chains, counter replaces checksum). | |
| WOTS+: l = l1 + l2 (includes checksum chains). | |
| Matches costs.sage compute_wots_l() (line 129). | |
| """ | |
| n_bits = HASHBYTES * 8 # 128 | |
| log_w = round(math.log2(w)) # w is always a power of 2 | |
| l1 = n_bits // log_w | |
| if use_wotsc: | |
| return l1 | |
| # WOTS+: add checksum chains | |
| l2 = math.ceil(math.log(l1 * (w - 1), w)) | |
| # Use ceiling of log_w for integer chain count | |
| l2 = math.ceil(math.log2(l1 * (w - 1)) / math.log2(w)) | |
| return l1 + l2 | |
| def compute_nu(l, paramsum, w): | |
| """Number of valid WOTS+C encodings for target digit sum = paramsum. | |
| Counts l-tuples of digits in {0,...,w-1} summing to exactly paramsum. | |
| Uses inclusion-exclusion formula from costs.sage compute_nu() (line 146). | |
| """ | |
| nu = 0 | |
| for j in range(l + 1): | |
| sign = (-1) ** j | |
| binom1 = math.comb(l, j) | |
| n = (paramsum + l) - j * w - 1 | |
| if n >= l - 1 and l - 1 >= 0: | |
| binom2 = math.comb(n, l - 1) | |
| else: | |
| binom2 = 0 | |
| nu += sign * binom1 * binom2 | |
| return max(nu, 1) # avoid division by zero | |
| # ============================================================================= | |
| # SHRINCS bare OTS: cost functions | |
| # ============================================================================= | |
| # | |
| # SHRINCS uses an unbalanced XMSS tree where each leaf is a WOTS(+C) OTS. | |
| # The q-th signature (1-indexed) consists of: | |
| # - Randomizer R (RANDOMNESS_SIZE bytes) | |
| # - WOTS(+C) signature: l chain values (l * HASHBYTES bytes) | |
| # + counter (COUNTER_SIZE bytes) if WOTS+C | |
| # - Authentication path: q sibling hashes (q * HASHBYTES bytes) | |
| # - Sibling public key: HASHBYTES bytes (other SHRINCS instance's PK.root) | |
| # | |
| # Verification: | |
| # - Hash message: C_Hmsg compressions | |
| # - Complete WOTS chains to reconstruct public key | |
| # - Compress l chain tops to get WOTS pk: Th(l) compressions | |
| # - Verify auth path: q hashes up the unbalanced tree | |
| # - Hash (pk_stateful, pk_stateless) to reconstruct SHRINCS pk | |
| def shrincs_ots_size(w, use_wotsc, q=1): | |
| """SHRINCS OTS signature size in bytes.""" | |
| l = compute_wots_l(w, use_wotsc) | |
| size = RANDOMNESS_SIZE # R | |
| size += l * HASHBYTES # chain values | |
| if use_wotsc: | |
| size += COUNTER_SIZE # counter | |
| size += q * HASHBYTES # auth path (q siblings) | |
| size += HASHBYTES # sibling pk (PK.root of other instance) | |
| return size | |
| def shrincs_ots_verify_compr(w, swn, use_wotsc, q=1): | |
| """SHRINCS OTS verification cost in SHA-256 compression calls. | |
| For WOTS+C: verifier completes chains from revealed positions. | |
| Cost = (l*(w-1) - S_wn) * C_Th1 + C_Th1c + Th(l) | |
| For WOTS+: expected half-chain completion. | |
| Cost = l*(w-1)//2 * C_Th1 + Th(l) | |
| Plus auth path, message hash, and pk reconstruction. | |
| Follows costs.sage compute_verification_time() (line 416). | |
| """ | |
| l = compute_wots_l(w, use_wotsc) | |
| Thl = compute_Th(l) | |
| if use_wotsc: | |
| # WOTS+C: deterministic chain completion | |
| wots_compr = ((w - 1) * l - swn) * C_Th1 + C_Th1c + Thl | |
| else: | |
| # WOTS+: expected chain completion (average digit = (w-1)/2) | |
| wots_compr = ((w - 1) * l // 2) * C_Th1 + Thl | |
| auth_compr = q * C_Th2 # auth path: q Merkle hashes | |
| msg_compr = C_Hmsg # message hash | |
| pk_compr = C_Th2 # hash(pk_stateful, pk_stateless) | |
| return wots_compr + auth_compr + msg_compr + pk_compr | |
| def shrincs_ots_sign_compr(w, swn, use_wotsc): | |
| """SHRINCS OTS signing cost in SHA-256 compression calls. | |
| Includes: | |
| - Secret key derivation: l PRF calls | |
| - Chain computation: sum-of-digits hash calls (WOTS+C) or l*(w-1)/2 (WOTS+) | |
| - Grinding cost (WOTS+C only): expected trials * (C_Hmsg + C_PRFmsg) | |
| - Auth path computation: negligible (single node for q=1) | |
| For WOTS+C grinding: probability of hitting target sum S_wn is nu / w^l. | |
| Expected trials = w^l / nu. Each trial costs C_Hmsg + C_PRFmsg compressions. | |
| """ | |
| l = compute_wots_l(w, use_wotsc) | |
| # Secret key derivation | |
| keygen_compr = l * C_PRF | |
| if use_wotsc: | |
| # Chain computation: signer hashes from 0 to digit_i for each chain. | |
| # With target sum S_wn, expected sum of digits = S_wn. | |
| chain_compr = swn * C_Th1 | |
| # Grinding: find counter where digit sum = S_wn | |
| nu = compute_nu(l, swn, w) | |
| # Expected trials = w^l / nu. Use log-space to avoid overflow. | |
| log2_trials = l * math.log2(w) - math.log2(nu) | |
| expected_trials = 2.0 ** log2_trials | |
| grind_compr = expected_trials * (C_Hmsg + C_PRFmsg) | |
| return keygen_compr + chain_compr + grind_compr | |
| else: | |
| # WOTS+: no grinding; signer hashes to digit positions | |
| # Expected chain computation = l * (w-1) / 2 | |
| chain_compr = (l * (w - 1) // 2) * C_Th1 | |
| return keygen_compr + chain_compr | |
| # ============================================================================= | |
| # SHRINCS parameter search | |
| # ============================================================================= | |
| def search_shrincs_params(): | |
| """Systematic search for SHRINCS bare OTS parameters. | |
| Searches over: | |
| - w in {4, 16, 256} (Winternitz parameter) | |
| - WOTS+C (with counter) vs WOTS+ (with checksum) | |
| - S_wn sweep (WOTS+C only) | |
| Returns list of dicts sorted by signature size. | |
| """ | |
| results = [] | |
| for w in [4, 16, 256]: | |
| for use_wotsc in [True, False]: | |
| l = compute_wots_l(w, use_wotsc) | |
| max_swn = l * (w - 1) | |
| size = shrincs_ots_size(w, use_wotsc) | |
| if use_wotsc: | |
| # Sweep S_wn from 0 to max in steps | |
| # Use finer steps near boundaries of interest | |
| swn_values = sorted(set( | |
| list(range(0, max_swn + 1, max(1, max_swn // 500))) | |
| + [max_swn] | |
| )) | |
| for swn in swn_values: | |
| verify = shrincs_ots_verify_compr(w, swn, True) | |
| sign = shrincs_ots_sign_compr(w, swn, True) | |
| cpb = verify / size | |
| results.append({ | |
| 'w': w, 'wotsc': True, 'l': l, 'swn': swn, | |
| 'size': size, 'verify_compr': verify, | |
| 'sign_compr': sign, 'cpb': cpb, | |
| }) | |
| else: | |
| # WOTS+ has no S_wn parameter; single result per w | |
| verify = shrincs_ots_verify_compr(w, 0, False) | |
| sign = shrincs_ots_sign_compr(w, 0, False) | |
| cpb = verify / size | |
| results.append({ | |
| 'w': w, 'wotsc': False, 'l': l, 'swn': 0, | |
| 'size': size, 'verify_compr': verify, | |
| 'sign_compr': sign, 'cpb': cpb, | |
| }) | |
| return results | |
| def find_best_shrincs(target_cpb): | |
| """Find smallest SHRINCS sig with compr/byte <= target_cpb. | |
| Filters by signing cost <= MAX_SIGN_COMPR. | |
| Returns the result with smallest signature size, breaking ties by lowest c/B. | |
| """ | |
| all_results = search_shrincs_params() | |
| # Filter by signing constraint and c/B target | |
| candidates = [r for r in all_results | |
| if r['sign_compr'] <= MAX_SIGN_COMPR and r['cpb'] <= target_cpb] | |
| if not candidates: | |
| return None | |
| return min(candidates, key=lambda r: (r['size'], r['cpb'])) | |
| def print_shrincs_search_results(): | |
| """Print SHRINCS parameter search results as a table.""" | |
| all_results = search_shrincs_params() | |
| feasible = [r for r in all_results if r['sign_compr'] <= MAX_SIGN_COMPR] | |
| # Group by (w, wotsc) and show best per group + boundary points | |
| print("=" * 90) | |
| print("SHRINCS Bare OTS Parameter Search") | |
| print(f"Constraint: sign_compr <= {MAX_SIGN_COMPR:,.0f} ({MAX_SIGN_COMPR/SLH_DSA_SIGN_COMPR:.0f}x SLH-DSA)") | |
| print("=" * 90) | |
| for w in [4, 16, 256]: | |
| for use_wotsc in [True, False]: | |
| label = f"WOTS+C" if use_wotsc else f"WOTS+" | |
| group = [r for r in feasible if r['w'] == w and r['wotsc'] == use_wotsc] | |
| if not group: | |
| print(f"\n {label} w={w}: no feasible params") | |
| continue | |
| # Show range | |
| min_cpb = min(r['cpb'] for r in group) | |
| max_cpb = max(r['cpb'] for r in group) | |
| size = group[0]['size'] | |
| print(f"\n {label} w={w}, l={group[0]['l']}, sig={size} B, " | |
| f"c/B range: [{min_cpb:.3f}, {max_cpb:.3f}]") | |
| if use_wotsc: | |
| # Show key points: min c/B, Schnorr threshold, SLH-DSA threshold | |
| thresholds = [ | |
| ("min c/B", min(group, key=lambda r: r['cpb'])), | |
| ("Schnorr (~3.24)", min((r for r in group if r['cpb'] <= SCHNORR_COMPR_PER_BYTE), key=lambda r: r['cpb'], default=None)), | |
| ("SLH-DSA (~0.30)", min((r for r in group if r['cpb'] <= 0.30), key=lambda r: r['cpb'], default=None)), | |
| ] | |
| print(f" {'Target':<20s} {'S_wn':>6s} {'Size':>6s} {'Verify(C)':>10s} {'Sign(C)':>12s} {'c/B':>8s}") | |
| print(f" {'-'*20} {'-'*6} {'-'*6} {'-'*10} {'-'*12} {'-'*8}") | |
| for name, r in thresholds: | |
| if r is None: | |
| print(f" {name:<20s} {'N/A (infeasible)':>50s}") | |
| else: | |
| print(f" {name:<20s} {r['swn']:>6d} {r['size']:>6d} {r['verify_compr']:>10,.0f} {r['sign_compr']:>12,.0f} {r['cpb']:>8.3f}") | |
| else: | |
| r = group[0] | |
| print(f" {'S_wn':>6s} {'Size':>6s} {'Verify(C)':>10s} {'Sign(C)':>12s} {'c/B':>8s}") | |
| print(f" {'-'*6} {'-'*6} {'-'*10} {'-'*12} {'-'*8}") | |
| print(f" {'N/A':>6s} {r['size']:>6d} {r['verify_compr']:>10,.0f} {r['sign_compr']:>12,.0f} {r['cpb']:>8.3f}") | |
| # Print selected params for both plots | |
| print("\n" + "=" * 90) | |
| print("Selected parameters:") | |
| print("=" * 90) | |
| size_opt = find_best_shrincs(SCHNORR_COMPR_PER_BYTE) | |
| verify_opt = find_best_shrincs(0.30) | |
| for label, r in [("Size-optimized (c/B <= Schnorr)", size_opt), | |
| ("Verify-optimized (c/B <= SLH-DSA)", verify_opt)]: | |
| if r is None: | |
| print(f" {label}: no feasible params found") | |
| else: | |
| wots_label = "WOTS+C" if r['wotsc'] else "WOTS+" | |
| print(f" {label}:") | |
| print(f" {wots_label} w={r['w']}, l={r['l']}, S_wn={r['swn']}") | |
| print(f" sig={r['size']} B, verify={r['verify_compr']:,.0f} compr, " | |
| f"sign={r['sign_compr']:,.0f} compr, c/B={r['cpb']:.3f}") | |
| # ============================================================================= | |
| # Transaction model | |
| # ============================================================================= | |
| BLOCK_WEIGHT = 4_000_000 # weight units | |
| BLOCK_INTERVAL = 600 # seconds | |
| N_INPUTS = 2.27 # 90-day avg, transactionfee.info, 2026-04-01 | |
| N_OUTPUTS = 2.64 # same source | |
| # Non-witness bytes per tx (identical for all schemes) | |
| NON_WITNESS = (4 # version | |
| + 1 # input count (varint, <= 252) | |
| + N_INPUTS * (32 + 4 + 1 + 4) # per input: prevout + scriptlen + seq | |
| + 1 # output count | |
| + N_OUTPUTS * (8 + 1 + 34) # per output: amount + scriptlen + script | |
| + 4) # locktime | |
| # Witness encoding per input: stack_items(1) + varint(sig_len) + sig | |
| # We assume the scriptPubKey commits directly to the PQ public key | |
| # (analogous to P2TR keyspend), so no script or control block overhead. | |
| # P2TR keyspend: 1 + 1 + 64 = 66 bytes | |
| # PQ keyspend: 1 + varint(sig_len) + sig_len | |
| def witness_per_input(sig_size): | |
| """Witness bytes per input for a given signature size.""" | |
| # varint: 1 byte for sig < 253, 3 bytes for 253 <= sig < 65536 | |
| varint_len = 1 if sig_size < 253 else 3 | |
| return 1 + varint_len + sig_size # stack_items + varint + sig | |
| def tx_weight(sig_size): | |
| """Compute average transaction weight in WU. | |
| Segwit weight = 4 * non_witness_bytes + 1 * witness_bytes. | |
| """ | |
| witness = 2 + N_INPUTS * witness_per_input(sig_size) # 2 = marker + flag | |
| return 4 * NON_WITNESS + witness | |
| def tps(sig_size): | |
| """Transactions per second for given signature size.""" | |
| return BLOCK_WEIGHT / tx_weight(sig_size) / BLOCK_INTERVAL | |
| # ============================================================================= | |
| # Scheme definitions | |
| # ============================================================================= | |
| def build_schemes(): | |
| """Build scheme parameter dicts for both plots. | |
| Each scheme has: | |
| name, sig_size, compr_per_byte, sign_compr, | |
| is_keyspend (bool), verify_cmd (costs.sage command or formula) | |
| """ | |
| # --- SHRINCS parameters (from search) --- | |
| # Each plot uses a c/B threshold: Schnorr (~3.24) or SLH-DSA (0.30). | |
| # find_best_shrincs returns the smallest sig meeting the c/B constraint. | |
| shrincs_size = find_best_shrincs(SCHNORR_COMPR_PER_BYTE) | |
| shrincs_verify = find_best_shrincs(0.30) | |
| # --- Fixed SPHINCS+ parameters (from paper tables, verified via costs.sage) --- | |
| # SLH-DSA: sage costs.sage --params SPX 64 14 12 63 7 16 0 | |
| slh_dsa = dict(name='SLH-DSA', sig=7872, cpb=0.30, sign_c=2_280_000, | |
| cmd='--params SPX 64 14 12 63 7 16 0') | |
| # Optimized SPHINCS+ (q_s=2^40) | |
| # Size: sage costs.sage --params W+C_P+FP 40 11 14 40 5 256 2040 | |
| sphincs_size = dict(name='Opt. SPHINCS+', sig=4036, cpb=2.62, sign_c=6_420_000, | |
| cmd='--params W+C_P+FP 40 11 14 40 5 256 2040') | |
| # Verify: sage costs.sage --params W+C_P+FP 40 8 16 44 4 16 240 | |
| sphincs_verify = dict(name='Opt. SPHINCS+', sig=4480, cpb=0.29, sign_c=6_870_000, | |
| cmd='--params W+C_P+FP 40 8 16 44 4 16 240') | |
| # SHRIMPS compact (q_s=2^10) | |
| # Verify: sage costs.sage --params W+C_P+FP 10 8 17 12 1 16 240 | |
| # Size from post: 2548 + 16 (sibling pk) = 2564 B; compr/byte ~0.19 | |
| shrimps_verify = dict(name='SHRIMPS', sig=2564, cpb=0.19, sign_c=6_800_000, | |
| cmd='--params W+C_P+FP 10 8 17 12 1 16 240') | |
| # Size-optimized SHRIMPS: same params as verify-opt. SHRIMPS signature | |
| # size is dominated by the PORS+FP component (~1808 B / 2564 B), not WOTS. | |
| # Using w=256 saves bytes in WOTS chains but forces d>=2 (signing budget), | |
| # adding a second WOTS+auth layer that roughly cancels the savings. | |
| shrimps_size = dict(name='SHRIMPS', sig=2564, cpb=0.19, sign_c=6_800_000, | |
| cmd='--params W+C_P+FP 10 8 17 12 1 16 240') | |
| # --- Build per-plot scheme lists --- | |
| def make_shrincs(r, name='SHRINCS'): | |
| wots = "WOTS+C" if r['wotsc'] else "WOTS+" | |
| return dict(name=name, sig=r['size'], cpb=r['cpb'], | |
| sign_c=r['sign_compr'], | |
| cmd=f"bare OTS: {wots} w={r['w']} l={r['l']} S_wn={r['swn']}") | |
| def make_mix(shrincs_r, shrimps_d, fallback_d): | |
| """60% SHRINCS + 30% SHRIMPS + 10% fallback.""" | |
| avg_sig = (0.60 * shrincs_r['size'] | |
| + 0.30 * shrimps_d['sig'] | |
| + 0.10 * fallback_d['sig']) | |
| # Weighted average compr/byte (for block verification time) | |
| avg_cpb = (0.60 * shrincs_r['cpb'] * shrincs_r['size'] | |
| + 0.30 * shrimps_d['cpb'] * shrimps_d['sig'] | |
| + 0.10 * fallback_d['cpb'] * fallback_d['sig']) / avg_sig | |
| return dict(name='Mixed', sig=avg_sig, cpb=avg_cpb, sign_c=0, | |
| cmd='60% SHRINCS + 30% SHRIMPS + 10% fallback') | |
| p2tr = dict(name='P2TR', sig=64, cpb=None, sign_c=0, cmd='BIP-340 Schnorr') | |
| size_schemes = [ | |
| p2tr, | |
| make_shrincs(shrincs_size), | |
| shrimps_size, | |
| make_mix(shrincs_size, shrimps_size, sphincs_size), | |
| sphincs_size, | |
| slh_dsa, | |
| ] | |
| verify_schemes = [ | |
| p2tr, | |
| make_shrincs(shrincs_verify), | |
| shrimps_verify, | |
| make_mix(shrincs_verify, shrimps_verify, sphincs_verify), | |
| sphincs_verify, | |
| slh_dsa, | |
| ] | |
| return size_schemes, verify_schemes | |
| # ============================================================================= | |
| # Plotting | |
| # ============================================================================= | |
| # Color and marker for each scheme name | |
| STYLE = { | |
| 'P2TR': dict(color='#1f77b4', marker='D', zorder=10), | |
| 'SHRINCS': dict(color='#2ca02c', marker='s', zorder=10), | |
| 'SHRIMPS': dict(color='#ff7f0e', marker='^', zorder=10), | |
| 'Mixed': dict(color='#9467bd', marker='p', zorder=10), | |
| 'Opt. SPHINCS+': dict(color='#d62728', marker='o', zorder=10), | |
| 'SLH-DSA': dict(color='#8c564b', marker='v', zorder=10), | |
| } | |
| # Annotation offsets per scheme, per plot. (dx, dy) in points. | |
| # Tuned to avoid overlaps given the data positions. | |
| # Annotation offsets for the short label next to each point. | |
| # (dx, dy) in points. Positive dx = label to the right, negative = left. | |
| # | |
| # Size-opt Y values: P2TR 6.81, SHRINCS 3.85, Mixed 1.37, SHRIMPS 0.98, | |
| # SPHINCS+ 0.66, SLH-DSA 0.35 | |
| # Verify-opt Y values: P2TR 6.81, SHRINCS 2.88, Mixed 1.28, SHRIMPS 0.98, | |
| # SPHINCS+ 0.60, SLH-DSA 0.35 | |
| ANNOT_OFFSETS = { | |
| 'size': { | |
| 'P2TR': (18, -14), | |
| 'SHRINCS': (18, 12), | |
| 'Mixed': (-18, 14), | |
| 'SHRIMPS': (-18, 14), | |
| 'Opt. SPHINCS+': (18, 14), | |
| 'SLH-DSA': (18, -14), | |
| }, | |
| 'verify': { | |
| 'P2TR': (18, -14), | |
| 'SHRINCS': (18, 12), | |
| 'Mixed': (-18, 14), | |
| 'SHRIMPS': (-18, 14), | |
| 'Opt. SPHINCS+': (18, 14), | |
| 'SLH-DSA': (18, -14), | |
| }, | |
| } | |
| # Display labels for each scheme | |
| LABELS = { | |
| 'P2TR': 'P2TR keyspend only', | |
| 'SHRINCS': 'SHRINCS only', | |
| 'SHRIMPS': 'SHRIMPS only', | |
| 'Mixed': 'Mixed (60/30/10)', | |
| 'Opt. SPHINCS+': 'Opt. SPHINCS+ only', | |
| 'SLH-DSA': 'SLH-DSA only', | |
| } | |
| def utxos_per_block(tps_val): | |
| """UTXOs spent per block given TPS.""" | |
| return tps_val * BLOCK_INTERVAL * N_INPUTS | |
| def plot_single(schemes, title, caption_extra, offsets, filename): | |
| """Generate a single standalone plot.""" | |
| fig, ax = plt.subplots(figsize=(10, 8)) | |
| # Collect data for the table | |
| table_rows = [] | |
| for s in schemes: | |
| y = tps(s['sig']) | |
| style = STYLE[s['name']] | |
| ax.plot(s['sig'], y, markersize=14, markeredgecolor='black', | |
| markeredgewidth=0.8, **style) | |
| # Short label near the point (just the name) | |
| label = LABELS[s['name']] | |
| offset = offsets.get(s['name'], (15, 0)) | |
| ha = 'left' if offset[0] > 0 else 'right' | |
| ax.annotate(label, (s['sig'], y), textcoords='offset points', | |
| xytext=offset, fontsize=11, va='center', ha=ha, | |
| color=style['color'], fontweight='bold', | |
| arrowprops=dict(arrowstyle='-', color=style['color'], | |
| lw=0.8, alpha=0.5)) | |
| table_rows.append((label, f"{s['sig']:.0f}", f"{y:.2f}", | |
| f"{utxos_per_block(y):,.0f}")) | |
| # Data table in the empty upper-right area, sorted by sig size | |
| table_rows.sort(key=lambda r: float(r[1])) | |
| col_labels = ['Scheme', 'Sig (B)', 'TPS', 'UTXOs/blk'] | |
| table = ax.table( | |
| cellText=table_rows, | |
| colLabels=col_labels, | |
| loc='upper right', | |
| cellLoc='right', | |
| colWidths=[0.24, 0.10, 0.08, 0.13], | |
| ) | |
| table.auto_set_font_size(False) | |
| table.set_fontsize(10) | |
| table.scale(1, 1.3) | |
| # Style the table | |
| for (row, col), cell in table.get_celld().items(): | |
| cell.set_edgecolor('#cccccc') | |
| cell.set_linewidth(0.5) | |
| if row == 0: | |
| cell.set_facecolor('#f0f0f0') | |
| cell.set_text_props(fontweight='bold') | |
| else: | |
| cell.set_facecolor('white') | |
| cell.set_alpha(0.9) | |
| if col == 0: | |
| cell.set_text_props(ha='left') | |
| ax.set_xscale('log') | |
| ax.set_xlabel('Signature size (bytes)', fontsize=14) | |
| ax.set_ylabel('Transactions per second', fontsize=14) | |
| ax.set_title(title, fontsize=16, fontweight='bold', pad=14) | |
| ax.xaxis.set_major_formatter(ticker.FuncFormatter( | |
| lambda x, _: f'{x:,.0f}')) | |
| ax.tick_params(labelsize=12) | |
| ax.set_xlim(40, 15000) | |
| ax.set_ylim(bottom=0) | |
| ax.yaxis.grid(True, alpha=0.25) | |
| ax.xaxis.grid(False) | |
| ax.set_axisbelow(True) | |
| # Secondary Y-axis: UTXOs spent per block | |
| ax2 = ax.twinx() | |
| ymin, ymax = ax.get_ylim() | |
| ax2.set_ylim(utxos_per_block(ymin), utxos_per_block(ymax)) | |
| ax2.set_ylabel('UTXOs spent per block', fontsize=14, color='#666666') | |
| ax2.tick_params(labelsize=12, colors='#666666') | |
| ax2.yaxis.set_major_formatter(ticker.FuncFormatter( | |
| lambda x, _: f'{x:,.0f}')) | |
| # Caption below the plot | |
| caption = ( | |
| caption_extra + " " | |
| "Each point assumes all block spends use that scheme.\n" | |
| "Mixed = 60% SHRINCS + 30% SHRIMPS + 10% fallback (opt. SPHINCS+). " | |
| f"Average tx: {N_INPUTS:.2f} inputs, {N_OUTPUTS:.2f} outputs " | |
| "(transactionfee.info 90-day avg, 2026-04-01).\n" | |
| f"4 MWU blocks, 600 s interval. " | |
| f"Output commits directly to PQ public key (no tapscript overhead). " | |
| f"Signing cost <= 5x SLH-DSA ({MAX_SIGN_COMPR/1e6:.1f}M SHA-256 compr.)." | |
| ) | |
| fig.text(0.5, -0.005, caption, ha='center', va='top', fontsize=9.5, | |
| color='#555555', linespacing=1.5) | |
| fig.tight_layout(rect=[0, 0, 1, 1]) | |
| fig.savefig(filename, dpi=200, bbox_inches='tight', | |
| pad_inches=0.3) | |
| print(f"Saved: {filename}") | |
| return fig | |
| def make_figures(): | |
| """Generate two separate figures.""" | |
| size_schemes, verify_schemes = build_schemes() | |
| plot_single( | |
| size_schemes, | |
| 'Bitcoin Post-Quantum Throughput: Size-Optimized', | |
| 'Verification time per signature byte does not exceed that of Schnorr signatures.', | |
| ANNOT_OFFSETS['size'], | |
| 'tps_size_optimized.png', | |
| ) | |
| plot_single( | |
| verify_schemes, | |
| 'Bitcoin Post-Quantum Throughput: Verification-Optimized', | |
| 'Verification time per signature byte does not exceed that of SLH-DSA.', | |
| ANNOT_OFFSETS['verify'], | |
| 'tps_verify_optimized.png', | |
| ) | |
| # ============================================================================= | |
| # Main | |
| # ============================================================================= | |
| if __name__ == '__main__': | |
| import sys | |
| if '--search' in sys.argv: | |
| print_shrincs_search_results() | |
| else: | |
| # Always print params for transparency | |
| print_shrincs_search_results() | |
| print() | |
| # Print scheme summary for both plots | |
| size_schemes, verify_schemes = build_schemes() | |
| for label, schemes in [("SIZE-OPTIMIZED", size_schemes), | |
| ("VERIFY-OPTIMIZED", verify_schemes)]: | |
| print(f"\n{'=' * 70}") | |
| print(f" {label} PLOT") | |
| print(f"{'=' * 70}") | |
| print(f" {'Scheme':<16s} {'Sig(B)':>8s} {'TPS':>8s} {'c/B':>8s} " | |
| f"{'UTXOs/blk':>12s} {'Verification':>30s}") | |
| print(f" {'-'*16} {'-'*8} {'-'*8} {'-'*8} {'-'*12} {'-'*30}") | |
| for s in schemes: | |
| y = tps(s['sig']) | |
| cpb_str = 'N/A' if s.get('cpb') is None else f"{s['cpb']:.3f}" | |
| utxos = utxos_per_block(y) | |
| print(f" {s['name']:<16s} {s['sig']:>8.0f} {y:>8.2f} {cpb_str:>8s} " | |
| f"{utxos:>12,.0f} {s['cmd']:>30s}") | |
| print("\nGenerating figures...") | |
| make_figures() | |
| plt.show() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment