This directory contains a reference computation for compensating miners
who lost confirmation weight in epoch 276 because the v0.2.13 upgrade
did not suppress confirmation PoC the way its release notes
described. Two unintended CPoCs ran inside epoch 276 after the upgrade
height, knocking 7 active participants to INACTIVE (via
FailedConfirmationPoC) and reducing the confirmation_weight of
12 others.
The output CSV maps each affected miner to the exact ngonka they
should receive. All inputs are queried from an archive full node and
written to reference JSON files alongside the script, so the
computation is fully deterministic and reproducible without any
off-chain or third-party data.
- 19 miners lost
confirmation_weightin epoch 276 due to the v0.2.13 CPoC misfire. - Total amount: 32 429.966 GNK of compensation.
- 7 of the 19 were forced from
ACTIVEtoINACTIVEby the unintended CPoCs and received zero reward. - The remaining 12 stayed
ACTIVEbut had theirconfirmation_weightpartially reduced. - Both snapshot block heights are derived at runtime from on-chain state — nothing is hardcoded.
Proposal #54 (passed 2026-05-22) scheduled the v0.2.13 software upgrade at block height 4 267 300. Its release notes promised:
Disables confirmation PoC triggers for the rest of the upgrade epoch via a grace-epoch
UpgradeProtectionWindowof 10000 blocks. The new snapshot logic starts from the next epoch.
The 10 000-block window is not a release-notes invention. It is a hardcoded constant in the v0.2.13 upgrade handler in the chain repository:
inference-chain/app/upgrades/v0_2_13/upgrades.go— commit 1e24d1c7ce66df0096de295dace06f164ebad4f3// Block window after the upgrade in which confirmation PoC is skipped. // Same value as v0.2.10; covers the rest of the upgrade epoch on mainnet. GraceUpgradeProtectionWindow int64 = 10000
The same handler then calls AddPunishmentGraceEpoch(epoch, ..., 10000)
at upgrade execution, which in turn should override the chain-default
ConfirmationPocParams.UpgradeProtectionWindow = 500 for the rest of
the upgrade epoch via this branch in
x/inference/module/confirmation_poc.go:110:
if graceParams, ok := am.keeper.GetPunishmentGraceEpoch(ctx, epochContext.EpochIndex);
ok && graceParams.UpgradeProtectionWindow > 0 {
upgradeProtectionWindow = graceParams.UpgradeProtectionWindow
am.LogDebug("using grace UpgradeProtectionWindow", ...)
}So when the proposal text mentions a "10 000 block grace-epoch
window," it is referring to the value above — verifiable in the
upgrade-handler source, the commit, and the governance artifact at
proposals/governance-artifacts/update-v0.2.13/README.md:54-56.
Important note about the default 500: querying the live chain at
the upgrade block returns
confirmation_poc_params.upgrade_protection_window = 500. That is the
chain-default for normal upgrades. The 10 000 lives only in the
v0.2.13 upgrade handler and was meant to be written into the
PunishmentGraceEpoch keyed by the current epoch at the moment the
handler ran. Whether it was actually written, and whether the CPoC
trigger checked the override correctly, is the bug — but the promise
to do so is concretely traceable to the commit above.
This compensation proposal does not depend on the 10 000 number for its math. The 10 000 only motivates why it is reasonable to repair the participants that the misfire damaged: the chain promised the participants there would be no CPoC for the rest of the epoch, and the two CPoCs that did fire damaged participants the chain had committed not to test in this window.
In practice the protection did not take effect, and two additional
CPoCs ran inside epoch 276 after the upgrade. Each CPoC re-sampled
participants and re-computed confirmation_weight. Net result:
- 7 participants who were
ACTIVEath_beforewere forced toINACTIVEbyh_after, via theFailedConfirmationPoCpath inx/inference/calculations/status.go:82. They received zero reward in epoch 276. - 12 more participants stayed
ACTIVEbut had theirconfirmation_weightreduced. - Total
confirmation_weightin the epoch fell by 131 214 cw (845 946 → 714 732), a 15.5% drop.
Because reward distribution in SettleAccounts is scaled by per-
participant effective weight, dropping participants and reducing their
cw translated directly into lost rewards.
The script derives both snapshot block heights from on-chain state at runtime:
| Value | Source |
|---|---|
upgrade_height |
gov/v1/proposals/54.messages[].plan.height → 4 267 300 |
h_before |
upgrade_height - 1 → 4 267 299 |
next_epoch_start |
epoch_group_data/277.effective_block_height → 4 275 062 |
h_after |
next_epoch_start - 1 → 4 275 061 |
h_after is the last block of epoch 276 itself, taken directly from
the chain's own record of the epoch boundary. By this height all CPoCs
that fired inside epoch 276 — intended or otherwise — have settled, so
the snapshot captures the full extent of the damage caused inside the
epoch, without bleeding into epoch 277.
We deliberately do not use the chain-default
upgrade_protection_window = 500 to construct h_after (which would
yield 4 267 800). That window falls between the two unintended CPoCs
and would underestimate the damage. The epoch boundary is the
authoritative end-of-damage point.
A participant is eligible only if status_before == ACTIVE. For
eligible participants we classify each transition based on the chain's
own calculations/status.go rules:
| Transition | Detection | lost_cw |
Reason tag |
|---|---|---|---|
ACTIVE → ACTIVE |
cw dropped | cw_before - cw_after |
partial_cpoc_damage |
ACTIVE → ACTIVE |
cw unchanged | 0 |
no_loss |
ACTIVE → INACTIVE, missed/inactiveLLR unchanged |
only path left is FailedConfirmationPoC (see status.go:82) |
cw_before |
failed_cpoc_full_drop |
ACTIVE → INACTIVE, missed or inactiveLLR grew |
participant turned off — Downtime SPRT (status.go:75) |
0 |
skipped_downtime |
ACTIVE → INVALID |
separate slashing path | 0 |
skipped_invalid |
status_before != ACTIVE |
excluded outright | 0 |
ineligible_not_active_before |
Rationale for the INACTIVE split:
x/inference/calculations/status.go has exactly two paths from
ACTIVE into INACTIVE — Downtime (line 75) and
FailedConfirmationPoC (line 82). Downtime is driven by SPRT on
missed_requests vs inference_count, which leaves a footprint in
current_epoch_stats.missed_requests and inactiveLLR. If neither of
those moved between snapshots, the only remaining path into INACTIVE
is FailedConfirmationPoC, i.e. CPoC damage. This is how the script
distinguishes voluntary node downtime from CPoC misfire damage on
purely on-chain signals.
The chain pays rewards in SettleAccounts via
bitcoin_rewards.go:723-736:
// reward[addr] = (effective_weight[addr] * fixed_epoch_reward) / totalPoCWeightBeforeDowntimewhere totalPoCWeightBeforeDowntime = totalFullWeight = sum(vw.Weight).
Crucially, this denominator is the epoch-wide constant total_weight
(the sum of base PoC weights), not the post-CPoC
confirmation_weight sum. The chain explicitly avoids redistributing
unclaimed shares: any CPoC-reduced or invalidated participant's share
becomes a remainder that goes to the governance module, not to the
remaining participants.
Therefore the per-unit price the chain actually paid is:
coefficient = total_rewarded / total_weight
= 193,820,331,174,280 ngonka / 798,029
= 242,873.794 ngonka per weight unit
We approximate the lost share by treating lost_cw as lost effective-
weight units and multiply by the coefficient:
compensation = lost_cw * total_rewarded / total_weight (integer ngonka)
All arithmetic is integer ngonka via fixed-point:
per_cw_q = total_rewarded * 1e18 // total_weight
compensation = lost_cw * per_cw_q // 1e18
No floating-point rounding.
| Value | |
|---|---|
| Members of epoch 276 | 54 |
ACTIVE before upgrade (status_before) |
46 |
Dropped to INACTIVE via failed_cpoc_full_drop |
7 |
Stayed ACTIVE but lost cw (partial_cpoc_damage) |
12 |
no_loss (eligible, untouched) |
27 |
ineligible_not_active_before |
8 |
| Total eligible for compensation | 19 |
Total confirmation_weight lost |
131 214 cw |
Total lost_cw after misfire-damage classification |
133 526 |
total_weight (denominator from epoch_group_data.total_weight) |
798 029 |
| Total reward pool in epoch 276 | 193 820.331 GNK |
coefficient (ngonka per weight unit) |
242 873.794 |
| Total compensation | 32 429.966 GNK |
pip install -r requirements.txt
python3 payout_276.py <ARCHIVE_NODE_IP>The script:
- Fetches
gov/v1/proposals/54and readsmessages[].plan.heightto deriveh_before. - Fetches
epoch_group_data/277(current state) and readseffective_block_heightto deriveh_after. - Fetches four snapshots at those two heights
(
epoch_group_data/276,participant?limit=10000) and the per-member rewards (epoch_performance_summary/276/<addr>for each member). - Writes all eight raw responses to JSON files alongside the script.
- Classifies each participant, applies the compensation formula, and
writes
payout_276.csv.
Custom output path:
python3 payout_276.py <ARCHIVE_NODE_IP> --out my_payout.csvThe detection queries historical state at past block heights:
epoch_group_data/276andparticipantath_before = 4 267 299epoch_group_data/276andparticipantath_after = 4 275 061
A node that has pruned state for those heights cannot answer these
queries. Any archive full node retaining state since at least
h_before will work.
| File | Content |
|---|---|
proposal_54.json |
Raw gov/v1/proposals/54 (source of h_before). |
epoch277_group_data_current.json |
Raw epoch_group_data/277 (source of h_after). |
epoch276_group_data_before.json |
Raw epoch_group_data/276 at h_before. |
epoch276_group_data_after.json |
Raw epoch_group_data/276 at h_after. |
epoch276_participant_before.json |
Raw participant?limit=10000 at h_before. |
epoch276_participant_after.json |
Raw participant?limit=10000 at h_after. |
epoch276_performance.json |
Raw epoch_performance_summary/276/<addr> for each member, keyed by address. |
payout_276.csv |
Final compensation table (generated). |
Reviewers can verify all numbers without running an archive node by inspecting these JSON files directly.
payout_276.csv:
address, status_before, status_after, cw_before, cw_after, lost_cw,
reason, rewarded_coins_received, compensation_ngonka, compensation_gnkstatus_before,status_after—ACTIVE/INACTIVE/INVALIDread fromparticipant.{index}.statusat the two heights.cw_before,cw_after—confirmation_weightread fromepoch_group_data/276.validation_weights[].confirmation_weightat the two heights.lost_cw— input to the compensation formula, per the classification table above.reason— one of the*_full_drop/*_damage/skipped_*/ineligible_*/no_losstags from the classifier.rewarded_coins_received— what the chain actually paid this participant in epoch 276 (zero for the 7 dropped ones).compensation_ngonka,compensation_gnk— the refund amount.
Rows are sorted by compensation_ngonka descending. Only rows with
compensation_ngonka > 0 are included.
- Reproduce against any archive node. The CSV should match byte-for-byte; inputs are pure on-chain state at chain-derived block heights, no hardcoded values.
- Open
proposal_54.jsonand confirmmessages[].plan.height == 4267300. - Open
epoch277_group_data_current.jsonand confirmepoch_group_data.effective_block_height == 4275062. The script derivesh_after = 4275061from this. - Pick any row with
reason == failed_cpoc_full_drop. Openepoch276_participant_before.jsonand confirmstatus == ACTIVE. Openepoch276_participant_after.jsonand confirmstatus == INACTIVEwithmissed_requestsandinactiveLLRunchanged frombefore— meaning the only path that could move the participant out of ACTIVE is theFailedConfirmationPoCbranch in calculations/status.go:82. - Pick any row with
reason == partial_cpoc_damage. Confirmcw_after < cw_beforein the twoepoch276_group_data_*.jsonfiles.lost_cwshould equalcw_before - cw_after. - Verify
compensation_ngonka == lost_cw * total_rewarded // total_weightwithtotal_rewardedsummed fromepoch276_performance.jsonandtotal_weightread fromepoch276_group_data_before.json.epoch_group_data.total_weight. - Sum the
compensation_ngonkacolumn and confirm it matches the script's reported total (32 429.966 GNK). - Negative control: pick any address that was
INACTIVEath_before(e.g.gonka1ujnc662v6g69jm6fgxnr79a2m7ehzeut059239— search for it inepoch276_participant_before.json). It must not appear inpayout_276.csvregardless of itsconfirmation_weightvalues.
payout_276.py— end-to-end script (archive node required).payout_276.csv— output.requirements.txt— single dependency (aiohttp).- Reference JSON files listed above.