Created
September 8, 2026 20:03
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // 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