Cecuro Prevented Earmark Drift on Alchemix V3 **TL;DR** - **Cecuro reported this bug to Alchemix through their bug bounty program.** Alchemix triaged the finding and awarded a **$1,000 bounty** under the Low severity tier. - The vulnerability is a **Low severity accounting bug** in Alchemix V3's `AlchemistV3.sol` that caused the protocol to systematically under-earmark debt after every repayment, self-liquidation, or liquidation. - The root cause was a **stale storage snapshot**: four functions transferred yield tokens (MYT) to the Transmuter without bumping the `lastTransmuterTokenBalance` accumulator, so the next earmark cycle misread the transfer as an external balance increase and double-counted the earmarked portion as "cover." - A fork PoC against the live **Optimism USDC AlchemistV3** showed that a single repay of ~8e18 earmarked debt caused **7.88e18 of under-earmarking (~49%)** in the next earmark window. - The impact is asymmetric: **Transmuter stakers** get their redemptions backed more slowly than the staking graph dictates, while **borrowers** retain more control over their collateral than the system intended. - The fix is a one-line update inside each affected function: bump `lastTransmuterTokenBalance` by the earmarked portion of the transfer. If you are building or maintaining a DeFi protocol with internal accounting that mirrors token balances, **[run your contracts through Cecuro](https://app.cecuro.ai)** to catch bugs like this before the protocol goes live. --- ## The Target: Alchemix V3 and the Transmuter Alchemix V3 is a self-repaying loan protocol. Users deposit yield-bearing assets, mint a debt-like synthetic stablecoin (alUSD), and have their loan paid down passively as the underlying yield accrues. The mechanism that makes this work is the **Transmuter**. Here is the cycle in one paragraph. A borrower deposits a yield token (Alchemix calls these "MYT" -- Morpho Yield Tokens -- in the V3 deployment), mints alUSD against it, and walks away with a synthetic dollar that has no liquidation risk and no interest payments. On the other side of the market, alUSD holders stake into the Transmuter to receive 1:1 redemption against the yield generated by all those deposits. As yield accrues to MYT held by borrowers, the protocol pulls a portion to the Transmuter, where stakers gradually convert their alUSD into the underlying asset. The borrower's debt decreases, the staker's alUSD position transmutes into yield-bearing collateral, and everyone leaves happy. The bookkeeping that makes this dance work is **earmarking**. The Transmuter publishes a "graph" describing how much debt should be marked for redemption over a given window. The Alchemist contract reads this graph, distributes the demand across borrowers proportional to their unearmarked debt, and tracks the result in `cumulativeEarmarked`. Earmarked debt is debt that has been claimed by Transmuter stakers but not yet repaid out of yield. The bug we found lives in the seam between two pieces of state that need to stay synchronized: the Transmuter's actual MYT balance, and the Alchemist's snapshot of that balance. --- ## Two Pieces of State That Must Agree Inside `AlchemistV3.sol`, the function that runs every accounting cycle is `_earmark()`. The first thing it does is read the Transmuter's MYT balance and compare it to the last balance the Alchemist saw: ```solidity // _earmark() -- conceptual uint256 transmuterBalance = TokenUtils.safeBalanceOf(myt, address(transmuter)); if (transmuterBalance > lastTransmuterTokenBalance) { _pendingCoverShares += (transmuterBalance - lastTransmuterTokenBalance); } lastTransmuterTokenBalance = transmuterBalance; ``` The intent is clear. Any MYT that **appeared at the Transmuter unexpectedly** -- meaning the Alchemist's accounting did not put it there -- should be treated as "cover." Cover reduces the amount of debt the Alchemist has to earmark from the graph, because the Transmuter has been handed yield it can immediately use to fulfill redemptions. The system is correct as long as the Alchemist updates `lastTransmuterTokenBalance` every time it pushes MYT to the Transmuter through one of its own functions. That update tells the next `_earmark()` call: "the new balance you are about to see at the Transmuter is balance I sent on purpose, not surprise yield." The Transmuter side of the relationship gets this right. After its `claimRedemption()` flow, the Transmuter calls back into the Alchemist via `setTransmuterTokenBalance(...)` to keep the snapshot in sync. The bug is that **four functions on the Alchemist side do not**. --- ## The Four Functions That Forget to Update | Function | Line | Transfer to Transmuter | |---|---|---| | `repay()` | 596 | `TokenUtils.safeTransferFrom(myt, msg.sender, transmuter, creditToYield)` | | `selfLiquidate()` | 761 | `TokenUtils.safeTransfer(myt, transmuter, repaidDebtInYield)` | | `_forceRepay()` | 976 | `TokenUtils.safeTransfer(myt, address(transmuter), creditToYield)` | | `_doLiquidation()` | 1220 | `TokenUtils.safeTransfer(myt, transmuter, netToTransmuter)` | Each of these functions does the same thing in spirit. It reduces a borrower's debt, shrinks the global earmark counter for the portion of repaid debt that was already earmarked, transfers MYT to the Transmuter to back the redemption, and exits. The missing line is the bump on `lastTransmuterTokenBalance`. Without it, the next `_earmark()` call sees the Transmuter's balance climb -- exactly by the amount the Alchemist just sent -- and logs the entire delta as cover. The earmarked portion of that transfer gets accounted for twice: once when `_subEarmarkedDebt()` shrinks `cumulativeEarmarked`, and again when the same MYT is treated as a cover share that reduces the next earmark cycle. Two paths in the codebase reduce earmarking demand. One is correct (handing debt off the books). The other is meant to handle surprise yield. The bug is that one transfer is being run through both paths simultaneously. --- ## Walking Through the Numbers The cleanest way to see the bug is to trace a single repay. **Initial state:** `totalDebt = 100e18`, `cumulativeEarmarked = 50e18`. A user has 100e18 of debt, 50e18 of which has been earmarked. **Step 1: User repays 50e18, all of it earmarked.** - `_subEarmarkedDebt()` reduces `cumulativeEarmarked` from 50 to 0. - `_subDebt()` reduces `totalDebt` from 100 to 50. - 50e18 worth of MYT is transferred to the Transmuter. - `lastTransmuterTokenBalance` is **not** updated. After the repay, the relationship `liveUnearmarked = totalDebt - cumulativeEarmarked` gives 50 - 0 = 50. Good. The remaining 50e18 of debt is now unearmarked, ready to be picked up in the next earmark cycle. **Step 2: Next `_earmark()` call.** - The Transmuter's balance is 50e18 higher than `lastTransmuterTokenBalance`. - `_pendingCoverShares` grows by 50e18. - The graph asks for 50e18 of new earmarking. - Cover (50e18) is netted against the demand, leaving 0. - **No earmarking happens.** The 50e18 of unearmarked debt that should have been earmarked this cycle stays unearmarked. The Transmuter keeps the MYT it just received, but the staking graph has no live earmark commitment behind any new demand. The next time a Transmuter staker tries to claim, the redemption pulls from raw MYT balance instead of from earmarked collateral, and that pool depletes faster than the protocol expects. The asymmetry is the part that bites. Borrowers and stakers share the same pool of accounting state, and a bug that helps one always hurts the other. Borrowers got their debt earmarked less aggressively; stakers see their redemptions backed less aggressively. --- ## Proving It on Live Optimism We built a Foundry fork against Optimism mainnet at block 150059612 (April 9, 2026) and ran the full path against the live USDC AlchemistV3 deployment at `0x61fe8dBBff8cf864062fdA593B55bbE1A31cA238`. The PoC does six things in sequence: 1. User A deposits ~10 USDC of MYT and mints ~8 alUSD of debt at 80% LTV. 2. User A redeems all 8 alUSD through the Transmuter, creating earmark demand. 3. Time advances by `timeToTransmute` blocks. The Alchemist earmarks ~7.75e18 of User A's debt. 4. User A repays the earmarked debt with their reserved MYT. The MYT is transferred to the Transmuter. 5. User B opens a new position with 16e18 of debt and creates a fresh redemption. 6. Time advances again. We measure how much of User B's debt actually gets earmarked. Here are the numbers from the run: ``` === AFTER REPAY === lastTransmuterTokenBalance (STALE): 15822983269256 actual transmuter MYT balance: 159157754954106694086 STALE DELTA (spurious cover): 159157739131123424830 === FINAL STATE === User B debt: 15999999200000000000 User B earmarked: 8117748034733579941 UNDER-EARMARKED BY: 7882251165266420059 ``` User B's 16e18 of debt should have been fully earmarked given the redemption demand, but only 8.1e18 actually was. The remaining 7.88e18 went uncovered because User A's repay had already poisoned the cover pool. That is a 49% miss in a single window, on the live deployment. The stale delta itself is striking. `lastTransmuterTokenBalance` was sitting at ~1.58e13 while the actual Transmuter MYT balance was ~1.59e20 -- seven orders of magnitude apart. The accumulator had been silently drifting behind reality for a long time before our PoC even started. --- ## Why "Low" and Not Higher Funds were never at risk of being stolen, and the principal of stakers and borrowers was preserved. What happened was a **timing distortion**: redemptions get backed slower than the protocol promises in its graph, and borrowers retain their collateral longer than the protocol intended. Eventually the math catches up either through new earmark cycles, manual operator intervention, or the Transmuter calling back through `setTransmuterTokenBalance()` after a redemption claim. That puts it squarely in the "smart contract fails to deliver promised returns, but doesn't lose value" bucket of standard bug bounty severity guidelines. We submitted at Medium, the program triaged at Low, and a $1,000 bounty was awarded. This kind of severity boundary is worth taking seriously when designing audit scope. Not every accounting drift is a heist, but accounting drift in a system whose entire promise is "the math always lines up" still erodes user trust. Stakers in the Transmuter who notice their redemptions being backed by raw balance rather than earmarked collateral are not technically being stolen from, but they are getting a different product than the docs describe. | Severity Factor | Outcome | |---|---| | Direct theft of funds | None | | Permanent loss of principal | None | | Temporary freezing of funds | None | | Failure to deliver promised returns | Yes (slower redemption backing) | | Permissionless to trigger | Yes (any repay or liquidation) | | Repeats every cycle | Yes | | Quantified live impact | ~49% under-earmarking per window | | Final classification | Low ($1,000 bug bounty) | The line between Medium ("fails to deliver promised returns") and Low ("informational" or minor mechanical bugs) sits on whether the accounting drift is recoverable through normal protocol flow. In Alchemix V3, the Transmuter's own `claimRedemption()` flow does eventually re-sync `lastTransmuterTokenBalance`, which means the divergence is bounded by the Transmuter's redemption activity. That recovery path is what kept the severity contained. --- ## The Fix The recommended fix is a one-line addition inside each of the four affected functions. After the MYT transfer, bump `lastTransmuterTokenBalance` by the earmarked portion of the transfer: ```solidity // In repay() after line 596 lastTransmuterTokenBalance += earmarkedRepaidToYield; // In selfLiquidate() after line 761 lastTransmuterTokenBalance += repaidDebtInYield; // In _forceRepay() after line 976 lastTransmuterTokenBalance += creditToYield; // In _doLiquidation() after line 1220 lastTransmuterTokenBalance += netToTransmuter; ``` A simpler alternative is to re-read the Transmuter's MYT balance at the end of each function and overwrite the snapshot: ```solidity lastTransmuterTokenBalance = TokenUtils.safeBalanceOf(myt, address(transmuter)); ``` This second form is cleaner but slightly more expensive in gas because it adds a `BALANCE` opcode plus an SLOAD per call. For a function that runs on every repay and liquidation, the precise increment form is preferable. There is a subtlety in deciding **which portion** of the transfer should bump the snapshot. The unearmarked portion of a repay legitimately becomes "extra" MYT at the Transmuter -- yield that does count as cover for the next earmark cycle. The earmarked portion is the one that has already been accounted for through `cumulativeEarmarked` reduction. Only the earmarked portion should bump the snapshot. That is why the proposed fix tracks the earmarked component separately rather than bumping by the full `creditToYield`. `_forceRepay()` is the simplest case because the function is invoked when a position's debt is fully earmarked, so the entire `creditToYield` is the earmarked portion. Same for `selfLiquidate()` and `_doLiquidation()` -- the transfers are bounded above by the position's earmarked debt, so the full transfer amount can safely bump the snapshot. **[Start a Cecuro audit](https://app.cecuro.ai)** to find accounting bugs like this in your own contracts before they ship. --- ## Lessons for Smart Contract Developers The pattern that produced this bug is not specific to Alchemix. Any time a contract maintains an internal accumulator that mirrors an external token balance, the same class of bug becomes possible. ### Mirror Accumulators Need a Write After Every External Mutation If your contract holds state that says "the last time we looked, account X had balance Y," that state must be updated every time your contract changes account X's balance. Not most of the time. Every time. The audit checklist for any function that calls `transfer`, `transferFrom`, or any external state mutation should explicitly include: "did we update every internal accumulator that mirrors the affected balance?" The mistake in `AlchemistV3.sol` was that the snapshot was correctly updated inside `_earmark()` (where the balance was being read), but four other functions that **wrote** to the same balance forgot to update the snapshot. Read paths and write paths both need to maintain the invariant; it is easy to remember the read paths and forget the write paths. ### Build a Mutation Matrix for Cross-Contract State For any state variable shared between two contracts, build a small matrix: rows are operations, columns are contracts that mutate the underlying value. Every cell either updates the snapshot or has a documented reason it does not need to. For Alchemix V3, the matrix would have looked like this: | Operation | Mutates Transmuter MYT? | Updates `lastTransmuterTokenBalance`? | |---|---|---| | Transmuter `claimRedemption()` | Yes (decreases) | Yes via `setTransmuterTokenBalance()` | | Alchemist `_earmark()` | No (only reads) | Yes (snapshot at start) | | Alchemist `repay()` | Yes (increases) | **No -- bug** | | Alchemist `selfLiquidate()` | Yes (increases) | **No -- bug** | | Alchemist `_forceRepay()` | Yes (increases) | **No -- bug** | | Alchemist `_doLiquidation()` | Yes (increases) | **No -- bug** | | External MYT yield accrual | Yes (increases) | No (intentional -- this is cover) | Four blank cells out of seven. The matrix would have made the omission visible at a glance during code review. We have written before about this technique for parallel data structures; it works equally well for shared accumulators across contracts. ### Cover Mechanisms Are Failure-Mode Detectors The interesting design choice in Alchemix V3 is that the cover mechanism exists precisely because the Transmuter is allowed to receive MYT through paths the Alchemist does not control. External yield accrual on the underlying Morpho vaults, donations, accidental transfers -- any of these can show up at the Transmuter and need to be netted out of earmark demand. A cover mechanism is therefore a **failure-mode detector**. It is the contract saying: "if the balance changes for reasons I did not expect, treat the surplus as a gift to stakers." That detector only works if the contract correctly distinguishes "I changed the balance" from "the balance changed for reasons outside my control." The bug in this case was that four of the contract's own write paths were being misclassified as external events. When designing a cover-style mechanism, every internal write path that touches the external balance should immediately follow up with a snapshot update, and there should be a property test that asserts: "after any sequence of internal writes, the accumulator equals the external balance." That property would have failed instantly on any random repay sequence. ### Test Multi-Cycle State Drift, Not Just Single-Call Behavior Standard test suites test the happy path of a single function call: repay, check balances, assert equal. The bug here only surfaces when you test a sequence of operations across multiple earmark cycles. The first repay does not look broken. The second earmark cycle is where the cover gets miscounted. The third cycle is where the under-earmarking becomes visible to the next user. Adversarial tests for accounting bugs need to chain interactions across cycles. A useful pattern is to write a "drift" test: run a randomized sequence of N operations from each of the affected paths, and at the end assert that internal accumulators match the external truth. If they do not, you have an accumulator that is being written by one path and not by another. ### Invariant Tests Catch What Unit Tests Miss Foundry's invariant testing harness is a perfect fit for this class of bug. The invariant `lastTransmuterTokenBalance == TokenUtils.safeBalanceOf(myt, address(transmuter)) - _pendingCoverShares` (modulo the cover semantics) would have failed within the first few sequences of `repay()` calls. The cost of writing such an invariant is small. The cost of shipping without one is exactly this kind of finding. --- ## Broader Context: Synthetic Stablecoins and Accounting Surface Area Synthetic stablecoin protocols like Alchemix have an unusual amount of internal accounting surface area. The promise of a self-repaying loan is mathematically equivalent to a continuous stream of small transfers between borrowers, the protocol, and stakers. Every deposit, mint, repay, liquidation, claim, and redemption needs to be accounted for in a way that maintains global consistency. The danger zones in this design tend to cluster around three places: 1. **Cross-contract state synchronization.** Snapshots of the other contract's balance, mirrored counters, "last seen" accumulators. Any of these can drift if write paths on either side forget to update them. 2. **Earmarking and queueing.** Anywhere the protocol commits to backing future obligations with current collateral, there is bookkeeping that needs to survive every kind of unhappy path: partial repays, liquidations during earmark windows, multiple cycles before claim. 3. **Yield accrual paths.** Yield comes from external sources (Morpho, Yearn, Aave, etc.) and shows up as token balance increases without an internal trigger. The protocol has to distinguish "yield" from "internal transfer" and route them differently. The bug we found sits at the intersection of all three. The Transmuter's MYT balance is shared state, the earmarking flow depends on that balance being correctly classified, and the cover mechanism is the protocol's way of routing yield-like balance changes. A single missing write broke the classification, which propagated through earmarking and produced visible under-earmarking in the live deployment. These are the kinds of multi-step accounting interactions that benefit most from systematic analysis. Looking at any single function in `AlchemistV3.sol` does not reveal the bug -- `repay()` looks correct in isolation. The bug only appears when you trace state across `repay()` and the next `_earmark()` call together. Audits that focus on per-function correctness can miss these. Audits that build up a model of cross-function state interactions catch them. --- ## How Cecuro Found This This finding came out of Cecuro's automated cross-contract state interaction analysis. The pipeline systematically catalogs every state variable that is read by one function and written by another, and flags variables where the write paths and read paths come from different functions or different contracts. Mirror accumulators -- internal copies of external state -- are flagged as high-risk because of exactly this synchronization-gap pattern. For Alchemix V3, the analysis identified `lastTransmuterTokenBalance` as a mirror of the Transmuter's MYT balance, then enumerated every function that mutated either side of the relationship. The asymmetry between the Transmuter's `setTransmuterTokenBalance()` callback (which kept the snapshot honest) and the Alchemist's four MYT-transferring functions (which did not) jumped out immediately. The rest was tracing the consequences. Once you know the snapshot is stale, you can predict that `_earmark()` will misread the next Transmuter balance check as cover, and that the cover will be netted against new earmark demand. The fork PoC quantified the impact at 49% under-earmarking on the live Optimism deployment. **[Start your audit today](https://app.cecuro.ai)** -- our cross-contract state analysis catches accounting bugs that single-function audits routinely miss. --- ## Responsible Disclosure Timeline This vulnerability was reported directly to Alchemix through their bug bounty program. Alchemix triaged the finding and awarded a $1,000 bounty under the Low severity tier. Cecuro follows responsible disclosure practices: we only publish technical details after the affected protocol has had adequate time to remediate. We believe sharing the full technical analysis, including the working fork PoC and quantified impact numbers, raises the security bar for the entire ecosystem. The mirror-accumulator pattern that produced this bug is common in synthetic stablecoin protocols, and developers building similar systems benefit from seeing exactly how the synchronization gap manifests.