Skip to content

Repository files navigation

Zcash Transaction Fee Market — cadCAD Simulation

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.py from 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.

Quick Start

# 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 lab

Model Architecture

Each 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)"]
Loading

Fee Model Protocol

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.


Project Structure

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

CLI Reference

Stochastic simulation

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

Historical backtest

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.

Data fetcher

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.


New Fee Models

zip317_lowerbase — Sensitivity Analysis

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.

zip317_marginalfee — Dynamic Marginal Rate

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 Design

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.


Output Schema

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)

Historical Backtest

sim/generators/historical.py provides two classes:

HistoricalGenerator (calibration)

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.

ParquetBacktest (replay)

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.

About

A simulation framework for modeling Zcash fee mechanisms

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages