Reproduced Exploit
Valkyrie — a long first distribution blocks the next reward notify
depositRewards is permissionless. The first caller can pass a duration of type(uint32).max - 1 - now. That stores endTimestamp = type(uint32).max - 1.
Chain
Other
Category
arithmetic
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: 61557-reward-distribution-can-be-blocked-by-an-initial-distributio. Standalone Foundry PoC and full write-up: 61557-reward-distribution-can-be-blocked-by-an-initial-distributio_exp in the
evm-hack-registrymirror.
Vulnerability classes: vuln/arithmetic/overflow · vuln/dos/frozen-funds · 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/61557-reward-distribution-can-be-blocked-by-an-initial-distributio_exp.sol.
Key info#
| Impact | HIGH — the second notify reverts, so the staker is paid 0 of 100 RWD and that 100 RWD never enters the pool |
| Protocol | Paladin Valkyrie incentive logic |
| Vulnerable code | Extension path casts now + newDuration to uint32 |
| Bug class | A first duration near uint32 max makes every later depositRewards revert |
| Finding | Cyfrin (Draiakoo) — Paladin Valkyrie, 12 March 2025 · id 61557 |
| Report | 2025-03-12-cyfrin-paladin-valkyrie-v2.0.md |
| Source | AuditVault finding |
| Status | Fixed in Valkyrie commit c3298fa (maximum distribution duration). Reproduced here as a local synthetic. |
| Compiler | solc 0.8.27, cancun, via IR |
TL;DR#
depositRewards is permissionless. The first caller can pass a duration of type(uint32).max - 1 - now. That stores endTimestamp = type(uint32).max - 1.
While that distribution is still active, the next notify does:
newDuration = (endTimestamp - now) + duration
endTimestamp = uint32(now + newDuration)
now + newDuration is 4295572094, which is above type(uint32).max. The cast reverts. A notifier who tries to stream 100 RWD for one week is rolled back. The sole staker's claim returns 0.
nowTs replaces block.timestamp. The three-day gap is advance(3 days), the same gap as the report's skip(3 days).
The vulnerable code#
Pre-fix extension branch, reduced to one pool and one reward token:
uint256 remainingDuration = uint256(endTimestampCache) - nowTs;
uint256 newDuration = remainingDuration + duration;
uint256 leftoverRewards = uint256(rewardData.ratePerSec) * remainingDuration;
uint256 newRate = (amount + leftoverRewards) / newDuration;
require(newRate >= rewardData.ratePerSec, "rate");
rewardData.ratePerSec = uint96(newRate);
uint256 endRaw = nowTs + newDuration;
// @> VULN: the extended end is cast to uint32 and reverts when the first duration filled the type
require(endRaw <= uint256(type(uint32).max), "uint32");
rewardData.endTimestamp = uint32(endRaw);
The fixed logic rejects any duration above 4 weeks, so the grief schedule cannot be created and the second notify's end still fits in uint32.
Root cause#
The stored end is a uint32, and an active distribution extends by adding the new duration to whatever time is left. Nothing stops the first duration from sitting one second below the top of that type. The next extension then cannot be stored.
Preconditions#
depositRewardsis callable by anyone, with a duration of at least one week and no maximum.- The first duration is
type(uint32).max - 1 - nowTsand the amount is 1 wei. The resulting rate is 0, but the end timestamp is stilltype(uint32).max - 1. - A second notify of 100 RWD for 1 week arrives 3 days later, while that end is still in the future.
Attack walkthrough#
- Stake 1 unit of liquidity. This caller is the only staker.
- Notify 1 wei with the grief duration.
endTimestampbecomes4294967294. - Advance 3 days.
- Mint 100 RWD to an honest notifier and call
depositRewardsfor 1 week. - The extension require reverts. The 100 RWD transfer is rolled back.
claimreturns 0. The notifier still holds 100 RWD.
Control: duration must be between 1 week and 4 weeks. The first notify streams 604800 wei for 1 week (rate 1). Three days later a second notify of 100 RWD for 1 week is accepted. After the blended window of 950400 seconds the staker is paid 99999999999999792000 wei, just under 100 RWD, instead of 0.
Diagrams#
Impact#
No later reward token can be scheduled on this pool until the grief end, which is decades away. The honest 100 RWD distribution does not start. The staker is paid 0 of 100 RWD.
Remediation#
Cap duration at a few weeks, and reject an extension whose new end does not fit in uint32. Valkyrie did this in commit c3298fa.
Sources#
Taxonomy#
severity/highvuln/arithmetic/overflow·vuln/dos/frozen-funds·vuln/logic/reward-calculation
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 61557-reward-distribution-can-be-blocked-by-an-initial-distributio_exp (evm-hack-registry mirror).
- AuditVault finding: 61557-reward-distribution-can-be-blocked-by-an-initial-distributio.
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.