Reproduced Exploit
Suzaku — stake cache pays rewards before P-Chain confirmation
initializeValidatorStakeUpdate writes the operator's next-epoch stake into nodeStakeCache as soon as the update is requested. The P-Chain weight update is still pending. Reward math reads that cache and does not check the pending flag.
Chain
Other
Category
logic
Date
Jul 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: 61236-immediate-stake-cache-updates-enable-reward-distribution-wit. Standalone Foundry PoC and full write-up: 61236-immediate-stake-cache-updates-enable-reward-distribution-wit_exp in the
evm-hack-registrymirror.
Vulnerability classes: vuln/logic/reward-calculation · vuln/logic/incorrect-state-transition
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/61236-immediate-stake-cache-updates-enable-reward-distribution-wit_exp.sol.
Key info#
| Impact | HIGH — the operator is paid 4 RWD extra. Unconfirmed stake of 50 ether pays 5 RWD; the confirmed stake of 10 ether pays 1 RWD |
| Protocol | Suzaku Core AvalancheL1Middleware |
| Vulnerable code | initializeValidatorStakeUpdate writes nodeStakeCache[currentEpoch + 1] before the P-Chain confirms |
| Bug class | Reward uses a stake cache that was updated before confirmation |
| Finding | Cyfrin (0kage) — Suzaku Core, 7 July 2025 · id 61236 |
| Report | 2025-07-07-cyfrin-suzaku-core-v2.0.md |
| Source | AuditVault finding |
| Status | Fixed in suzaku-core commit 5157351. Reproduced here as a local synthetic. |
| Compiler | solc 0.8.27, cancun, via IR |
TL;DR#
initializeValidatorStakeUpdate writes the operator's next-epoch stake into nodeStakeCache as soon as the update is requested. The P-Chain weight update is still pending. Reward math reads that cache and does not check the pending flag.
With a confirmed stake of 10 ether and a requested stake of 50 ether, reward = stake / 10:
- The cache for the next epoch becomes 50 ether while
nodePendingUpdateis still true. - The epoch counter moves to 2. The reward pays 5 RWD.
- The confirmed stake would have paid 1 RWD. The operator keeps the 4 RWD difference.
The real middleware derives the epoch from time. This replay is one block, so the same getCurrentEpoch() value is advanced between those steps instead of warping the timestamp.
The vulnerable code#
Pre-fix body of the cache write, reduced to one node and one asset class:
function initializeValidatorStakeUpdate(uint256 newStake) external {
uint48 currentEpoch = getCurrentEpoch();
nodePendingUpdate[validationID] = true;
// @> VULN: next-epoch stake is cached before the P-Chain confirms the weight update
nodeStakeCache[currentEpoch + 1][validationID] = newStake;
}
getOperatorUsedStakeCachedPerEpoch then adds nodeStakeCache[epoch][validationID] into the stake used for rewards. The fixed contract stores the new stake in pendingStake and writes nodeStakeCache only inside completeStakeUpdate.
Root cause#
The cache is treated as confirmed stake. It is written at initialization, not at confirmation. forceUpdateNodes skips validators with a pending weight update, but the reward path has no such skip, so the two paths disagree about which stake is real.
Preconditions#
- The node already has a confirmed cached stake. Here that is 10 ether.
- The operator calls
initializeValidatorStakeUpdatewith a larger stake. Here that is 50 ether. - The P-Chain has not confirmed the weight update.
- A reward is distributed for the epoch the cache was written into. Reward = stake / 10, so the inflated payout is 5 RWD and the fair payout is 1 RWD.
Attack walkthrough#
- Seed the node at 10 ether for epoch 1 and epoch 2. Mint 5 RWD into the rewards contract.
- Call
initializeValidatorStakeUpdate(50 ether). The cache for epoch 2 becomes 50 ether.nodePendingUpdatestays true. - Advance the epoch counter to 2. No timestamp warp.
distributeOperatorRewardreads the cache and transfers 5 RWD.- The fair amount for the confirmed stake is 1 RWD. The operator is sent the 4 RWD surplus.
Control: initializeValidatorStakeUpdate stores the 50 ether as pending and does not touch the epoch-2 cache. The same distribution pays 1 RWD. completeStakeUpdate writes 50 ether into the following epoch only, after confirmation.
Diagrams#
Impact#
The operator receives 4 RWD that the confirmed stake did not earn. Any reward distributed in the window between initializeValidatorStakeUpdate and P-Chain confirmation uses the unconfirmed stake. A rejected or delayed P-Chain update leaves L1 rewards ahead of the stake the P-Chain actually validated.
Remediation#
Write nodeStakeCache only after completeValidatorWeightUpdate succeeds. Until then, keep the new stake in a pending slot that reward math does not read. Suzaku did this in commit 5157351 by storing _pendingStake and moving the cache write into the completion path.
Sources#
- AuditVault finding 61236
- Cyfrin Suzaku Core report, 7 July 2025
- Vulnerable file: AvalancheL1Middleware.sol at fix commit
5157351(the cache write moved out of initialization) - Fix: suzaku-core@5157351
Taxonomy#
vuln/logic/reward-calculationvuln/logic/incorrect-state-transition
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 61236-immediate-stake-cache-updates-enable-reward-distribution-wit_exp (evm-hack-registry mirror).
- AuditVault finding: 61236-immediate-stake-cache-updates-enable-reward-distribution-wit.
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.