58471 sc high accounting error in forcerepay doliquidation overstates tvl enabling under scaled redemptions and potential insolvency
Submitted on Nov 2nd 2025 at 14:40:22 UTC by @winnerz for Audit Comp | Alchemix V3
- Report ID: #58471
- Report Type: Smart Contract
- Report severity: High
- Target: https://github.com/alchemix-finance/v3-poc/blob/immunefi\\_audit/src/AlchemistV3.sol
- Impacts:
- Protocol insolvency
Description
Brief/Intro
When _forceRepay and liquidation _doLiquidation transfer MYT out of the Alchemist, the contract does not decrement _mytSharesDeposited. Since getTotalUnderlyingValue() derives TVL from _mytSharesDeposited, reported TVL remains inflated while actual holdings drop. This depresses badDebtRatio used by the Transmuter and can delay or under‑apply scaling of redemptions, creating a path to protocol insolvency. It can also soften liquidation sizing by overstating global collateralization inputs.
Vulnerability Details
TVL is derived from _mytSharesDeposited:
// src/AlchemistV3.sol: 1238-1241
function _getTotalUnderlyingValue() internal view returns (uint256 totalUnderlyingValue) {
uint256 yieldTokenTVLInUnderlying = convertYieldTokensToUnderlying(_mytSharesDeposited);
totalUnderlyingValue = yieldTokenTVLInUnderlying;
}
In _forceRepay, MYT is transferred out but _mytSharesDeposited is not decremented, so reported TVL does not change:
// src/AlchemistV3.sol: 738-782
function _forceRepay(uint256 accountId, uint256 amount) internal returns (uint256) {
if (amount == 0) {
return 0;
}
_checkForValidAccountId(accountId);
Account storage account = _accounts[accountId];
// Query transmuter and earmark global debt
_earmark();
// Sync current user debt before deciding how much is available to be repaid
_sync(accountId);
uint256 debt;
// Burning yieldTokens will pay off all types of debt
_checkState((debt = account.debt) > 0);
uint256 credit = amount > debt ? debt : amount;
uint256 creditToYield = convertDebtTokensToYield(credit);
_subDebt(accountId, credit);
// Repay debt from earmarked amount of debt first
uint256 earmarkToRemove = credit > account.earmarked ? account.earmarked : credit;
account.earmarked -= earmarkToRemove;
creditToYield = creditToYield > account.collateralBalance ? account.collateralBalance : creditToYield;
account.collateralBalance -= creditToYield;
uint256 protocolFeeTotal = creditToYield * protocolFee / BPS;
emit ForceRepay(accountId, amount, creditToYield, protocolFeeTotal);
if (account.collateralBalance > protocolFeeTotal) {
account.collateralBalance -= protocolFeeTotal;
// Transfer the protocol fee to the protocol fee receiver
TokenUtils.safeTransfer(myt, protocolFeeReceiver, protocolFeeTotal);
}
if (creditToYield > 0) {
// Transfer the repaid tokens from the account to the transmuter.
TokenUtils.safeTransfer(myt, address(transmuter), creditToYield);
}
return creditToYield;
}
Similarly, in _doLiquidation, MYT leaves the contract without decreasing _mytSharesDeposited:
// src/AlchemistV3.sol: 867-880
amountLiquidated = convertDebtTokensToYield(liquidationAmount);
feeInYield = convertDebtTokensToYield(baseFee);
// update user balance and debt
account.collateralBalance = account.collateralBalance > amountLiquidated ? account.collateralBalance - amountLiquidated : 0;
_subDebt(accountId, debtToBurn);
// send liquidation amount - fee to transmuter
TokenUtils.safeTransfer(myt, transmuter, amountLiquidated - feeInYield);
// send base fee to liquidator if available
if (feeInYield > 0 && account.collateralBalance >= feeInYield) {
TokenUtils.safeTransfer(myt, msg.sender, feeInYield);
}
By contrast, other outflow paths do reduce _mytSharesDeposited:
// src/AlchemistV3.sol:withdraw()
TokenUtils.safeTransfer(myt, recipient, amount);
_mytSharesDeposited -= amount;
// src/AlchemistV3.sol:redeem()
TokenUtils.safeTransfer(myt, transmuter, collRedeemed);
TokenUtils.safeTransfer(myt, protocolFeeReceiver, feeCollateral);
_mytSharesDeposited -= collRedeemed + feeCollateral;
// src/AlchemistV3.sol:repay()
TokenUtils.safeTransfer(myt, protocolFeeReceiver, creditToYield * protocolFee / BPS);
_mytSharesDeposited -= creditToYield * protocolFee / BPS;
Transmuter claimRedemption() relies on the Alchemist TVL in its denominator; an inflated TVL depresses this ratio and delays scaling:
// src/Transmuter.sol: 217-226
uint256 yieldTokenBalance = TokenUtils.safeBalanceOf(alchemist.myt(), address(this));
// Avoid divide by 0
uint256 denominator = alchemist.getTotalUnderlyingValue() + alchemist.convertYieldTokensToUnderlying(yieldTokenBalance) > 0 ? alchemist.getTotalUnderlyingValue() + alchemist.convertYieldTokensToUnderlying(yieldTokenBalance) : 1;
uint256 badDebtRatio = alchemist.totalSyntheticsIssued() * 10**TokenUtils.expectDecimals(alchemist.underlyingToken()) / denominator;
uint256 scaledTransmuted = amountTransmuted;
if (badDebtRatio > 1e18) {
scaledTransmuted = amountTransmuted * FIXED_POINT_SCALAR / badDebtRatio;
}
flow-of-funds
_mytSharesDepositedintent (state):
// src/AlchemistV3.sol: 132-134
/// @dev Total yield tokens deposited
/// This is used to differentiate between tokens deposited into a CDP and balance of the contract
uint256 private _mytSharesDeposited;
- Why
repay()only decrements fees: the principal is transferred from the payer directly to the Transmuter (Alchemist never increases its MYT), so only the fee portion is an Alchemist outflow that must reduce_mytSharesDeposited. - In
_forceRepayand_doLiquidation, the principal leaves from the Alchemist balance itself, so_mytSharesDepositedmust be decreased by what is sent out (plus any fee outflows) to keep TVL honest.
Effect on the Transmuter ratio
- Let
den_used = TVL_used + transmuterUnderlyingandden_true = TVL_true + transmuterUnderlying. - Since TVL_used > TVL_true after drift,
den_used > den_truewhich impliesusedR = totalSyntheticsIssued / den_used < trueR = totalSyntheticsIssued / den_true. - Scaling in
claimRedemption()usesmin(1, 1/ratio). IftrueR > 1butusedR <= 1, the claim is paid unscaled when it should be scaled. If both exceed 1, the paid amount under the used ratio is larger than under the true ratio by a factortrueR/usedR > 1.
The drift persists until a path that decrements _mytSharesDeposited runs (withdraw or redeem or the fee leg of repay). Multiple _forceRepay/liquidation events can accumulate drift across blocks, affecting all subsequent redemptions and liquidation decisions protocol-wide.
Why this matters:
getTotalUnderlyingValue()uses_mytSharesDepositedto compute TVL. Omitting decrements during_forceRepay/liquidation keeps reported TVL unchanged even though MYT actually left the protocol.- Transmuter
claimRedemption()computes abadDebtRatiousinggetTotalUnderlyingValue(). Inflated TVL depresses this ratio (usedR < trueR), delaying or under‑applying scaling when bad debt exists. This can systematically overpay claims and lead to insolvency. - Liquidation sizing (
calculateLiquidation) that takes global collateralization as an input can be influenced to under‑liquidate due to the overstated TVL.
Impact Details
- Impact: Protocol insolvency
- Rationale:
- Inflated TVL depresses
badDebtRatioand reduces/delays redemption scaling, overpaying claimants when bad debt exists. - Over time, this can drain reserves and result in protocol insolvency.
- Secondary: Under-liquidation due to overstated global collateralization inputs.
- Inflated TVL depresses
References
_forceRepayMYT outflow without_mytSharesDepositeddecrement: https://github.com/alchemix-finance/v3-poc/blob/a192ab313c81ba3ab621d9ca1ee000110fbdd1e9/src/AlchemistV3.sol#L738_doLiquidationMYT outflow without_mytSharesDepositeddecrement: https://github.com/alchemix-finance/v3-poc/blob/a192ab313c81ba3ab621d9ca1ee000110fbdd1e9/src/AlchemistV3.sol#L867withdraw()correct decrement: https://github.com/alchemix-finance/v3-poc/blob/a192ab313c81ba3ab621d9ca1ee000110fbdd1e9/src/AlchemistV3.sol#L410redeem()correct decrement: https://github.com/alchemix-finance/v3-poc/blob/a192ab313c81ba3ab621d9ca1ee000110fbdd1e9/src/AlchemistV3.sol#L638repay()fee decrement (principal comes from payer, not Alchemist): https://github.com/alchemix-finance/v3-poc/blob/a192ab313c81ba3ab621d9ca1ee000110fbdd1e9/src/AlchemistV3.sol#L541
Mitigation
- In
_forceRepay, decrement_mytSharesDepositedby the MYT actually sent out:- Subtract
creditToYieldandprotocolFeeTotalwhen they are transferred to Transmuter andprotocolFeeReceiverrespectively.
- Subtract
- In
_doLiquidation, decrement_mytSharesDepositedby the fullamountLiquidated(the gross MYT outflow), i.e., the portion sent to Transmuter and the fee to the liquidator.
Proof of Concept
Proof of Concept
Set-up
- Foundry; solc 0.8.28; EVM cancun. No RPC/fork required.
Command
FOUNDRY_PROFILE=default forge test -vvv --match-path src/test/AlchemistV3_TVLDrift.t.sol --evm-version cancun
Work-flow
PoC 1 - TVL overstatement after _forceRepay/liquidation
- Create a position: deposit 20,000 MYT shares and mint 12,000 debt.
- Create a matured redemption (1,000 debt) and
poke()the position to earmark. - Tighten collateralization bounds to 2.0 so liquidation executes.
- Record
reportedBefore = getTotalUnderlyingValue()andtrueBefore = convert(MYT balanceOf(Alchemist))in debt units. - Call
liquidate(tokenId); record Transmuter MYT balance delta andyPaid. - Record
reportedAfterandtrueAfter. - Assertions:
reportedAfter == reportedBefore,trueAfter < trueBefore,drift = reportedAfter - trueAfter > 0.
PoC 2 - Depressed ratio delays scaling in claimRedemption()
- Set Transmuter fee to 0 for simple accounting.
- Induce drift as in PoC 1 (create position, earmark, liquidate).
- Compute two denominators for the ratio:
den_used = getTotalUnderlyingValue() + convert(transmuterShares)andden_true = trueTVL + convert(transmuterShares). - Compute
usedR = totalSyntheticsIssued / den_usedandtrueR = totalSyntheticsIssued / den_true; verifyusedR < trueR. - Create a small matured redemption (500 debt) and ensure Transmuter already holds enough MYT to pay it (no
redeem()call). - Claim and measure
debtClaimedfrom the user’s MYT delta converted back to debt units. - Expected in healthy conditions:
expectedDebtTrue = 500(no scaling). AssertdebtClaimed == expectedDebtTruewhileusedR < trueR, showing scaling activation is delayed by the inflated denominator.
Supporting observation - under‑liquidation at the decision boundary
- After drift, compute global ratios
usedGlobalM(withgetTotalUnderlyingValue()) andtrueGlobalM(with actual MYT balance). - Set
globalMinimumCollateralizationto a midpoint between them. - Call
calculateLiquidation(...)twice, once withusedGlobalMand once withtrueGlobalM. - Expect
debtBurnUsed < accountDebtwhiledebtBurnTrue == accountDebt, demonstrating that inflated TVL can reduce liquidation severity.
// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity 0.8.28;
import {Test} from "../../../lib/forge-std/src/Test.sol";
import {console2} from "../../../lib/forge-std/src/console2.sol";
import {IERC20} from "../../../lib/openzeppelin-contracts/contracts/token/ERC20/IERC20.sol";
import {TransparentUpgradeableProxy} from "../../../lib/openzeppelin-contracts/contracts/proxy/transparent/TransparentUpgradeableProxy.sol";
import {AlchemistV3} from "../../AlchemistV3.sol";
import {Transmuter} from "../../Transmuter.sol";
import {AlchemicTokenV3} from "../mocks/AlchemicTokenV3.sol";
import {AlchemistV3Position} from "../../AlchemistV3Position.sol";
import {AlchemistTokenVault} from "../../AlchemistTokenVault.sol";
import {IVaultV2} from "../../../lib/vault-v2/src/interfaces/IVaultV2.sol";
import {MockMYTVault} from "../mocks/MockMYTVault.sol";
import {MockMYTStrategy} from "../mocks/MockMYTStrategy.sol";
import {MYTTestHelper} from "../libraries/MYTTestHelper.sol";
import {IMYTStrategy} from "../../interfaces/IMYTStrategy.sol";
import {TestERC20} from "../mocks/TestERC20.sol";
import {MockYieldToken} from "../mocks/MockYieldToken.sol";
import {AlchemistInitializationParams} from "../../interfaces/IAlchemistV3.sol";
import {ITransmuter} from "../../interfaces/ITransmuter.sol";
contract AlchemistV3_TVLDrift is Test {
AlchemistV3 alchemist;
Transmuter transmuter;
AlchemicTokenV3 alToken;
AlchemistV3Position positionNFT;
AlchemistTokenVault feeVault;
MockMYTVault vault;
MockMYTStrategy strategy;
address admin = address(0xA11CE);
address curator = address(0xC0DE);
address user = address(0xD00D);
address feeReceiver = address(0xFEE);
uint256 timeToTransmute = 8;
uint256 protocolFeeBps = 100; // 1%
uint256 repayFeeBps = 200; // 2%
uint256 liquidatorFeeBps = 300; // 3%
uint256 minColl = 12e17; // 1.2
uint256 lowerBound = 11e17; // 1.1
function setUp() public {
address underlying = address(new TestERC20(2_000_000 ether, 18));
vault = new MockMYTVault(admin, underlying);
strategy = MYTTestHelper._setupStrategy(address(vault), address(new MockYieldToken(underlying)), admin, "Mock", "MockProtocol", IMYTStrategy.RiskClass.LOW);
// curator + adapter + caps
vm.prank(admin);
vault.setCurator(curator);
bytes memory idData = strategy.getIdData();
vm.startPrank(curator);
vault.submit(abi.encodeCall(IVaultV2.addAdapter, address(strategy)));
vault.addAdapter(address(strategy));
vault.submit(abi.encodeCall(IVaultV2.increaseAbsoluteCap, (idData, 1e27)));
vault.increaseAbsoluteCap(idData, 1e27);
vault.submit(abi.encodeCall(IVaultV2.increaseRelativeCap, (idData, 1e18)));
vault.increaseRelativeCap(idData, 1e18);
vm.stopPrank();
// seed
address seeder = address(0x5EED);
deal(vault.asset(), seeder, 1_500_000 ether);
vm.startPrank(seeder);
IERC20(vault.asset()).approve(address(vault), type(uint256).max);
vault.deposit(1_000_000 ether, seeder);
vm.stopPrank();
// tokens + transmuter
alToken = new AlchemicTokenV3("alUSD", "alUSD", 0);
ITransmuter.TransmuterInitializationParams memory tp = ITransmuter.TransmuterInitializationParams({
syntheticToken: address(alToken),
feeReceiver: feeReceiver,
timeToTransmute: timeToTransmute,
transmutationFee: 10,
exitFee: 20,
graphSize: 1_000_000
});
transmuter = new Transmuter(tp);
// alchemist proxy
AlchemistV3 logic = new AlchemistV3();
AlchemistInitializationParams memory ap = AlchemistInitializationParams({
admin: admin,
debtToken: address(alToken),
underlyingToken: vault.asset(),
depositCap: type(uint256).max,
minimumCollateralization: minColl,
collateralizationLowerBound: lowerBound,
globalMinimumCollateralization: minColl,
transmuter: address(transmuter),
protocolFee: protocolFeeBps,
protocolFeeReceiver: feeReceiver,
liquidatorFee: liquidatorFeeBps,
repaymentFee: repayFeeBps,
myt: address(vault)
});
bytes memory data = abi.encodeWithSelector(AlchemistV3.initialize.selector, ap);
TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy(address(logic), address(this), data);
alchemist = AlchemistV3(address(proxy));
// bindings
alToken.setWhitelist(address(alchemist), true);
transmuter.setAlchemist(address(alchemist));
transmuter.setDepositCap(type(uint128).max);
positionNFT = new AlchemistV3Position(address(alchemist));
vm.prank(admin);
alchemist.setAlchemistPositionNFT(address(positionNFT));
feeVault = new AlchemistTokenVault(vault.asset(), address(alchemist), admin);
vm.prank(admin);
alchemist.setAlchemistFeeVault(address(feeVault));
// fund user
deal(vault.asset(), user, 200_000 ether);
vm.startPrank(user);
IERC20(vault.asset()).approve(address(vault), type(uint256).max);
vault.deposit(50_000 ether, user);
IERC20(address(vault)).approve(address(alchemist), type(uint256).max);
vm.stopPrank();
}
function _createUserPosition(uint256 depositShares, uint256 debtToMint) internal returns (uint256 tokenId) {
vm.startPrank(user);
alchemist.deposit(depositShares, user, 0);
tokenId = positionNFT.tokenOfOwnerByIndex(user, 0);
alchemist.approveMint(tokenId, user, debtToMint);
alchemist.mintFrom(tokenId, debtToMint, user);
vm.roll(block.number + 1);
vm.stopPrank();
}
function _createAndMatureRedemption(uint256 amount) internal returns (uint256 id) {
vm.startPrank(user);
deal(address(alToken), user, amount, true);
IERC20(address(alToken)).approve(address(transmuter), type(uint256).max);
transmuter.createRedemption(amount);
uint256 idx = transmuter.balanceOf(user) - 1;
id = transmuter.tokenOfOwnerByIndex(user, idx);
vm.stopPrank();
vm.roll(block.number + timeToTransmute + 1);
}
function _trueTVLDebt() internal view returns (uint256) {
uint256 shares = IERC20(address(vault)).balanceOf(address(alchemist));
uint256 underlying = alchemist.convertYieldTokensToUnderlying(shares);
return alchemist.normalizeUnderlyingTokensToDebt(underlying);
}
function _trueTVLUnderlying() internal view returns (uint256) {
uint256 shares = IERC20(address(vault)).balanceOf(address(alchemist));
return alchemist.convertYieldTokensToUnderlying(shares);
}
function testTVLOverstatementAfterForceRepay() public {
// 1) Create position: deposit 20k shares, mint 12k debt (ratio ~1.666x)
uint256 tokenA = _createUserPosition(20_000 ether, 12_000 ether);
// 2) Small redemption to generate earmarks without wiping debt
_createAndMatureRedemption(1_000 ether);
alchemist.poke(tokenA); // apply earmark
// 3) Force undercollateralized path so liquidate() runs and triggers _forceRepay
vm.startPrank(admin);
alchemist.setMinimumCollateralization(2_000_000_000_000_000_000); // 2.0
alchemist.setCollateralizationLowerBound(2_000_000_000_000_000_000);
vm.stopPrank();
// 4) Capture reported vs true TVL before
uint256 reportedBefore = alchemist.getTotalUnderlyingValue();
uint256 trueBefore = _trueTVLDebt();
uint256 transmuterBefore = IERC20(address(vault)).balanceOf(address(transmuter));
// 5) Liquidate (permissionless); expect _forceRepay to send MYT to transmuter
(uint256 yPaid,,) = alchemist.liquidate(tokenA);
uint256 transmuterAfter = IERC20(address(vault)).balanceOf(address(transmuter));
// 6) Capture after
uint256 reportedAfter = alchemist.getTotalUnderlyingValue();
uint256 trueAfter = _trueTVLDebt();
// Sanity: MYT moved to Transmuter
assertGt(transmuterAfter, transmuterBefore, "no MYT sent to transmuter");
assertGt(yPaid, 0, "no yield paid by liquidate");
// Core check: reported TVL unchanged while actual MYT balance decreased
console2.log("reportedBefore", reportedBefore);
console2.log("reportedAfter", reportedAfter);
console2.log("trueBefore", trueBefore);
console2.log("trueAfter", trueAfter);
assertEq(reportedAfter, reportedBefore, "reported TVL should not change (uses _mytSharesDeposited)");
assertLt(trueAfter, trueBefore, "actual MYT balance decreased");
// Drift must be positive
uint256 drift = reportedAfter - trueAfter;
console2.log("tvl drift (debt units)", drift);
assertGt(drift, 0, "no TVL drift observed");
}
function testUnderScaledRedemptionDueToInflatedDenominator() public {
// Configure zero transmutation fee to simplify accounting of claim output
transmuter.setTransmutationFee(0);
// 1) Create position with ample collateral and induce drift via liquidation-triggered _forceRepay
uint256 tokenA = _createUserPosition(20_000 ether, 12_000 ether);
_createAndMatureRedemption(1_000 ether);
alchemist.poke(tokenA);
vm.startPrank(admin);
alchemist.setMinimumCollateralization(2_000_000_000_000_000_000); // 2.0
alchemist.setCollateralizationLowerBound(2_000_000_000_000_000_000);
vm.stopPrank();
(uint256 yPaid,,) = alchemist.liquidate(tokenA);
assertGt(yPaid, 0, "expected some yield paid by _forceRepay");
// 2) Compute used vs true badDebtRatio pre-claim
uint256 transmuterShares = IERC20(address(vault)).balanceOf(address(transmuter));
uint256 usedDenomUnderlying = alchemist.getTotalUnderlyingValue() + alchemist.convertYieldTokensToUnderlying(transmuterShares);
uint256 trueDenomUnderlying = _trueTVLUnderlying() + alchemist.convertYieldTokensToUnderlying(transmuterShares);
uint256 ts = alchemist.totalSyntheticsIssued();
// 3) Raise totalSyntheticsIssued until usedR <= 1e18 and trueR > 1e18 to demonstrate under-scaling
// Target ts' just below usedDenom to keep usedR <= 1 while ensuring ts' > trueDenom so trueR > 1
uint256 targetTs = usedDenomUnderlying > 1 ? usedDenomUnderlying - 1 : usedDenomUnderlying;
if (targetTs > ts) {
uint256 need = targetTs - ts;
uint256 maxBorrow = alchemist.getMaxBorrowable(tokenA);
if (need > maxBorrow) need = maxBorrow; // clamp to available capacity
if (need > 0) {
vm.startPrank(user);
alchemist.approveMint(tokenA, user, need);
alchemist.mintFrom(tokenA, need, user);
vm.stopPrank();
ts = alchemist.totalSyntheticsIssued();
}
}
// Recompute ratios after mint adjustment
uint256 usedR = (ts * 1e18) / (usedDenomUnderlying == 0 ? 1 : usedDenomUnderlying);
uint256 trueR = (ts * 1e18) / (trueDenomUnderlying == 0 ? 1 : trueDenomUnderlying);
console2.log("usedR", usedR);
console2.log("trueR", trueR);
// With inflated denominator, the used ratio is strictly less than the true ratio
assertLt(usedR, trueR, "usedR should be strictly less than true ratio");
// 4) Create a small matured redemption R that can be fully paid from transmuter balance (no redeem call)
// Choose 500 debt units
uint256 r = _createAndMatureRedemption(500 ether);
alchemist.poke(tokenA);
// Ensure transmuter has enough MYT to pay this without redeeming from Alchemist
uint256 preTransmuterShares = IERC20(address(vault)).balanceOf(address(transmuter));
assertGe(preTransmuterShares, alchemist.convertDebtTokensToYield(500 ether), "insufficient transmuter balance to isolate scaling");
// 4) Claim and measure actual paid debt (via MYT delta converted to debt units)
uint256 userSharesBefore = IERC20(address(vault)).balanceOf(user);
vm.prank(user);
transmuter.claimRedemption(r);
uint256 userSharesAfter = IERC20(address(vault)).balanceOf(user);
uint256 debtClaimed = alchemist.convertYieldTokensToDebt(userSharesAfter - userSharesBefore);
// 5) Compute the debt the user would receive under the true ratio
uint256 amount = 500 ether;
// In healthy conditions (trueR <= 1e18), no scaling applies; claim equals requested amount
uint256 expectedDebtTrue = amount;
console2.log("debtClaimed", debtClaimed);
console2.log("expectedDebtTrue", expectedDebtTrue);
// Assert no scaling occurs; demonstrates that inflated denominator lowers the ratio and delays scaling activation
assertEq(debtClaimed, expectedDebtTrue, "unexpected scaling occurred");
}
// Supporting observation: liquidation sizing uses inflated global ratio input.
function testLiquidationDecisionUsesInflatedGlobalRatio_supporting() public {
// 1) Setup and induce drift via liquidation-triggered _forceRepay
uint256 tokenA = _createUserPosition(20_000 ether, 12_000 ether);
_createAndMatureRedemption(1_000 ether);
alchemist.poke(tokenA);
vm.startPrank(admin);
alchemist.setMinimumCollateralization(2_000_000_000_000_000_000); // 2.0
alchemist.setCollateralizationLowerBound(2_000_000_000_000_000_000);
vm.stopPrank();
(uint256 yPaid,,) = alchemist.liquidate(tokenA);
assertGt(yPaid, 0, "expected some yield paid by _forceRepay");
// 2) Gather account and global values
( , uint256 acctDebt, ) = alchemist.getCDP(tokenA);
uint256 acctCollateralDebtUnits = alchemist.totalValue(tokenA); // already in debt units
uint256 totalDebt = alchemist.totalDebt();
uint256 usedUnderlying = alchemist.getTotalUnderlyingValue();
uint256 trueUnderlying = _trueTVLUnderlying();
uint256 usedGlobalM = alchemist.normalizeUnderlyingTokensToDebt(usedUnderlying) * 1e18 / (totalDebt == 0 ? 1 : totalDebt);
uint256 trueGlobalM = alchemist.normalizeUnderlyingTokensToDebt(trueUnderlying) * 1e18 / (totalDebt == 0 ? 1 : totalDebt);
console2.log("usedGlobalM", usedGlobalM);
console2.log("trueGlobalM", trueGlobalM);
// With inflated TVL, usedGlobalM should be strictly greater than trueGlobalM
assertGt(usedGlobalM, trueGlobalM, "expected usedGlobalM > trueGlobalM");
// 3) Choose a globalMinimumCollateralization between trueGlobalM and usedGlobalM to demonstrate
// a decision boundary difference (full vs partial liquidation) purely due to inflated ratio input.
uint256 mid = (usedGlobalM + trueGlobalM) / 2;
// Ensure it satisfies contract constraint: globalMinimumCollateralization >= minimumCollateralization
uint256 minM = alchemist.minimumCollateralization();
if (mid < minM) {
mid = minM;
}
vm.prank(admin);
alchemist.setGlobalMinimumCollateralization(mid);
// 4) Compute liquidation outcomes under used vs true global ratio
(uint256 grossUsed, uint256 debtBurnUsed, uint256 feeUsed, ) = alchemist.calculateLiquidation(
acctCollateralDebtUnits,
acctDebt,
alchemist.minimumCollateralization(),
usedGlobalM,
alchemist.globalMinimumCollateralization(),
alchemist.liquidatorFee()
);
(uint256 grossTrue, uint256 debtBurnTrue, uint256 feeTrue, ) = alchemist.calculateLiquidation(
acctCollateralDebtUnits,
acctDebt,
alchemist.minimumCollateralization(),
trueGlobalM,
alchemist.globalMinimumCollateralization(),
alchemist.liquidatorFee()
);
console2.log("debtBurnUsed", debtBurnUsed);
console2.log("debtBurnTrue", debtBurnTrue);
console2.log("grossUsed", grossUsed);
console2.log("grossTrue", grossTrue);
console2.log("feeUsed", feeUsed);
console2.log("feeTrue", feeTrue);
// Expect: with inflated usedGlobalM >= mid (boundary), the used path avoids full liquidation
// while the true path (trueGlobalM < mid) triggers full liquidation of account debt.
assertLt(debtBurnUsed, acctDebt, "used path should not fully liquidate");
assertEq(debtBurnTrue, acctDebt, "true path should fully liquidate");
}
}
Output
PoC 1: TVL overstatement after _forceRepay/liquidation
- Asserts reported TVL (via
_mytSharesDeposited) stays constant while true MYT holdings decrease; prints a positive “tvl drift”.
[PASS] testTVLOverstatementAfterForceRepay() (gas: 2620261)
Logs:
reportedBefore 20000000000000000000000
reportedAfter 20000000000000000000000
trueBefore 20000000000000000000000
trueAfter 15500600000000000000002
tvl drift (debt units) 4499399999999999999998
PoC 2: Depressed ratio delays scaling in claimRedemption()
- Shows
usedR < trueRafter drift. A small claim remains unscaled, evidencing that the inflated denominator depresses the ratio and delays scaling activation.
[PASS] testUnderScaledRedemptionDueToInflatedDenominator() (gas: 3438499)
Logs:
usedR 494851482698755036
trueR 607585707558872523
debtClaimed 500000000000000000000
expectedDebtTrue 500000000000000000000
Supporting observation
- Demonstrates that liquidation sizing based on an inflated global collateralization can under‑liquidate compared to the “true” input computed from actual MYT holdings.
[PASS] testLiquidationDecisionUsesInflatedGlobalRatio_supporting() (gas: 2760666)
Logs:
usedGlobalM 2580545269215385210
trueGlobalM 1999999999999999999
debtBurnUsed 232509000000000000000
debtBurnTrue 7750300000000000000001
grossUsed 465018000000000000000
grossTrue 7750300000000000000001
feeUsed 232509000000000000000
feeTrue 0