Reproduced Exploit
Valkyrie — time-weighted rewards keep growing after the stream ends
The reward index stops at endTimestamp. The time-weighted ratio does not. _earnedTimeWeighted sets
Chain
Other
Category
logic
Date
Mar 2025
Source
AuditVault
EVM Playground
Source-level debugger — step opcodes and Solidity in sync
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.
Source & credit. Reproduction of a public audit finding curated by AuditVault — the original finding: 61564-rewards-distributed-with-timeweightedincentivelogic-can-cont. Standalone Foundry PoC and full write-up: 61564-rewards-distributed-with-timeweightedincentivelogic-can-cont_exp in the
evm-hack-registrymirror.
Vulnerability classes: vuln/logic/reward-calculation · vuln/logic/incorrect-state-transition · vuln/logic/fee-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/61564-rewards-distributed-with-timeweightedincentivelogic-can-cont_exp.sol.
Key info#
| Impact | HIGH — a claim five weeks after a one-week stream pays 400.000661375661269760 RWD more than the amount due when the stream ended |
| Protocol | Paladin Valkyrie TimeWeightedIncentiveLogic |
| Vulnerable code | timeDiff = now - checkpoint inside _earnedTimeWeighted |
| Bug class | The ramp ratio is not capped at endTimestamp |
| Finding | Cyfrin (Draiakoo) — Paladin Valkyrie, 12 March 2025 · id 61564 |
| Report | 2025-03-12-cyfrin-paladin-valkyrie-v2.0.md |
| Source | AuditVault finding |
| Status | The report's diff caps timeDiff at endTimestamp. Reproduced here as a local synthetic. |
| Compiler | solc 0.8.27, cancun, via IR |
TL;DR#
The reward index stops at endTimestamp. The time-weighted ratio does not. _earnedTimeWeighted sets
timeDiff = now - checkpointTimestamp
and, once the position would have finished ramping, weights the post-ramp part of that timeDiff at a full unit. A longer wait makes timeDiff larger, so the ratio moves toward 1 even though no new rewards are streaming.
For the finding's 1000 RWD over 1 week, with a checkpoint duration of 0:
- Claiming as the week ends pays 499.999173280423147200 RWD (the report's 499).
- Claiming 5 weeks after the start pays 899.999834656084416960 RWD (the report's 899).
- The extra is 400.000661375661269760 RWD.
nowTs replaces block.timestamp. advance(5 weeks) is the report's skip(5 weeks).
The vulnerable code#
uint256 lastUpdateTs = checkpointTimestamp[account];
// @> VULN: timeDiff is not capped at endTimestamp, so waiting past the end raises the payout
uint256 timeDiff = nowTs - lastUpdateTs;
uint256 newDuration = duration + timeDiff;
if (newDuration >= maxDuration) newDuration = maxDuration;
uint256 remainingIncreaseDuration = maxDuration - duration;
uint256 left = ((((maxDuration - duration - 1) * UNIT) / 2) / maxDuration);
uint256 ratio = ((left * remainingIncreaseDuration) + (UNIT * (timeDiff - remainingIncreaseDuration))) / timeDiff;
earnedAmount = (maxEarned * ratio) / UNIT;
The fixed function sets timeDiff to min(now, endTimestamp) - checkpoint. A claim five weeks later then equals the end-of-window claim, and a second claim pays 0.
Root cause#
maxEarned is capped because _newRewardPerToken stops at endTimestamp. The ratio that scales maxEarned still treats every second after the checkpoint as part of the ramp, including seconds after the stream ended. Those extra seconds are weighted at UNIT in the numerator and only dilute the half-weight ramp in the denominator, so the ratio rises toward 1.
Preconditions#
- The staker has all 100 liquidity. The checkpoint duration is 0 and the checkpoint timestamp is the distribution start.
- 1000 RWD is streamed for 1 week, and the required ramp duration is that same week.
- The staker does not claim until 5 weeks after the start. The reward index has been frozen since the end of week one.
Attack walkthrough#
- Stake 100 liquidity. Checkpoint timestamp is
nowTs, duration is 0. - Deposit 1000 RWD for 1 week.
- Advance 5 weeks.
claimcomputestimeDiff = 5 weeks. The payout is 899.999834656084416960 RWD.- The amount due at the end of week one is 499.999173280423147200 RWD. That part is returned to the depositor.
- The staker keeps the extra 400.000661375661269760 RWD.
Control: the same stake, deposit, and five-week wait, with timeDiff capped at endTimestamp. The claim equals 499.999173280423147200 RWD, so the extra is 0. A further claim one week later pays 0.
Diagrams#
Impact#
The late claim pulls 400.000661375661269760 RWD that was not owed at the end of the distribution. That comes out of the tokens the depositor funded for the one-week stream. Waiting longer moves the payout closer to the full maxEarned.
Remediation#
Cap timeDiff at endTimestamp before it enters the ratio, which is the diff in the finding. A claim after the end then matches a claim at the end, and a second claim pays 0.
Sources#
Taxonomy#
severity/highvuln/logic/reward-calculation·vuln/logic/timestamp-dependence·vuln/loss-of-funds/reward-theft
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 61564-rewards-distributed-with-timeweightedincentivelogic-can-cont_exp (evm-hack-registry mirror).
- AuditVault finding: 61564-rewards-distributed-with-timeweightedincentivelogic-can-cont.
Alerts & third-party analyses
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.