0x7e19f8edb1f1666322113f15d7674593950ac94bbc25d2aff96adabdcae0a6c30xe2910b29252f97bb6f3cc5e66bfa0551821c7461Ethereum0x66149ab384cc066fb9e6bc140f1378d1015045e9EthereumOn Ethereum mainnet block 20667434, an unprivileged adversary cluster exploited PythiaTokenStaking at 0xe2910b29252f97bb6f3cc5e66bfa0551821c7461 by moving one staking-share position across fresh attacker-controlled helper contracts and calling claimRewards() from each recipient. The observed seed transaction is 0x7e19f8edb1f1666322113f15d7674593950ac94bbc25d2aff96adabdcae0a6c3, submitted by EOA 0xd861e6f1760d014d6ee6428cf7f7d732563c74c0 to orchestrator 0x542533536e314180e1b9f00b2c046f6282eb3647.
The root cause is a reward-accounting bug in transferable staking shares. AbstractRewards defines adjustPointsForTransfer specifically to preserve historical reward accounting when shares move, but PythiaTokenStaking never invokes it on ERC20 transfers. As a result, once rewards are distributed, a fresh recipient with zero withdrawnRewards and zero pointsCorrection can re-claim historical rewards attached to the same shares. The attacker repeated that flow through fresh helpers, recycled liquid PYTHIA back into staking, and extracted 531346180634069534713 raw units of PYTHIA into attacker-controlled escrow positions.
PythiaTokenStaking is both the staking pool and the ERC20 share token. At the incident block, both and are the token at . Staking mints transferable shares 1:1 with deposited PYTHIA, and splits each claim into an escrowed portion sent through at and a liquid portion transferred directly to the caller.
depositTokenrewardTokenPYTHIA0x66149ab384cc066fb9e6bc140f1378d1015045e9claimRewards()PythiaEscrowRewards0x0ef1c026c6ed555432a39ae2b5d8c246e75ef75eReward accounting lives in AbstractRewards. It tracks cumulative entitlement through shareBasedPoints, pointsCorrection, and withdrawnRewards. The claimable amount for an account is:
return ((shareBasedPoints * getAccountBalance(_account)).toInt256()
+ pointsCorrection[_account]).toUint256() / SCALING_FACTOR
- withdrawnRewards[_account];
This accounting model is safe only if transfers preserve accumulated rewards. AbstractRewards includes an internal transfer-adjustment helper for that purpose:
function adjustPointsForTransfer(address _from, address _to, uint256 _shares) internal {
int256 magnitudeCorrection = (shareBasedPoints * _shares).toInt256();
pointsCorrection[_from] = pointsCorrection[_from] + magnitudeCorrection;
pointsCorrection[_to] = pointsCorrection[_to] - magnitudeCorrection;
}
The vulnerability class is an accounting flaw in transferable reward-bearing shares. PythiaTokenStaking correctly adjusts reward points on mint and burn by calling adjustPoints, but it does not override the ERC20 transfer path to call adjustPointsForTransfer. That omission breaks the conservation invariant for already-distributed rewards: historical entitlement remains claimable by the sender's old share balance and becomes claimable again by a fresh recipient that has not previously withdrawn rewards.
The critical invariant is: after rewards are distributed, transferring staking shares must preserve total claimable rewards across sender and recipient. The code-level breakpoint is the ordinary ERC20 transfer path inherited from OpenZeppelin ERC20, because PythiaTokenStaking never synchronizes pointsCorrection or withdrawnRewards when shares move. Once shareBasedPoints has been increased by prior reward distribution, the attacker only needs transferable shares plus fresh recipient addresses to duplicate the same claimable snapshot.
The relevant victim-side code is concise:
function mint(address account, uint256 amount) internal {
super._update(address(0), account, amount);
adjustPoints(account, -int256(amount));
}
function burn(address account, uint256 amount) internal {
super._update(account, address(0), amount);
adjustPoints(account, int256(amount));
}
Mint and burn are handled, but transfer is not. That is why the exploit is deterministic and repeatable.
The ACT opportunity exists in the Ethereum pre-state immediately before block 20667434. Any unprivileged actor can buy PYTHIA on the public market, stake it to mint shares, deploy fresh helper contracts, and call the public claimRewards() entrypoint. No privileged keys, private orderflow, or attacker-specific artifacts are required.
The attack sequence is:
PYTHIA and stake it into PythiaTokenStaking to mint transferable staking shares.claimRewards(). Because the helper has fresh reward state, it receives historical rewards tied to the transferred shares.The seed trace shows this exact loop. One representative segment is:
PYTHIA::transfer(0xA9aA754F3565cde831c9A208756A6BA5f05DBa16, 19044911692966321838)
emit RewardsClaimed(_user: 0xA9aA754F3565cde831c9A208756A6BA5f05DBa16,
_escrowedAmount: 19044911692966321837,
_nonEscrowedAmount: 19044911692966321838)
PYTHIA::transfer(0x542533536e314180E1B9f00b2c046f6282eb3647, 19044911692966321838)
PythiaTokenStaking::transfer(0x542533536e314180E1B9f00b2c046f6282eb3647, 607747754829692141873)
The same pattern repeats for the next helper:
PythiaTokenStaking::transfer(0xb6C6027cE1b78Ac876fA67e832B03997Ab2ea4E9, 646434386115794694702)
PythiaTokenStaking::claimRewards()
PythiaEscrowRewards::vestingLock(0xb6C6027cE1b78Ac876fA67e832B03997Ab2ea4E9, 20257229584208287027)
emit RewardsClaimed(_user: 0xb6C6027cE1b78Ac876fA67e832B03997Ab2ea4E9,
_escrowedAmount: 20257229584208287027,
_nonEscrowedAmount: 20257229584208287027)
PythiaTokenStaking::transfer(0x542533536e314180E1B9f00b2c046f6282eb3647, 646434386115794694702)
Those traces match the mathematical bug. A fresh helper starts with withdrawnRewards == 0 and no transfer correction, so getRedeemablePayouts(helper) reflects prior shareBasedPoints as if the helper had always held the shares. Returning the shares does not erase the rewards already claimed, so the orchestrator can send the same position to another fresh helper and repeat.
The identified adversary cluster consists of:
0xd861e6f1760d014d6ee6428cf7f7d732563c74c0, which submitted the seed transaction.0x542533536e314180e1b9f00b2c046f6282eb3647, which held the share position, received liquid rewards back, and restaked them.0x387a781fec912d03643463a111bde301a3ed379e and 0x84543c8541fe45f7d75ba922bae6178cc26181c8, which each received transferred shares, called claimRewards(), and returned assets to the orchestrator.The on-chain lifecycle is:
This is an ACT flow rather than a privileged incident. The exploitability depends only on public contracts, public chain state, and the attacker's ability to deploy fresh recipients and submit a transaction.
The measured loss in the observed transaction is 531346180634069534713 raw PYTHIA units from the staking contract balance into attacker-controlled escrow positions. The balance-diff artifact shows:
{
"token": "0x66149ab384cc066fb9e6bc140f1378d1015045e9",
"holder": "0xe2910b29252f97bb6f3cc5e66bfa0551821c7461",
"delta": "-531346180634069534713"
}
The same artifact shows the escrow contract balance increasing by the same amount:
{
"token": "0x66149ab384cc066fb9e6bc140f1378d1015045e9",
"holder": "0x0ef1c026c6ed555432a39ae2b5d8c246e75ef75e",
"delta": "531346180634069534713"
}
Separately, the orchestrator's staking-share balance increased by 531346180634069534726 share units because the liquid half of each duplicate reward claim was recycled back into staking during the same transaction. The affected parties are the staking pool and its honest stakers, whose reward inventory was consumed by duplicate claims.
0x7e19f8edb1f1666322113f15d7674593950ac94bbc25d2aff96adabdcae0a6c320667434 with pre-state immediately before that block used as the ACT baseline0xe2910b29252f97bb6f3cc5e66bfa0551821c74610x66149ab384cc066fb9e6bc140f1378d1015045e90x0ef1c026c6ed555432a39ae2b5d8c246e75ef75ePythiaTokenStaking.sol and AbstractRewards.sol from the collected verified source0x7e19f8edb1f1666322113f15d7674593950ac94bbc25d2aff96adabdcae0a6c3