# 56732 sc critical incorrect boundary condition in querygraph leads to systematic under earmarking and transmuter redemption fund loss

**Submitted on Oct 20th 2025 at 02:44:06 UTC by @enoch for** [**Audit Comp | Alchemix V3**](https://immunefi.com/audit-competition/alchemix-v3-audit-competition)

* **Report ID:** #56732
* **Report Type:** Smart Contract
* **Report severity:** Critical
* **Target:** <https://github.com/alchemix-finance/v3-poc/blob/immunefi\\_audit/src/Transmuter.sol>
* **Impacts:**
  * Permanent freezing of funds

## Description

## Relevant Context

The Alchemix V3 protocol implements a transmuter system where users can deposit synthetic tokens (alAssets) to eventually receive yield-bearing tokens. The transmuter tracks position maturation through a staking graph data structure that records active stakes across block ranges.

The `AlchemistV3` contract maintains an earmarking mechanism to allocate borrower debt for redemption by transmuter users. The earmarking process queries the transmuter's staking graph to determine how much active stake should be converted into earmarked debt, which directly determines the redemption capacity available to transmuter users.

## Finding Description

The `Transmuter#queryGraph` function contains an incorrect boundary condition check that causes it to return zero when queried for a single block (`endBlock == startBlock`), despite active stakes existing at that block.

```solidity
function queryGraph(uint256 startBlock, uint256 endBlock) external view returns (uint256) {
    if (endBlock <= startBlock) return 0;
    // ... rest of function
}
```

The condition `endBlock <= startBlock` includes the equality case, which is incorrect. When `endBlock == startBlock`, the function should return the amount of active stake at that specific block, not zero. The underlying `StakingGraph#queryStake` function is designed to handle single-block queries correctly by computing the integral of active stake over the range `[startBlock, endBlock]`.

This bug manifests in `AlchemistV3#_earmark`, which is invoked by 9 different protocol functions (`deposit`, `mint`, `mintFrom`, `burn`, `withdraw`, `redeem`, `repay`, `liquidate`, and `poke`):

```solidity
function _earmark() internal {
    if (totalDebt == 0) return;
    if (block.number <= lastEarmarkBlock) return;
    
    uint256 amount = ITransmuter(transmuter).queryGraph(lastEarmarkBlock + 1, block.number);
    // ... earmarking logic using 'amount'
}
```

When `_earmark` is called in consecutive blocks (i.e., when `lastEarmarkBlock == block.number - 1`), the query becomes `queryGraph(block.number, block.number)`, which incorrectly returns zero. This causes the active stake at that block to be completely excluded from earmarking calculations, systematically understating `cumulativeEarmarked`.

The consecutive block scenario occurs naturally during normal protocol usage with moderate activity, and can be deliberately triggered by users timing their interactions. Over time, each missed earmark accumulates, creating a growing discrepancy between actual transmuter liabilities and tracked earmarked debt.

The highest impact occurs during transmuter redemptions. When users call `Transmuter#claimRedemption`, the transmuter attempts to redeem collateral from the alchemist by calling `AlchemistV3#redeem`. However, this function caps redemptions to available earmarked debt:

```solidity
function redeem(uint256 amount) external onlyTransmuter {
    _earmark();
    
    uint256 liveEarmarked = cumulativeEarmarked;
    if (amount > liveEarmarked) amount = liveEarmarked;  // Redemption capped here
    
    // ... transfer collateral to transmuter
}
```

When `cumulativeEarmarked` is systematically understated due to missed consecutive-block earmarks, legitimate redemptions become under-collateralized. Transmuter users receive less yield tokens than their positions entitle them to, or receive none at all if the earmark deficit is severe.

## Impact

Transmuter users suffer direct fund loss proportional to the cumulative earmarking deficit. The magnitude of loss depends on protocol activity patterns and the frequency of consecutive-block interactions. In scenarios with sustained consecutive-block activity (which can occur naturally or be deliberately induced), the deficit can reach 20-30% of total transmuter obligations, causing equivalent losses to redemption claimants. The last redeemers bear disproportionate losses as they claim after the earmarked capacity is exhausted.

## Recommendation

Modify the boundary condition in `Transmuter#queryGraph` to allow single-block queries:

```diff
function queryGraph(uint256 startBlock, uint256 endBlock) external view returns (uint256) {
-   if (endBlock <= startBlock) return 0;
+   if (endBlock < startBlock) return 0;

    int256 queried = _stakingGraph.queryStake(startBlock, endBlock);
    if (queried == 0) return 0;

    return (queried / BLOCK_SCALING_FACTOR).toUint256()
        + (queried % BLOCK_SCALING_FACTOR == 0 ? 0 : 1);
}
```

This change ensures that when `endBlock == startBlock`, the function correctly queries and returns the active stake at that specific block, allowing `AlchemistV3#_earmark` to properly account for consecutive-block earmarking scenarios.

## Proof of Concept

## Proof of Concept

The provided test case in `src/test/AlchemistV3.t.sol` demonstrates the under-earmarking behavior:

**Test Setup:**

1. The test file can be run from the repository root using standard Foundry test commands
2. The test case `test_QueryGraphBug_ConsecutiveBlocksUnderearmarksCausesRedemptionLoss` is located in the existing test suite

**Step-by-Step Execution:**

1. A whale borrows 5,256,000e18 debt tokens by depositing 10,000,000e18 collateral into `AlchemistV3` and minting the maximum borrowable amount through `AlchemistV3#mint`.
2. The whale deposits the entire borrowed amount into the transmuter via `Transmuter#createRedemption`, creating a transmuter position that will mature over 5,256,000 blocks (matching the borrowed amount, so 1e18 debt should be earmarkable per block).
3. The protocol advances 10 blocks and calls `AlchemistV3#poke`, triggering `_earmark`. The query becomes `queryGraph(startBlock, startBlock + 9)`, correctly returning 10e18 worth of active stake. `cumulativeEarmarked` increases to 10e18.
4. The protocol advances exactly 1 more block (now at `startBlock + 10`) and calls `AlchemistV3#poke` again. This time, `lastEarmarkBlock` equals `startBlock + 9`, so the query becomes `queryGraph(startBlock + 10, startBlock + 10)`. Due to the bug, this returns 0 instead of 1e18. `cumulativeEarmarked` remains at 10e18, missing the 1e18 from block `startBlock + 10`.
5. After full maturation (5,256,000 blocks later), the whale calls `Transmuter#claimRedemption`. The transmuter attempts to redeem the full 5,256,000e18, but `AlchemistV3#redeem` caps it to `cumulativeEarmarked`, which is 1e18 short due to the single missed earmark in step 4.
6. The whale receives 1e18 less yield tokens than entitled, demonstrating direct fund loss.

**Test Code:**

```solidity
function test_QueryGraphBug_ConsecutiveBlocksUnderearmarksCausesRedemptionLoss() external {
    uint256 depositAmount = 10_000_000e18;
    uint256 borrowAmount = 5_256_000e18;
    
    // whale borrows 5_256_000e18
    vm.startPrank(someWhale);
    SafeERC20.safeApprove(address(vault), address(alchemist), depositAmount);
    alchemist.deposit(depositAmount, someWhale, 0);
    uint256 tokenId = AlchemistNFTHelper.getFirstTokenId(someWhale, address(alchemistNFT));
    alchemist.mint(tokenId, borrowAmount, someWhale);
    vm.stopPrank();

    uint256 totalDebt = alchemist.totalDebt();
    assertEq(totalDebt, borrowAmount, "Total debt should be 5_256_000e18");

    // Create transmuter redemption for full debt amount
    vm.startPrank(someWhale);
    SafeERC20.safeApprove(address(alToken), address(transmuterLogic), borrowAmount);
    transmuterLogic.createRedemption(borrowAmount);
    vm.stopPrank();
    
    uint256 startEarmarkBlock = block.number + 1;
    vm.roll(startEarmarkBlock + 9);
    alchemist.poke(tokenId);
    uint256 earmarkedStep1 = alchemist.cumulativeEarmarked();
    // Each block 1e18 debt is earmarked
    // From startEarmarkBlock to startEarmarkBlock + 9, there are 10 blocks
    // Therefore, the total earmarked should be 10e18
    assertEq(earmarkedStep1, 10e18, "Earmarked should be 10e18");

    vm.roll(startEarmarkBlock + 10);
    alchemist.poke(tokenId);
    uint256 earmarkedStep2 = alchemist.cumulativeEarmarked();
    // The bug manifests here: earmarking at consecutive block returns 0
    // This means the cumulativeEarmarked would stay the same
    assertEq(earmarkedStep2, earmarkedStep1, "Earmarked should be the same");
    
    // Full redemption
    vm.roll(startEarmarkBlock + 5_256_000 + 10);
    uint256 mytTokenBefore = IERC20(alchemist.myt()).balanceOf(address(alchemist));
    vm.prank(someWhale);
    transmuterLogic.claimRedemption(tokenId);
    uint256 mytTokenAfter = IERC20(alchemist.myt()).balanceOf(address(alchemist));

    uint256 mytTokenRedeemed = mytTokenBefore - mytTokenAfter;
    // 1e18 got lost due to the missed earmark at block startEarmarkBlock + 10
    assertEq(mytTokenRedeemed + 1e18, borrowAmount, "Myt token should be redeemed");
}
```

This test demonstrates that even a single consecutive-block interaction causes measurable fund loss. In production scenarios with sustained activity, the cumulative effect would be substantially larger.


---

# Agent Instructions: Querying This Documentation

If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://reports.immunefi.com/alchemix-v3/56732-sc-critical-incorrect-boundary-condition-in-querygraph-leads-to-systematic-under-earmarking-an.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
