Binomial test p0 floor/ceiling mismatch — stricter downtime threshold silently never enforced #1081
Summary
A floor vs. ceiling inconsistency in decimalToPermille causes the dynamic p0 selection and the actual binomial test to use different p0 tables. Validators who should be punished under the stricter threshold pass the test using the more lenient table.
Root Cause
Files:
- x/inference/calculations/stats_table.go:705-711 — decimalToPermille uses IntPart() (floor)
- x/inference/calculations/stats.go:91-94 — MissedStatTest calls decimalToPermille (floor)
- x/inference/keeper/bitcoin_rewards.go:461 — getDynamicP0 calls decimalToPermilleCeil (ceiling)
- x/inference/keeper/bitcoin_rewards.go:471 — ceilToSupportedP0Permille maps to nearest supported table
The mismatch:
1. getDynamicP0 selects p0 using ceiling permille conversion → e.g., 0.1004 → 101 → maps to p0=0.200 table (stricter)
2. MissedStatTest executes the test using floor permille conversion → 0.1004 → 100 → uses p0=0.100 table (more lenient)
Result: The system "thinks" it selected the stricter p0=0.200 threshold, but the actual test runs against p0=0.100. Validators who miss 15% of requests pass the p0=0.100 test but would fail p0=0.200.
Exploit Scenario
- Network conditions cause
getDynamicP0to select a p0 value like 0.1004 - The system logs that p0=0.200 threshold is in effect (ceiling path)
- But
MissedStatTestuses p0=0.100 table (floor path) - A validator strategically misses ~15% of validations
- They pass the lenient test and collect full rewards
- Under the intended stricter test (p0=0.200), they would be punished
Impact
High — The entire dynamic downtime punishment system can be silently operating at a more lenient threshold than intended. Validators who should be penalized continue collecting full rewards.
Suggested Fix
Use the same conversion function in both paths. Either:
- Change MissedStatTest to use decimalToPermilleCeil (enforce the stricter table)
- Or change getDynamicP0 to use decimalToPermille (floor, consistent)
The first option is safer — when in doubt, enforce the stricter threshold.
Additional Finding: Dynamic Pricing IntPart Truncation
File: x/inference/keeper/dynamic_pricing.go:199-201
IntPart() truncates toward zero. When currentPrice is small (1-10), repeated multiplication by maxDecreasePerBlock (e.g., 0.98) produces decimals like 0.98, which truncates to 0. Price gets stuck at minPrice and can only recover at ~2% of minPrice per block.
Fix: Use Round(0) instead of IntPart().
Additional Finding: Training RerankIfSomeNodesLeft Overwrites State
File: x/inference/training/training_sync.go:588-593
SaveEpochState is called twice — first with full state, then with active-only filtered state. The second call overwrites the first, permanently deleting dropped node records. If a node reconnects, its activity record is gone.
Fix: Single save of merged state.
Payout address: gonka10zaal553duxp05nvfpqtsqrm2g0j6j34r8nan7
💬 Comments (2)
Thanks @unameisfine for the thorough walkthrough — you're right, and I've reverified against the code paths.
Confirmed on my side:
getDynamicP0always returnspermilleToP0Decimal(finalPermille)wherefinalPermille∈ {50, 100, 200, 300, 400, 500} afterceilToSupportedP0Permille. The returnedDecimal{Value: N, Exponent: -3}is exactlyN/1000, sodecimalToPermille(floor) produces the same permille as the ceiling-based selection — no mismatch reachesMissedStatTest.- The governance
BinomTestP0 > 0.500path returns the raw value,decimalToPermillereturns-1, andMissedStatTestfalls back to the exactBinomialPValuecomputation (stats.go:96-101). No table lookup, no rounding mismatch. - The claim-rewards path (
msg_server_claim_rewards.go:264) uses raw governanceBinomTestP0but does not go through any ceiling-based selection, so the described divergence scenario also can't manifest there.
The two rounding functions do diverge in isolation, but every production caller snaps to an exact permille before reaching the stat test. The exploit scenario as I described it is not reachable on current main.
Downgrading severity assessment to Low / code hygiene — unifying the rounding helpers would still be reasonable defensive cleanup, but this is not an active vulnerability. Closing to keep the queue clean. Apologies for the noise.
🔄 Auto-synced from Issue #1081 every hour.
Reviewed the code paths. I believe this mismatch is not exploitable in the current codebase — here's why:
getDynamicP0always returns an exact permillegetDynamicP0(bitcoin_rewards.go:558) returnspermilleToP0Decimal(finalPermille), wherefinalPermilleis always one of the supported table values (50, 100, 200, 300, 400, 500) — produced byceilToSupportedP0Permille.permilleToP0Decimal(100)→Decimal{Value: 100, Exponent: -3}→ exactly0.100.When
MissedStatTestreceives this value:0.100 * 1000 = 100.0→IntPart() = 100→ selects the correct table. Floor and ceiling are identical for exact permille values.The bypass case is also safe
The only path where a non-rounded p0 reaches
MissedStatTestis when governance setsBinomTestP0 > 0.500(bitcoin_rewards.go:476 — returns the raw governance value). In that case,decimalToPermillereturns-1(unsupported), andMissedStatTestcorrectly falls back to the exactBinomialPValuecomputation (stats.go:96-101) — no table lookup involved.The inconsistency is real, the exploit scenario is not
The two functions (
decimalToPermillefloor vsdecimalToPermilleCeilceil) do use different rounding, but becausegetDynamicP0always snaps to an exact supported permille before returning, the floor/ceiling difference never manifests in practice. The described scenario (p0=0.1004 using different tables) cannot occur —getDynamicP0would map 0.1004 to 0.200 before it ever reachesMissedStatTest.Unifying the rounding functions is still reasonable as defensive hardening, but the severity should be Low (code smell) rather than High (active exploit).