Skip to content

Instantly share code, notes, and snippets.

@jonasnick
Last active April 1, 2026 13:44
Show Gist options
  • Select an option

  • Save jonasnick/0e7a3c4a41063e39afe8a16f70662fd3 to your computer and use it in GitHub Desktop.

Select an option

Save jonasnick/0e7a3c4a41063e39afe8a16f70662fd3 to your computer and use it in GitHub Desktop.
#!/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