Skip to content

[BUG] Oracle weighted median uses wrong threshold - accepts 49.5% as majority #236

@Decussilva

Description

@Decussilva

Bug Description

Found a math error in x/oracle/types/ballot.go line 112. When total validator power is odd, the weighted median picks a price with less than 50% support.

What is broken:
The code does if pivot >= (totalPower / 2) to check if we hit majority. But integer division rounds down, so with 101 total power, threshold becomes 50 instead of 51.

Which module is affected:
Oracle module (x/oracle/types)

Why is this a bug:
Say total power is 101. Integer division gives us 101 / 2 = 50. So a validator with exactly 50 power (which is 49.5%) gets picked as the median. That's not a majority - you need more than 50% to have majority, not exactly 50%.

The comment on line 97 even says "equal or major to 50% of total power" but 50 out of 101 is 49.5%, not 50%.


Steps to Reproduce

  1. Set up validators with odd total power (like 101)
  2. Two validators submit oracle prices:
    • Validator A: 50 power, votes $1.00
    • Validator B: 51 power, votes $2.00
  3. Oracle tally runs in EndBlocker
  4. Median picks $1.00 even though that validator only has 49.5% of power

Code location:
https://github.com/KiiChain/kiichain/blob/cc9754a/x/oracle/types/ballot.go#L112

func (ex ExchangeRateBallot) WeightedMedianWithAssertion() sdkMath.LegacyDec {
    totalPower := ex.Power()
    if ex.Len() > 0 {
        pivot := int64(0)
        for _, vote := range ex {
            pivot += vote.Power
            if pivot >= (totalPower / 2) {  // ❌ Wrong for odd numbers
                return vote.ExchangeRate
            }
        }
    }
    return sdkMath.LegacyZeroDec()
}

Expected Behavior

Should need more than 50% to pick a median. With 101 total power, need at least 51 to be selected.


Actual Behavior

Right now with 101 total power:

threshold = 101 / 2 = 50

Validator A has 50 power, votes $1.00
- pivot = 50
- Check: 50 >= 50 → passes
- Returns $1.00 ✗

But 50 out of 101 is only 49.5%, not a majority.

Should be:
- pivot = 50
- Check: 50 > 50 → fails
- Keep going
- pivot = 101 (after B)
- Check: 101 > 50 → passes
- Returns $2.00 ✓

This happens every vote period when oracle does price tally.


Environment

  • Kiichain version / commit: v6.1.0 / cc9754a (verified latest)
  • Network: Affects all networks (local / testnet / mainnet)
  • File: x/oracle/types/ballot.go
  • Lines: 112
  • Function: WeightedMedianWithAssertion

Impact Assessment

Consensus failure: Maybe - if validators disagree on which median to pick

Oracle manipulation: Yes - someone with exactly 50% power can force their price

Security risk: High - oracle consensus broken with 50% instead of needing >50%

This runs every vote period in EndBlocker. When total power is odd, a validator with exactly half can manipulate the oracle price even though they don't have real majority.

Severity: HIGH


Suggested Fix

Just change >= to >:

// Before
if pivot >= (totalPower / 2) {
    return vote.ExchangeRate
}

// After
if pivot > (totalPower / 2) {
    return vote.ExchangeRate
}

Or do ceiling division:

threshold := (totalPower + 1) / 2
if pivot >= threshold {
    return vote.ExchangeRate
}

Validation

  • ✅ Code still vulnerable in latest commit cc9754a
  • ✅ Not by design (mathematical error in majority calculation)
  • ✅ Tested by code review and mathematical analysis

Reporter Declaration

By submitting this issue, I confirm that:

  • This report is NOT generated by AI
  • I personally tested and verified this issue through code review
  • I understand that false or low-effort reports are disqualified from rewards

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions