Reproduced Exploit

KittenSwap — epoch rewards use the next epoch's supply, so the voter is paid 100 of 1000

The bribe loads the supply used for a finished epoch with getPriorSupplyIndex(_nextEpochStart + DURATION). That timestamp is the first second of the next epoch. A checkpoint written on that boundary is included.

May 2025•Other•logic•3 min read

Chain

Other

Category

logic

Date

May 2025

Source

AuditVault

EVM Playground

Source-level debugger — step opcodes and Solidity in sync

evm-hack-analyzer

The attack is replayed in an in-browser EVM preloaded with the exact dumped fork state. The execution tree shows every call; step by Solidity line or by opcode across all depths — source, Stack, Memory, Storage, Balances (native / ERC-20 / NFT), Transient storage and Return value stay in sync. Click a tree node, opcode, or source line to jump. No backend, no live RPC.

Loading fork state…

Source & credit. Reproduction of a public audit finding curated by AuditVault — the original finding: 58012-h-01-incorrect-next-epoch-supply-usage-affects-reward-calcul. Standalone Foundry PoC and full write-up: 58012-h-01-incorrect-next-epoch-supply-usage-affects-reward-calcul_exp in the evm-hack-registry mirror.


Vulnerability classes: vuln/logic/reward-calculation

Reproduction: a self-contained Foundry PoC that compiles and runs offline with only forge-std — no fork and no RPC. Trace: output.txt. PoC: test/58012-h-01-incorrect-next-epoch-supply-usage-affects-reward-calcul_exp.sol.


Key info#

ImpactHIGH — the voter is paid 100 of a fair 1,000; 900 KITTEN stays in the bribe
ProtocolKittenSwap ExternalBribe
Vulnerable codegetPriorSupplyIndex(_nextEpochStart + DURATION)
Bug classThe index timestamp is the next epoch, not the one being paid
FindingPashov Audit Group — KittenSwap, 7 May 2025 · H-01 · id 58012
ReportKittenSwap-security-review_2025-05-07
SourceAuditVault finding
StatusThe forked Velodrome line, reproduced with two supply checkpoints.
Compilersolc 0.8.27, cancun, via IR

TL;DR#

The bribe loads the supply used for a finished epoch with getPriorSupplyIndex(_nextEpochStart + DURATION). That timestamp is the first second of the next epoch. A checkpoint written on that boundary is included.

Supply during the epoch was 100, and this voter held all of it, against a pot of 1,000 KITTEN. The boundary checkpoint says 1,000. The voter is paid 100 * 1000 / 1000 = 100. The fair pay, using DURATION - 1, is 1,000. 900 KITTEN stays in the bribe.

Finding 58152 quotes this same assignment. It is not reproduced separately.

The vulnerable code#

SOLIDITY
function _supplyAt(uint256 _nextEpochStart) internal view returns (uint256) {
    Prev memory _prev;
    // @> VULN: _nextEpochStart + DURATION is the next epoch, not the one being paid
    _prev._prevSupply = supplyCheckpoints[getPriorSupplyIndex(_nextEpochStart + DURATION)].supply;
    return _prev._prevSupply;
}

The report's fix is _nextEpochStart + DURATION - 1.

Root cause#

getPriorSupplyIndex returns the latest checkpoint whose timestamp is less than or equal to the argument. Passing the exact start of the next epoch makes a checkpoint written at that instant eligible. That checkpoint already includes deposits from the new epoch. The reward for the epoch that just ended is diluted by supply that was not present while it was earned.

Preconditions#

  • Checkpoint 0 at the epoch start, supply 100.
  • Checkpoint 1 exactly one duration later, supply 1,000.
  • The voter's balance for the epoch is 100. The pot is 1,000 KITTEN.

Attack walkthrough#

  1. Write the two supply checkpoints.
  2. Mint 1,000 KITTEN to the bribe.
  3. getPriorSupplyIndex(epochStart + duration) returns index 1.
  4. getReward pays 100 * 1000 / 1000 = 100.
  5. The bribe still holds 900. The fair amount is 1,000.

Control: the index argument is epochStart + duration - 1. That stops on checkpoint 0. The voter receives 1,000 and the bribe is empty.

Diagrams#

flowchart TD A["Epoch supply checkpoint is 100"] --> B["Next epoch checkpoint is 1000"] B --> C["Index looks up epoch start plus duration"] C --> D["The lookup returns supply 1000"] D --> E["Voter is paid 100 of a 1000 pot"] E --> F["900 KITTEN stays in the bribe"]

Impact#

The voter who held the whole epoch supply is underpaid by 900 KITTEN. The same index is used for every claim that crosses an epoch boundary where supply changed.

Remediation#

Pass _nextEpochStart + DURATION - 1 to getPriorSupplyIndex.

Sources#

Taxonomy#

  • vuln/logic/reward-calculation · vuln/logic/incorrect-calculation

Sources & further analysis#

Reproductions & code

Alerts & third-party analyses

  • Web3Sec X hacked database: search.
  • Rekt leaderboard: search.
  • Solodit incident search: search.

These dashboards index community alerts tweets, post-mortems, and independent write-ups. Reach them through the protocol name above to cross-check this reproduction against other analyses.