A pluggable cadCAD simulation framework for modelling Zcash fee markets across historical and future fee eras (Genesis, ZIP-313, ZIP-317, and beyond), including two new sensitivity / research fee models. Two complementary modes are supported:
- Stochastic simulation — Poisson arrivals with lognormal fee/weight distributions, fully configurable via CLI.
- Historical backtest — replays real on-chain transactions (fetched via
zcash_analytics.pyfrom a Zebra node) through each fee model using the same greedy mining logic, enabling counterfactual analysis of what different fee rules would have done to real blocks.
# Install dependencies
uv sync
# ── Stochastic simulation ─────────────────────────────────────────────────────
# Simulate a single fee model (500 blocks, 10 Monte Carlo runs)
uv run python run_sim.py --fee-model zip317_marginalfee --steps 500 --runs 10
# Sweep all five models and compare
uv run python run_sim.py --sweep --steps 500 --runs 10 --output output/sweep_results.csv
# ── Historical backtest ───────────────────────────────────────────────────────
# 1. Fetch on-chain data from a Zebra node (blocks 2,150,000–2,400,000)
uv run zcash_analytics.py --start 2150000 --end 2400000 --concurrency 16 --out data
# 2. Backtest all five fee models against that data
uv run python run_sim.py --backtest --sweep --data-dir data --output output/backtest_results.csv
# 3. Open the analysis notebook
uv run jupyter labEach simulation timestep represents one Zcash block (~10 s). Two Partial State Update (PSUB) blocks execute in sequence every step.
flowchart TD
P["params\n(fee_model, tx_arrival_rate,\nspam_ratio, weight_params,\nfee_params, seed, …)"]
IS["Initial State\nmempool = []\nblock_tx_count = 0\nminer_revenue = 0\n…"]
subgraph PSUB1["PSUB Block 1 — Transaction Arrival"]
direction TB
GEN["StochasticGenerator\nPoisson arrivals\nlognormal weight & fee\nspam injection"]
FM["FeeModel.is_valid(tx)\nmin_fee() check\n→ reject if fee < minimum"]
SIG1["Signal\nnew_txs, rejected"]
SU1A["s_mempool\nappend new_txs"]
SU1B["s_mempool_weight\nrecompute total WU"]
SU1C["s_rejected_tx_count\nper-step count"]
SU1D["s_total_rejected\ncumulative count"]
GEN --> FM --> SIG1
SIG1 --> SU1A & SU1B & SU1C & SU1D
end
subgraph PSUB2["PSUB Block 2 — Miner Block Construction"]
direction TB
SORT["Sort mempool by\nFeeModel.priority_key(tx) ↓"]
FILL["Greedy fill\nuntil block_weight_limit"]
SIG2["Signal\nblock_tx, block_weight_used\nminer_revenue"]
SU2A["s_mempool_post_mine\nremove mined txs"]
SU2B["s_mempool_weight_post_mine"]
SU2C["s_block_tx_count\ns_block_weight_used\ns_miner_revenue"]
SU2D["s_spam_success_rate\ns_avg_fee_rate_included\ns_avg_fee_rate_mempool\ns_block_utilization"]
SORT --> FILL --> SIG2
SIG2 --> SU2A & SU2B & SU2C & SU2D
end
P & IS --> PSUB1 --> PSUB2
PSUB2 -->|"next timestep\n(next block)"| PSUB1
PSUB2 --> OUT["Output DataFrame\none row per\n(fee_model, run_id, timestep)"]
Every fee era implements the FeeModel abstract base class in sim/fee_models/base.py:
| Method | Purpose |
|---|---|
min_fee(tx) |
Minimum valid fee in zatoshis |
priority_key(tx) |
Mempool sort key — higher = mined first |
is_valid(tx) |
fee >= min_fee(tx) |
block_weight_limit() |
Block capacity (WU or bytes) |
prepare_block(sH) |
Hook called before each block's arrival phase; no-op for static models |
| Model | Era / Purpose | Min fee | Priority | Block limit |
|---|---|---|---|---|
GenesisModel |
Launch → Canopy | 10,000 zat flat | Absolute fee | 2 MB |
ZIP313Model |
Canopy → Nu6 | 1,000 zat flat | Absolute fee | 2 MB |
ZIP317Model |
Nu6+ | max(200×WU, 5,000 zat) |
Fee rate (zat/WU) | 400,000 WU |
ZIP317LowerBaseModel |
Sensitivity analysis | max(100×WU, 2,500 zat) |
Fee rate (zat/WU) | 400,000 WU |
ZIP317MarginalFeeModel |
Research / dynamic | max(rate×WU, 1,000 zat) where rate = 50-block median |
Fee rate (zat/WU) | 400,000 WU |
To add a new fee model: create sim/fee_models/mymodel.py subclassing FeeModel, register it in sim/fee_models/__init__.py.
zip_fee_modeling/
├── run_sim.py # CLI entry point (stochastic + backtest modes)
├── zcash_analytics.py # Async Zebra RPC fetcher → blocks/transactions parquet
├── pyproject.toml # UV-managed dependencies
├── data/ # Historical parquet data (produced by zcash_analytics.py)
│ ├── blocks.parquet # One row per block, block-level aggregates
│ └── transactions.parquet # One row per non-coinbase transaction
├── sim/
│ ├── fee_models/
│ │ ├── base.py # FeeModel abstract class (+ prepare_block hook)
│ │ ├── genesis.py # 10,000 zat flat fee
│ │ ├── zip313.py # 1,000 zat flat fee
│ │ ├── zip317.py # 200 zat/WU weight-based
│ │ ├── zip317_lowerbase.py # 100 zat/WU · 2,500 zat floor (sensitivity)
│ │ └── zip317_marginalfee.py # Dynamic 50-block-median rate · 1,000 zat floor
│ ├── generators/
│ │ ├── stochastic.py # Poisson arrivals + lognormal fee/weight
│ │ └── historical.py # ParquetBacktest (replay) + HistoricalGenerator (calibration)
│ ├── policies/
│ │ ├── arrival.py # p_arrival — generate & filter txs
│ │ └── mining.py # p_mine — greedy block construction
│ ├── state_updates/
│ │ ├── mempool.py # Mempool append / post-mine removal
│ │ └── block.py # Block metrics (revenue, utilisation, spam rate)
│ ├── params.py # make_params() — cadCAD parameter factory
│ ├── state.py # INITIAL_STATE definition
│ └── config.py # build_experiment() — assembles cadCAD Experiment
├── notebooks/
│ ├── analysis.ipynb # Stochastic simulation analysis (all 5 models)
│ └── historical_analysis.ipynb # On-chain data diagnostics + fee compliance
└── output/ # Simulation and backtest CSVs, saved chart PNGs
uv run python run_sim.py [OPTIONS]
Options:
--fee-model genesis | zip313 | zip317 | zip317_lowerbase | zip317_marginalfee
Fee model to simulate (default: genesis)
--steps INT Blocks per run (default: 500)
--runs INT Monte Carlo runs (default: 10)
--spam-ratio FLOAT Override spam fraction (0.0–1.0)
--tx-arrival-rate FLOAT Override Poisson λ (txs/block)
--seed INT Base RNG seed (default: 42)
--output PATH Output CSV path
--sweep Run all five models and merge output
uv run python run_sim.py --backtest [OPTIONS]
Options:
--backtest Enable historical replay mode
--data-dir PATH Folder with blocks.parquet + transactions.parquet (default: data/)
--fee-model genesis | zip313 | zip317 | zip317_lowerbase | zip317_marginalfee
Single model to backtest (default: genesis)
--sweep Backtest all five models and merge output
--output PATH Output CSV path
--backtest ignores --steps, --runs, --seed, --spam-ratio, and --tx-arrival-rate; the block sequence and transaction set are driven entirely by the parquet data.
uv run zcash_analytics.py [OPTIONS]
Options:
--host HOST Zebra / zcashd node host (or ZCASH_HOST env var)
--port INT RPC port (default: 8232)
--user / --password RPC credentials (or RPC_USER / RPC_PASS env vars)
--start INT Start block height (inclusive, required)
--end INT End block height (inclusive, default: chain tip)
--concurrency INT Max concurrent RPC requests (default: 8)
--out PATH Output directory (default: ./zcash_data)
Outputs blocks.parquet and transactions.parquet (+ transactions.csv) to --out.
A stripped-down variant of ZIP-317 that halves both the per-WU rate and the absolute floor:
| Parameter | ZIP-317 | zip317_lowerbase |
|---|---|---|
| Min rate | 200 zat/WU | 100 zat/WU |
| Absolute floor | 5,000 zat | 2,500 zat |
| Block limit | 400,000 WU | 400,000 WU |
| Priority | fee rate (zat/WU) | fee rate (zat/WU) |
Use this model to test whether the exact fee floor in ZIP-317 is load-bearing for spam resistance and miner revenue, or whether a cheaper threshold achieves equivalent outcomes.
An adaptive variant of ZIP-317 where the minimum fee rate is updated every block from real market data instead of being hard-coded:
marginal_rate(t) = median { fee_zat / weight for all txs in blocks [t-55 … t-5] }
min_fee(tx) = max(marginal_rate × weight, 1,000 zat)
The 5-block buffer (ZIP317MF_BUFFER) guards against shallow reorganisations; the 50-block window (ZIP317MF_LOOKBACK) smooths short-term spikes. The rate falls back to 200 zat/WU (the ZIP-317 default) until the window is fully populated.
Stochastic mode: prepare_block(sH) is called before each block's arrival phase and reads avg_fee_rate_included from the cadCAD state history to recompute the rate.
Backtest mode: run_backtest() computes median(fee_zat / tx_size) from actual parquet transactions over the same window and pushes the result into both the fee model and the ParquetBacktest loader before each block is processed.
The marginal_fee_rate column in the output CSV records the rate that was active for each block, enabling post-hoc analysis of how the threshold adapts.
Monte Carlo runs are implemented as a seed sweep in the cadCAD parameter space: run i uses seed = base_seed + i. This gives each run an independent, reproducible RNG trajectory. The run_id column in the output CSV identifies each run (0-indexed). cadCAD N=1 is used so cadCAD's own run counter stays out of the way.
Both stochastic and backtest modes write a CSV with the following columns. Backtest output additionally includes block_height.
| Column | Description |
|---|---|
fee_model |
genesis, zip313, zip317, zip317_lowerbase, or zip317_marginalfee |
run_id |
Monte Carlo run index (0-indexed); always 0 for backtest |
timestep |
Sequential block index within the run (1-indexed) |
block_height |
Actual chain block height (backtest only) |
mempool_weight |
Total weight of pending txs before mining (WU or bytes) |
block_tx_count |
Transactions included in the block |
block_weight_used |
Block capacity consumed |
miner_revenue |
Fees collected (zatoshis) |
block_utilization |
block_weight_used / block_weight_limit (0–1) |
spam_success_rate |
Fraction of block txs that were spam |
avg_fee_rate_included |
Mean fee rate of included txs (zat/WU) |
avg_fee_rate_mempool |
Mean fee rate of waiting txs (zat/WU) |
rejected_tx_count |
Txs rejected by fee filter this block |
total_rejected |
Cumulative rejected txs since start (stochastic only) |
marginal_fee_rate |
Current dynamic rate (zat/WU) (zip317_marginalfee only; NaN for static models) |
sim/generators/historical.py provides two classes:
Reads the legacy block-level CSV (height, timestamp, transaction_count, size, total_fees) to compute calibrated_arrival_rate() and calibrated_avg_fee() per era — useful for tuning stochastic simulation parameters.
Loads the rich parquet output from zcash_analytics.py and converts each real transaction into the simulation's internal format for counterfactual analysis.
Weight proxy: tx_size (bytes) is used as the weight field across all fee models, keeping comparisons consistent.
Min-fee thresholds per model:
| Model | Threshold applied |
|---|---|
genesis |
10,000 zat flat |
zip313 |
1,000 zat flat |
zip317 |
conventional_fee_zat from parquet (5,000 × max(2, n_logical_actions)) |
zip317_lowerbase |
max(100 × tx_size, 2,500) |
zip317_marginalfee |
max(dynamic_rate × tx_size, 1,000) where dynamic_rate is the median fee rate from the 50-block window ending 5 blocks before the current tip |
Dynamic rate for zip317_marginalfee: before each block the backtest computes median(fee_zat / tx_size) across all real txs in blocks [tip-55 … tip-5]. When fewer than 5 blocks of history are available the fallback rate is 200 zat/byte (the ZIP-317 default). The same window logic is used in the stochastic simulation via prepare_block(sH) and the cadCAD state history.
is_spam is set when fee_zat < conventional_fee_zat, i.e. the tx paid below the ZIP-317 era's conventional minimum regardless of which model is being tested.
Rolling mempool: transactions that are accepted by the fee filter but not mined carry over to the next block, capturing realistic congestion dynamics.