English · Русский
Reward calculation and payout tooling for a Mina Protocol block producer, working against your own archive node instead of third-party explorer APIs.
Updated for the Mesa hard fork (3 September 2026): 90-second slots, 360 MINA coinbase, reset epoch numbering.
- Why self-hosted data
- What's in the box
- Requirements
- Install
- Configuration
- Connecting to your node
- Daily use
- Ledger and reconciliation
- Payout redirects
- VRF slot prediction
- Running an archive node
- How the calculation works
- Confirmations and reorg safety
- Mesa hard fork notes
- Shell aliases
- Troubleshooting
- Security notes
The Mina ecosystem's public data APIs have not aged well. graphql.minaexplorer.com
stopped resolving, api.minastakes.com returns 500s, and the Foundation's S3
bucket of precomputed blocks was deleted without notice. Anything built on those
is dead.
This project reads from your own archive node's Postgres, plus the daemon's GraphQL for live state. The only external dependency is the public GCS bucket of staking ledgers, which is still maintained — and the block guardian falls back across several mirrors when one disappears.
| Path | What it does |
|---|---|
calc_rewards.py |
Works out each delegator's share, writes e<N>_payouts.csv |
send_payout.py |
Sends the payouts from that CSV |
withdraw.py |
Moves the validator's commission to a cold wallet |
GraphQL.py |
Data layer: archive SQL, daemon GraphQL, staking ledger cache |
mina_client.py |
Small GraphQL client for wallet and payment operations |
vrf/probe.py |
Predicts which slots you win, a whole epoch ahead |
archive/ |
Docker Compose stack for an archive node, with a fixed block guardian |
scripts/ |
Installer, SSH tunnel, shell aliases |
calc.sh |
Convenience wrapper around calc_rewards.py |
- Python 3.9+
- A Mina archive node with its Postgres reachable (see Running an archive node)
- A Mina daemon holding the payout wallet key, GraphQL reachable
jq(used bycalc.sh)- ~300 MB of disk per cached staking ledger
The daemon and archive can live on a remote server; everything talks to
127.0.0.1 through an SSH tunnel.
git clone https://github.com/etcusr/mina-payout-script.git
cd mina-payout-script
./scripts/install.shThe installer creates a virtualenv, installs dependencies, and copies
config.example.yml to config.yml. It never overwrites an existing config.
Everything lives in config.yml, which is gitignored. Start from
config.example.yml — every key is documented there. The essentials:
VALIDATOR_ADDRESS: B62q... # your block producer
SEND_FROM_ADDRESS: B62q... # wallet receiving the coinbase, sends payouts
WITHDRAW_TO_ADDRESS: B62q... # cold wallet for commission
VALIDATOR_FEE: 0.05 # 5%
GRAPHQL_HOST: 127.0.0.1
GRAPHQL_PORT: 3085
ARCHIVE_DB_URL: "postgresql://postgres:postgres@127.0.0.1:5432/archive"The wallet password is never stored. send_payout.py and withdraw.py
prompt for it at runtime with hidden input, so it stays out of shell history and
off disk.
If the node is remote, open a tunnel first — it forwards both the daemon GraphQL and the archive Postgres:
./scripts/tunnel.sh my-node --bg # ssh alias, user@host, whatever ssh takes
./scripts/tunnel.sh --stopThe GraphQL endpoint must point at the daemon that holds the wallet key, since that is where payments get signed. If your daemon exposes GraphQL on a different remote port (a Docker mapping, for instance):
REMOTE_GRAPHQL_PORT=6521 ./scripts/tunnel.sh my-node --bg./calc.sh # current epoch, taken from the daemon
./calc.sh 3 # epoch 3
./calc.sh 3 --vrf # plus the VRF scan (see below)This writes e<N>_payouts.csv — one line per destination address:
B62qDelegator...;3348000000000;3348.0;common;unlocked
address;nanomina;mina;delegation_type;lock_status
==========================================================================
EPOCH 0 chain tip 549218 | canonical tip 548928 | payout threshold 20 confs
==========================================================================
height time (UTC) coinbase archive confs note
-- ------ ---------------- ---------- -------- ------ ----------------
+ 549174 2026-09-06 06:46 360 pending 44 ready to pay out
+ 548900 2026-09-05 15:09 360 canonical 318 ready to pay out
~ 549300 2026-09-06 09:12 360 pending 3 17 more blocks (~1h)
x 548501 2026-09-04 12:01 360 orphaned - orphaned, no reward
'archive' is Postgres' own chain_status, which only flips to canonical at K=290.
Payout eligibility is the 'confs' column against CONFIRMATIONS_NUM=20.
| Marker | Meaning |
|---|---|
+ green |
Enough confirmations, included in this payout |
~ yellow |
Produced but still too shallow, excluded for now |
x red |
Orphaned, no reward |
The verdict at the bottom tells you whether to act:
| Verdict | Meaning |
|---|---|
READY |
Epoch is settled, no more blocks expected — safe to pay out |
PARTIAL |
Payable now, but more coinbase is still coming this epoch |
WAIT |
Blocks found, none confirmed deeply enough yet |
READY can appear before the epoch ends — if the VRF scan shows no
remaining won slots, there is nothing left to wait for.
python3 send_payout.py --epoch 3Reads e3_payouts.csv, unlocks the wallet, sends one transaction per line, then
watches until each is included. Anything unconfirmed within
TX_CHECK_TIMER_SECONDS is written to failed_payouts_3.csv so you can retry.
Before sending anything it runs pre-flight checks and aborts on any of them:
| Check | Why |
|---|---|
| Duplicate addresses in the CSV | The file was appended to across runs; sending it pays people twice |
| Total against the live wallet balance | A short balance stops the run partway and leaves the epoch half paid |
sended_txs_e<N>.csv already exists |
This epoch was already paid — see Ledger and reconciliation |
python3 send_payout.py --epoch 3 --dry-run # run the checks, send nothing
python3 send_payout.py --epoch 3 --resume # finish an interrupted round
python3 send_payout.py --epoch 3 --yes # skip the confirmation prompt--resume skips every address already present in sended_txs_e<N>.csv, so an
interrupted run continues rather than starting over. The wallet is relocked in
a finally block, so it stays locked even if a send raises.
python3 withdraw.py --amount 252.5
python3 withdraw.py --amount 252.5 --to B62qOther... # override destinationThe amount is deliberately manual: the wallet balance does not account for payouts still sitting in the mempool, so an automatic "everything minus a buffer" can easily send money you have already promised to delegators.
Payouts do not always land on exactly the computed amount. A run can be interrupted, a transaction can fail, an epoch can be recalculated after more blocks matured, or a bad payout file can send people several times what they were owed.
Rather than chasing each case by hand, the difference is carried into the next
epoch. payout_ledger.json holds one balance per address:
| Balance | Meaning |
|---|---|
| negative | The address received too much; its next payout is reduced by that amount |
| positive | The address was underpaid; the shortfall is added to its next payout |
If the debt exceeds the new payout, the address receives nothing and the remainder carries forward again. No manual intervention — it clears itself over one or two epochs.
After a payout round, regenerate the payout file so it reflects the final block count, then compare it against what was actually submitted:
python3 calc_rewards.py --epoch 0 # refresh e0_payouts.csv
python3 reconcile.py --epoch 0 # show sent vs owed per address
python3 reconcile.py --epoch 0 --commit # store the deltas in payout_ledger.jsonaddress sent owed delta
B62qDelegatorOne... 715.1038 357.5519 +357.5519
B62qDelegatorTwo... 661.8925 330.9463 +330.9463
sent 3,969.7965 | owed 3,012.6696
overpaid 957.2650 | underpaid 0.1381
Regenerating first matters: reconciling against a stale payout file carries the wrong numbers forward.
Two things keep the correction from being applied twice. calc_rewards.py does
not net carried balances off an epoch that was already paid — that file is the
baseline reconcile.py measures against, so it stays gross. And reconcile.py
refuses to commit an epoch that is already in the ledger history, unless you
pass --force.
The next calc_rewards.py run nets the balances off automatically before it
writes the CSV:
Carried balances applied to 25 address(es)
address this epoch carry to pay
B62qDelegatorOne... 412.3311 -357.5519 54.7792
The file is plain JSON, so a balance can be inspected, adjusted or dropped by
hand if you settle with someone off-chain. It is in .gitignore.
A reward does not have to go to the address that holds the stake. Two things this solves:
- you stake from several of your own addresses and want everything landing on one, instead of collecting from all of them afterwards
- an account is shared with someone and the reward has to be split
PAYOUT_REDIRECTS:
# everything to a single address
B62qMyStakingOne...: B62qMyMainWallet...
B62qMyStakingTwo...: B62qMyMainWallet...
# split between two people
B62qSharedAccount...:
B62qPartner...: 50
B62qMyMainWallet...: 50The share of the pool is still calculated from the staking address — only the destination of the transfer changes. Several staking addresses may resolve to the same destination, in which case their payouts are merged into a single transaction.
Shares are normalised, so 50/50, 0.5/0.5 and 1/1 all mean the same thing.
The last destination absorbs the rounding remainder, so the parts always add
back up to the original amount exactly.
MINIMUM_PAYOUT is applied to the final merged transfer, not per delegator
— a share too small to pay on its own still counts once combined with others
going to the same place.
calc_rewards.py prints what it redirected:
Payout redirects active: 3
B62qMyStakingOne...
-> B62qMyMainWallet... 100%
B62qSharedAccount...
-> B62qPartner... 50%
-> B62qMyMainWallet... 50%
Redirected payouts
B62qMyMainWallet... <- 128.44 MINA from 3 address(es)
B62qMyStakingOne...
B62qMyStakingTwo...
B62qSharedAccount... (50%)
Mina evaluates a VRF per (delegator, slot) pair at the start of each epoch, so
the winning slots for the whole epoch are already determined. vrf/probe.py
asks the daemon to replay that evaluation:
python3 vrf/probe.py --epoch 0 # whole epoch
python3 vrf/probe.py --epoch 0 --from-now # only slots still ahead
python3 vrf/probe.py 619 # one specific slotOutput:
=== 10 winning slot(s) in epoch 0, slots 0..7139 ===
slot 619 (epoch slot 619, past) idx 81 102,106.96 MINA B62q...
slot 1778 (epoch slot 1778, ahead) idx 60 69,705.75 MINA B62q...
...
Results are cached in vrf/won_slots_epoch<N>.json and reused by
calc_rewards.py --vrf, which turns the vague "another block is scheduled" into
an exact "the epoch fully settles on 10 Sep at 03:05 UTC".
Cost. Every (slot, delegator) pair is one elliptic-curve operation on the
producer node. A full epoch with 58 delegators is ~414,000 evaluations. Mina's
daemon is single-threaded (OCaml Async), so throughput tops out near 650
evaluations per second — about 10 minutes per epoch, whatever --workers says.
Extra workers only hide network latency.
Requirements. The block producer key must be unlockable in the daemon's wallet. The script unlocks it, scans, and locks it back — on success, on error, on Ctrl+C, and on SIGTERM. A follower node cannot do this: it holds no producer key and returns nothing.
archive/ contains a Docker Compose stack: Postgres, mina-archive, a
non-producing daemon that feeds it, and a replacement block guardian.
cd archive
docker compose up -dThe Foundation's own missing-blocks-guardian script hardcodes a single S3
bucket. When that bucket was deleted, the script retried the same missing block
forever, filling logs at hundreds of lines per second and never making progress.
archive/scripts/blocks-guardian.sh replaces it:
- tries several precomputed-block mirrors in order (GCS first, S3 second)
- validates the JSON before handing it to
mina-archive-blocks - blacklists blocks that are unavailable everywhere, instead of looping
- asks the daemon which blocks are actually missing, rather than guessing
Add more mirrors by appending to the SOURCES array in that script.
- Staking ledger. Fetched for the epoch's ledger hash from
gs://mina-staking-ledgersand cached underarchive/cache/ledgers/(~280 MB each, downloaded once). - Blocks. Queried from the archive Postgres: blocks your validator created
in that epoch, excluding orphans, at or beyond
CONFIRMATIONS_NUMdepth. - Reward pool.
coinbase + transaction fees - snark fees, summed over those blocks. The coinbase is read from the database, never hardcoded — Mesa halved it from 720 to 360. - Split. Each delegator gets
pool * (their_stake / total_stake) * (1 - fee). Foundation and O(1) Labs stake can carry different fees (VALIDATOR_FEE_FOUNDATION,VALIDATOR_FEE_O1LABS); those address lists live infoundation_addresses.txtandO(1)Labs.txt. - Filter. Anything below
MINIMUM_PAYOUTis dropped — not worth the fee. - Redirect. Destinations are rewritten per
PAYOUT_REDIRECTSand merged.
Whatever is left in the wallet after paying everyone is the validator's commission.
A confirmation here means a block built on top of yours - that is, your block is an ancestor of the current best tip. That is deliberately not the same as "the tip is N heights above you".
This distinction is the whole game. Mina lets more than one producer win the
same slot, and the loser's block simply never gains descendants. Meanwhile the
archive keeps every block marked pending until it is K=290 deep, so for about
17 hours a dead branch is indistinguishable from a live one by status alone.
Counting tip_height - block_height would report a block on a losing branch as
having hundreds of confirmations while nothing was ever built on it.
So confirmations are resolved by walking parent links back from the daemon's
best tip. A block off that chain gets zero confirmations immediately, and is
excluded from payouts long before the archive gets around to labelling it
orphaned.
That is what makes a small CONFIRMATIONS_NUM safe. The remaining risk is a
genuine deep reorg, and there is data on how deep those go. Measured on a full
archive across the entire Berkeley era, 211,202 reorg events:
| Reorg depth | Occurrences |
|---|---|
| 1 | 210,827 (99.82%) |
| 2 | 339 |
| 3 | 26 |
| 4 | 7 |
| 5 | 1 |
| 6 | 1 |
The deepest genuine reorg in two years was 6 blocks. (A 41-deep outlier also shows up, but that is the Mesa hard fork truncating the Berkeley tail, not a consensus reorg.)
CONFIRMATIONS_NUM: 20 therefore carries more than a 3x margin over the worst
case ever observed, while cutting the wait from ~17 hours to under an hour. Set
it to 290 if you want strict protocol finality; the Mina Foundation's own
guidance for exchanges is 15.
Because several producers can win the same slot, a large share of all blocks produced never make it into the chain. From the same archive:
| Era | Canonical | Orphaned | Orphan rate |
|---|---|---|---|
| Berkeley, mid-2024 | 20,000 | 37,793 | 65% |
| Berkeley, 2025 | 20,000 | 12,318 | 38% |
| Berkeley, 2026 | 19,565 | 10,962 | 36% |
| Mesa | 1,103 | 682 | 38% |
For comparison, Bitcoin orphans about 0.5% of blocks and pre-Merge Ethereum uncled 5-10%. The rate improved a lot over Berkeley and Mesa's 90-second slots did not make it worse, despite halving the propagation window.
Nothing is burned when a Mina block is orphaned - it is lost opportunity, not
lost work. But it does mean a won VRF slot is not the same as a paid block,
which is why calc_rewards.py --vrf shows an expectation adjusted by your
historical orphan rate alongside the raw VRF total.
Mesa activated on 3 September 2026 at 18:00 UTC and changed several things this script depends on:
| Berkeley | Mesa | |
|---|---|---|
| Slot duration | 180s | 90s |
| Coinbase per block | 720 MINA | 360 MINA |
| Slots per epoch | 7140 | 7140 (unchanged) |
| Epoch length | ~14.9 days | ~7.44 days |
global_slot_since_hard_fork |
continuous | reset to 0 |
| Epoch numbering | continuous | reset to 0 |
The slot reset makes slot_hf / 7140 ambiguous: Berkeley epoch 5 and Mesa
epoch 5 produce the same value. Eras are told apart by protocol_version_id in
the blocks table (1 = pre-Berkeley, 2 = Berkeley, 3 = Mesa), detected
automatically. To recalculate a historical epoch from an older era, set
PROTOCOL_VERSION_ID in config.yml.
global_slot_since_genesis did not reset, which is why delegator vesting
checks still work across the fork.
Archive schema changes were limited to zkApp tables, so the queries here were unaffected.
echo "source /path/to/mina-payout-script/scripts/aliases.sh" >> ~/.zshrc
export MINA_SSH_HOST=my-node # lets mina_tunnel run bare| Alias | Does |
|---|---|
mina_calc [epoch] [flags] |
Calculate rewards |
mina_pay --epoch N |
Send payouts |
mina_withdraw --amount N |
Withdraw commission |
mina_vrf --epoch N |
Predict won slots |
mina_tunnel <host> --bg |
Open the SSH tunnel |
mina_tunnel --stop |
Close it |
mina_cd |
cd into the project with the venv active |
ModuleNotFoundError right after moving the project
A virtualenv stores absolute paths, so it breaks when the directory moves.
Recreate it: rm -rf venv && ./scripts/install.sh
No canonical blocks found for epoch N in the current protocol era
Either the epoch genuinely has no blocks, or you are asking for an epoch from an
older protocol era. Set PROTOCOL_VERSION_ID for the era you mean.
could not connect to server on the Postgres URL
The tunnel is down. ./scripts/tunnel.sh <host> --bg
Couldn't find an unlocked key for specified sender from vrf/probe.py
evaluateVrf needs the producer key unlocked in the daemon's wallet. Check it
is there at all: { trackedAccounts { publicKey locked } }. A follower node
never has it.
VRF scan says nextBlockProduction: null
You are talking to a follower, not the producer. Point GRAPHQL_PORT at the
node running the block producer key.
Guardian logs the same block forever
The old Foundation script. Switch to archive/scripts/blocks-guardian.sh as
wired up in archive/docker-compose.yml.
config.ymlholds your addresses and DB credentials — gitignored, keep it so.- Wallet passwords are prompted for, never stored or logged.
send_payout.pyandwithdraw.pylock the wallet again when they finish.vrf/probe.pyunlocks the block producer key. It locks it back throughfinally,atexit, and signal handlers, and prints a manual lock command if all retries fail. Do not expose the daemon's GraphQL port publicly.- Payout CSVs contain delegator addresses and amounts — gitignored as
*.csv.
See LICENSE.