58127 sc critical users can invoke the poke function whenever the lastearmarkdebtblock is exactly one block behind the current block number which lead to affecting users earmarked debt
Submitted on Oct 30th 2025 at 20:00:30 UTC by @zeroK for Audit Comp | Alchemix V3
Report ID: #58127
Report Type: Smart Contract
Report severity: Critical
Target: https://github.com/alchemix-finance/v3-poc/blob/immunefi_audit/src/Transmuter.sol
Impacts:
Contract fails to deliver promised returns, but doesn't lose value
Theft of unclaimed yield
Smart contract unable to operate due to lack of token funds
Description
Brief/Intro
The earmarking system updates the earmark weight only when a specific token amount per block is reached. This is achieved by invoking the queryGraph function, which in turn calls the queryStake function in stakingGraph.sol. the staking graph library plays a critical role in retrieving and updating the current on-chain stake state. it implements a double Fenwick tree (Binary Indexed Tree) structure for improved efficiency. his process determines the tokens distributed per block.The team has already provided a comprehensive explanation of this mechanism, including how token emissions and redemptions per block are calculated, and how the earmark and redemption processes are interconnected. full details are available in the link below :
https://docs.google.com/document/d/1tY5_v4x6Njbm_oBsN3syptDPOW6ptPGX6OgFWrT_Xkk/edit?tab=t.0#heading=h.xcg1e2ldkd3a
since the tokens returned by queryGraph are tokens per block, there is a flow in the earmark function, that can lead to ignore tokens for unlimited number of blocks which lead to prevent accurate update to the earmark weight which affects in returns users required earmark amount will and same the collateral to be locked, this is possible by a simple way, the earmark function invokes call to the queryGraph by setting the block.number as end block and last updated block +1 as start, between these two blocks there is possibility of existing amount that need to be used to update earmarkWeight, but mailcious user can invoke poke() function for each 1 block difference, for example last updated block = 1000 and current block = 1001, returned amount by query should be 0.009 but since _earmark sets the start as last updated plus one, the queryGraph returns zero due to line below:
if (endBlock <= startBlock) return 0;this lead to earmarkWeight to be less accurate, which affect users earmarked debt and the survival mechanism too.
Vulnerability Details
the function _earmark implemented as shown below:
there is some line that is important to focus on to understand this report, first thing we can see the first check if (block.number <= lastEarmarkBlock) which prevents updating in same block, while its critical check but it won't prevent the malicious behavior since lastEarmarkBlock become block.number + 1 later when queryGraph invoked. the second thing is how lastEarmarkBlock get updated even if the earmarkWeight/_survivalAccumulator not updated, this give the malicious user to execute its attack, but lets first take a look at query graph function too:
all we need in this line is the check if (endBlock <= startBlock) return 0; which will be executed in our case, now the malicious user can invoke the steps below to make lose of tokens per one block possible.
alice invokes the poke() function, it invokes _earmark and _sync without any modifier or requirement. this will update cumulative earmark, earmarkWeight and _survivalAccumulator, and more important, it sets lastEarmarkedBlock to curren block.number(in our case block 1000 for simplicity).
after block.number become 1001, alice invoke poke() again, which leads the line `if (endBlock <= startBlock) and return zero, while it should return tokens per block and update earmarkWeight and other important state.
the amount returned in the line
ITransmuter(transmuter).queryGraph(lastEarmarkBlock + 1, block.number);equal to zero this is becauselastEarmarkBlock + 1 = 1001, block.number = 1001which lead to queryGraph to return zero early.the lastEarmarkBlock will get updated to current block.number, which open the possibility of repeating this attack.
this way the earmarkweight and other important state will not be updated each time alice invokes poke() when difference between lastEarmarkBlock and block.number is one block only, which affect users earmarked debt and the whole ecosystem mechanism.
while tokens per one block can be between 1-100 tokens depends on redemption demand, but repeating this behavior for up to +10 times will lead to consequences, and there is no modifier or safety action that prevent attacker to invoke this action except for quick update to the core implementation which can take at least 30 min(more than enough for attacker to cause real damage)
Impact Details
attacker can affect earmark mechanism by invoke call to _earmark function through poke() function for each one block differences.
References
https://github.com/alchemix-finance/v3-poc/blob/a192ab313c81ba3ab621d9ca1ee000110fbdd1e9/src/AlchemistV3.sol#L1098C5-L1132C6
https://github.com/alchemix-finance/v3-poc/blob/a192ab313c81ba3ab621d9ca1ee000110fbdd1e9/src/AlchemistV3.sol#L654-L658
https://github.com/alchemix-finance/v3-poc/blob/a192ab313c81ba3ab621d9ca1ee000110fbdd1e9/src/Transmuter.sol#L270
Proof of Concept
Proof of Concept
run the test below in transmuter.t.sol:
Was this helpful?