57066 sc critical a malicious actor can keep calling poke at every block to prevent collateral earmarking exposing transmuter users to delayed redemptions and loss of funds
Submitted on Oct 23rd 2025 at 08:03:45 UTC by @Oxdeadmanwalking for Audit Comp | Alchemix V3
Report ID: #57066
Report Type: Smart Contract
Report severity: Critical
Target: https://github.com/alchemix-finance/v3-poc/blob/immunefi_audit/src/AlchemistV3.sol
Impacts:
Contract fails to deliver promised returns, but doesn't lose value
Description
Brief/Intro
By calling poke() on every block to trigger an _earmark, an attacker forces queryGraph(n, n) to return 0, preventing the three critical earmarking variables (cumulativeEarmarked, _earmarkWeight, _survivalAccumulator) from updating. This can in turn break transmuter redemptions which rely on earmarked debt.
Vulnerability Details
Global earmarking of user collateral for redemptions happens through the _earmark function which is called upon every state changing call to the Alchemist contract.
The function first calculates the amount to be earmarked by querying the rolling amount of queued alAssets for redemption from lastEarmarkBlock up to the current block using the StakingGraph of the transmuter.
uint256 amount = ITransmuter(transmuter).queryGraph(lastEarmarkBlock + 1, block.number);In Transmuter:
After the amount if obtained, _earmark updates global state related to redemptions (namely, _survivalAccumulator , _earmarkWeight and cumulativeEarmarked)
At the end of the call, _earmark updates the lastEarmarkBlock to prevent double counting on subsequent calls
However, lastEarmarkBlock gets updated even if the amount returned by the staking graph is 0.
https://github.com/alchemix-finance/v3-poc/blob/a192ab313c81ba3ab621d9ca1ee000110fbdd1e9/src/Transmuter.sol#L269-L271
Lets see how we can trigger this state.
Suppose
lastEarmarkBlockis 1000. On the next block we call a non privileged state changing function such aspoke()to trigger an earmark at block 1001.The staking graph of the transmuter is queried for [
lastEarmarkBlock+1, block.number] which corresponds to [1001,1001], meaning just 1 block.queryGraphhowever, returns 0 by default ifstartBlock == endBlock
As a result the amount for earmarking to be returned will be 0. And the if statement that updates the weights and cumulative earmark amounts will be skipped inside
_earmark. ThelastEarmarkBlockhowever will get updated toblock.numberwhich is 1001.We have now essentially skipped a block of earmarking. We can keep repeating this and calling
poke()for subsequent blocks to prevent earmarks from ever accumulating for this time period.
The cost of this is trivial. To prevent earmarking for an hour assuming the following parameters on ETH mainnet:
300 blocks/hour (12sec block times)
74475 gas for an isolated
poke()in test scenarios0.0000000005 ETH per gas (~average gas price on Mainnet over the past months)
4000 USD ETH price 300 * (74475 * 0.0000000005 * 4000) == ~44 USD
Impact Details
The impact is that the redemption and earmarking mechanism gets broken for the time period of the attack as debt that should have gotten earmarked never does. Focusing on cumulativeEarmarked not updating, Alchemist.redeem will return a 0 amount of tokens back to the Transmuter upon a redemption. https://github.com/alchemix-finance/v3-poc/blob/a192ab313c81ba3ab621d9ca1ee000110fbdd1e9/src/Transmuter.sol#L232C13-L232C27 As a result, redemptions will fail and users will not be able to claim the underlying even if their position has fully matured.
References
https://github.com/alchemix-finance/v3-poc/blob/a192ab313c81ba3ab621d9ca1ee000110fbdd1e9/src/AlchemistV3.sol#L1098-L1132
https://github.com/alchemix-finance/v3-poc/blob/a192ab313c81ba3ab621d9ca1ee000110fbdd1e9/src/Transmuter.sol#L269-L278
Proof of Concept
Proof of Concept
Add this test to
AlchemistV3.t.solat the end of the file
The output should look like the following.
Note how the lastEarmarkBlock has increased without earmarked debt ever updating for that time period
Was this helpful?