Skip to content

Instantly share code, notes, and snippets.

@CharaD7
Created September 8, 2026 20:03
Show Gist options
  • Select an option

  • Save CharaD7/ed179dde00c75c0a19aabc2eda06ba73 to your computer and use it in GitHub Desktop.

Select an option

Save CharaD7/ed179dde00c75c0a19aabc2eda06ba73 to your computer and use it in GitHub Desktop.
TermMax V2 — INVESTIGATION (NULL RESULT): bad-debt not written down to totalAssets() does NOT reproduce as protocol insolvency; vault stays solvent. Includes trace + repro tests.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.27;
//
// TermMax / Term Structure Labs -- INVESTIGATION (NULL RESULT). Do NOT submit.
// ========================================================================
// Candidate : Protocol insolvency via bad debt never written down to totalAssets()
// Contract : contracts/v2/vault/TermMaxVaultV2.sol + contracts/v2/vault/OrderManagerV2.sol
// Result : The accounting asymmetry is REAL, but the end-to-end insolvency / depositor
// theft DOES NOT REPRODUCE. The vault remains solvent. => Medium, not CRIT/HIGH.
//
// The two test functions below were added to the repo harness test/v2/VaultV2.t.sol and are
// included for completeness / to show we did not assume. They PASS, i.e. the vault is NOT
// insolvent in the scenario.
//
// Run: forge test --isolate --match-contract VaultTestV2 --match-test testBadDebtInsolvency -vv
// Requires the repo setup (test/v2/utils/DeployUtils, JSONLoader, LoanUtils, testdata.json).
//
// CONFIRMED accounting flaw: redeemOrder records bad debt but totalAssets() is unchanged.
function testBadDebtInsolvencyProof() public {
vm.warp(currentTime + 2 days);
buyXt(48.219178e8, 1000e8);
vm.warp(currentTime + 3 days);
address lper2 = vm.randomAddress();
uint256 amount2 = 10000e8;
res.debt.mint(lper2, amount2);
vm.startPrank(lper2);
res.debt.approve(address(vault), amount2);
vault.deposit(amount2, lper2);
vm.stopPrank();
address borrower = vm.randomAddress();
vm.startPrank(borrower);
LoanUtils.fastMintGt(res, borrower, 200000e8, 300000e18); // large default
vm.stopPrank();
vm.warp(marketConfig.maturity + 1 days);
uint256 P_before = vault.totalAssets();
vm.prank(curator);
vault.redeemOrder(res.order);
uint256 P_after = vault.totalAssets();
uint256 badDebt = vault.badDebtMapping(address(res.collateral));
uint256 vaultDebt = res.debt.balanceOf(address(vault));
console.log("P(before redeemOrder) =", P_before);
console.log("P(after redeemOrder) =", P_after);
console.log("badDebt (recorded) =", badDebt);
console.log("vault debt.balanceOf =", vaultDebt);
uint256 shares = vault.balanceOf(lper2);
uint256 amt = vault.previewRedeem(shares);
console.log("early redeemer previewRedeem =", amt);
vm.startPrank(lper2);
uint256 received = vault.redeem(shares, lper2, lper2);
vm.stopPrank();
console.log("received by early redeemer =", received);
console.log("vault debt.balanceOf after =", res.debt.balanceOf(address(vault)));
console.log("vault totalAssets() after =", vault.totalAssets());
assertTrue(badDebt > 0, "bad debt not recorded");
assertTrue(P_after == P_before, "redeemOrder did not write down totalAssets");
}
// NULL RESULT: even after a bad-debt write-down, the remaining holder redeems fine.
function testBadDebtInsolvencyRemainingHolder() public {
vm.warp(currentTime + 2 days);
buyXt(48.219178e8, 1000e8);
address lper2 = vm.randomAddress();
uint256 amount2 = 20000e8;
res.debt.mint(lper2, amount2);
vm.startPrank(lper2);
res.debt.approve(address(vault), amount2);
vault.deposit(amount2, lper2);
vm.stopPrank();
address borrower = vm.randomAddress();
vm.startPrank(borrower);
LoanUtils.fastMintGt(res, borrower, 200000e8, 300000e18);
vm.stopPrank();
vm.warp(marketConfig.maturity + 1 days);
vm.prank(curator);
vault.redeemOrder(res.order);
uint256 earlyShares = vault.balanceOf(lper2);
vm.startPrank(lper2);
vault.redeem(earlyShares, lper2, lper2);
vm.stopPrank();
uint256 remainingShares = vault.balanceOf(deployer);
uint256 remainingPreview = vault.previewRedeem(remainingShares);
console.log("remaining holder shares =", remainingShares, " previewRedeem =", remainingPreview);
console.log("vault debt.balanceOf after early exit =", res.debt.balanceOf(address(vault)));
console.log("vault totalAssets() after early exit =", vault.totalAssets());
vm.startPrank(deployer);
try vault.redeem(remainingShares, deployer, deployer) {
console.log("remaining holder redeemed OK (NOT insolvent)");
} catch { console.log("remaining holder REVERTED (insolvent)"); }
vm.stopPrank();
}
}
//
// Observed (small default AND 200x-scaled default):
// P(before redeemOrder) = 2002410958900
// P(after redeemOrder) = 2002410958900 // totalAssets() UNCHANGED (overstatement exists)
// badDebt (recorded) = 1000000 // tiny vs the vault; does NOT scale with loan size
// vault debt.balanceOf = 2004820917800 // real backing >> book value
// received by early redeemer = 1001191764494 // full value
// vault totalAssets() after = 1001219194406
// remaining holder redeemed OK (NOT insolvent)
//
// WHY: the vault's realizable asset (debt.balanceOf) exceeds totalAssets() by a wide margin, so
// the recorded badDebt is orders of magnitude below the solvency buffer. redeemOrder is
// onlyCuratorRole and dealBadDebt (the write-down) is permissionless, so there is no persistent
// exploitable window. CONCLUSION: Medium (transient share-price overstatement), NOT CRIT/HIGH.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment