// SPDX-License-Identifier: MIT pragma solidity ^0.8.30; import {Initializable} from "@openzeppelin-contracts-upgradeable-5.6.1/proxy/utils/Initializable.sol"; import {AccessControlUpgradeable} from "@openzeppelin-contracts-upgradeable-5.6.1/access/AccessControlUpgradeable.sol"; import {UUPSUpgradeable} from "@openzeppelin-contracts-upgradeable-5.6.1/proxy/utils/UUPSUpgradeable.sol"; import {ReentrancyGuardTransient} from "@openzeppelin-contracts-5.6.1/utils/ReentrancyGuardTransient.sol"; import {IERC20} from "@openzeppelin-contracts-5.6.1/token/ERC20/IERC20.sol"; import {IERC20Metadata} from "@openzeppelin-contracts-5.6.1/token/ERC20/extensions/IERC20Metadata.sol"; import {SafeERC20} from "@openzeppelin-contracts-5.6.1/token/ERC20/utils/SafeERC20.sol"; import {Clones} from "@openzeppelin-contracts-5.6.1/proxy/Clones.sol"; import {Math} from "@openzeppelin-contracts-5.6.1/utils/math/Math.sol"; import {ILoanManager} from "./interfaces/ILoanManager.sol"; import {IFeeRegistry} from "./interfaces/IFeeRegistry.sol"; import {ICollateralManager} from "./interfaces/ICollateralManager.sol"; import {APNImplementation} from "./APNImplementation.sol"; import {ExecutedTerms} from "./types/DataTypes.sol"; import {Constants} from "./libraries/Constants.sol"; import {InterestMath} from "./libraries/InterestMath.sol"; import {LtvMath} from "./libraries/LtvMath.sol"; import {OracleLib} from "./libraries/OracleLib.sol"; import {AuctionMath} from "./libraries/AuctionMath.sol"; import {Roles} from "./libraries/Roles.sol"; /// @title LoanManager /// @notice Singleton that manages all loans on the principal chain. On execution it moves the /// principal from the lender to the borrower (net of the origination fee, which goes to the /// treasury), records the loan with its collateral parameters denormalized, snapshots the /// interest take rate, and mints the lender an APN. /// @dev UUPS-upgradeable; upgrades are gated by `UPGRADER_ROLE` (a Timelock in production). The Hub /// is wired once via {setHub}; the FeeRegistry and APN template are fixed at initialization and /// can only change through an upgrade. Storage uses ERC-7201. contract LoanManager is ILoanManager, Initializable, AccessControlUpgradeable, UUPSUpgradeable, ReentrancyGuardTransient { using SafeERC20 for IERC20; using Clones for address; /// @custom:storage-location erc7201:amish.storage.LoanManager struct LoanManagerStorage { mapping(bytes32 matchId => Loan) loans; address hub; address feeRegistry; address apnImplementation; address collateralManager; // Liquidation state, declared last so the preceding layout is never shifted across upgrades. mapping(bytes32 matchId => Auction) auctions; RiskParams risk; } // keccak256(abi.encode(uint256(keccak256("amish.storage.LoanManager")) - 1)) & ~bytes32(uint256(0xff)) bytes32 private constant STORAGE_LOCATION = 0x176143601c23619fa9e6d00dff558ebd44b17fb15cc48c460fe77cd55f46ee00; // --- Risk-parameter bounds (govern how far the admin can tune the auction) --- // The floors on durations stop the admin from collapsing the grace/auction to zero (which would // make a position instantly seizable at max bonus); the caps keep the levers in a sane range. uint32 internal constant MIN_AUCTION_DURATION = 5 minutes; uint32 internal constant MAX_AUCTION_DURATION = 7 days; uint32 internal constant MIN_GRACE_PERIOD = 1 hours; uint32 internal constant MAX_GRACE_PERIOD = 30 days; uint16 internal constant MAX_B_MIN = 500; // 5% uint16 internal constant MIN_HF_FLOOR = 5_000; // 0.50 uint16 internal constant MAX_HF_FLOOR = 9_900; // 0.99 (< BPS so Zone-3 distress never divides by zero) uint16 internal constant MAX_RESTORE_BUFFER = 2_000; // 20% event LoanCreated( bytes32 indexed matchId, address indexed borrower, address indexed lender, address apn, address principalToken, uint256 principalAmount, uint256 originationFee ); event LoanRepaid( bytes32 indexed matchId, address indexed payer, uint256 interestPortion, uint256 protocolFee, uint256 principalPortion, uint256 outstandingPrincipal ); event LoanClosed(bytes32 indexed matchId); event CollateralIncreased(bytes32 indexed matchId, uint256 amount, uint256 newAmount); event MarginCallTriggered(bytes32 indexed matchId, uint256 triggeredAt, uint256 currentLtvBps); event MarginCallCleared(bytes32 indexed matchId); event LiquidationTriggered(bytes32 indexed matchId, uint256 triggeredAt, uint256 debt, uint16 maxBonusBps); event LiquidationCleared(bytes32 indexed matchId); event LoanLiquidated( bytes32 indexed matchId, address indexed liquidator, bool isPartial, uint256 repaid, uint256 collateralSeized, uint256 bonusBps ); event LoanWrittenDown(bytes32 indexed matchId, uint256 shortfall); event RiskParamsUpdated(RiskParams params); event HubSet(address indexed hub); event CollateralManagerSet(address indexed collateralManager); error LoanManager_ZeroAddress(); error LoanManager_NotAContract(); error LoanManager_NotHub(); error LoanManager_NotCollateralManager(); error LoanManager_HubAlreadySet(); error LoanManager_CollateralManagerAlreadySet(); error LoanManager_WrongChain(); error LoanManager_LoanExists(bytes32 matchId); error LoanManager_NoSuchLoan(bytes32 matchId); error LoanManager_LoanClosed(bytes32 matchId); error LoanManager_ZeroAmount(); error LoanManager_NotInMarginCallZone(); error LoanManager_AlreadyMarginCalled(); error LoanManager_NotMarginCalled(); error LoanManager_GracePeriodActive(); error LoanManager_NotLiquidatable(); error LoanManager_AlreadyTriggered(); error LoanManager_NotTriggered(); error LoanManager_StillInMarginCallZone(); error LoanManager_StillLiquidatable(); error LoanManager_MaturityDefault(); error LoanManager_SeizeExceedsCollateral(uint256 required, uint256 available); error LoanManager_NothingToLiquidate(); error LoanManager_SlippageExceeded(uint256 required, uint256 maxRepay); error LoanManager_SeizeBelowMinimum(uint256 seized, uint256 minSeized); error LoanManager_InvalidRiskParam(); modifier onlyHub() { if (msg.sender != _getStorage().hub) revert LoanManager_NotHub(); _; } modifier onlyCollateralManager() { if (msg.sender != _getStorage().collateralManager) revert LoanManager_NotCollateralManager(); _; } constructor() { _disableInitializers(); } /// @param admin Holds DEFAULT_ADMIN_ROLE. Wires the AmishHub once via {setHub} after deployment. /// @param upgrader Holds UPGRADER_ROLE (a Timelock in production). /// @param feeRegistry_ The FeeRegistry read at execution. Fixed here; upgrade to change. /// @param apnImplementation_ The APN template cloned per loan. Fixed here; upgrade to change. function initialize(address admin, address upgrader, address feeRegistry_, address apnImplementation_) external initializer { if (admin == address(0) || upgrader == address(0)) revert LoanManager_ZeroAddress(); if (feeRegistry_.code.length == 0 || apnImplementation_.code.length == 0) revert LoanManager_NotAContract(); __AccessControl_init(); _grantRole(DEFAULT_ADMIN_ROLE, admin); _grantRole(Roles.UPGRADER_ROLE, upgrader); LoanManagerStorage storage $ = _getStorage(); $.feeRegistry = feeRegistry_; $.apnImplementation = apnImplementation_; // Default risk parameters; tunable within bounds via {setRiskParams}. $.risk = RiskParams({ auctionDuration: 60 minutes, marginCallDeadline: 72 hours, maturityGracePeriod: 72 hours, bMin: 50, // 0.50% hfFloor: 8_500, // 0.85 restoreBuffer: 200 // 2% }); } // --- admin: wiring & risk parameters --------------------------------- /// @notice Replace the global liquidation risk parameters. Restricted to `RISK_MANAGER_ROLE`. /// Every field is range-checked: durations have floors so grace/auction windows can never /// collapse to zero, and the bonus/threshold levers are capped. What makes a specific loan /// liquidatable (its MCT/LT/maxLiquidationBonus) is fixed in the signed intent and is not /// reachable here. function setRiskParams(RiskParams calldata params) external onlyRole(Roles.RISK_MANAGER_ROLE) { if (params.auctionDuration < MIN_AUCTION_DURATION || params.auctionDuration > MAX_AUCTION_DURATION) { revert LoanManager_InvalidRiskParam(); } if (params.marginCallDeadline < MIN_GRACE_PERIOD || params.marginCallDeadline > MAX_GRACE_PERIOD) { revert LoanManager_InvalidRiskParam(); } if (params.maturityGracePeriod < MIN_GRACE_PERIOD || params.maturityGracePeriod > MAX_GRACE_PERIOD) { revert LoanManager_InvalidRiskParam(); } if (params.bMin > MAX_B_MIN) revert LoanManager_InvalidRiskParam(); if (params.hfFloor < MIN_HF_FLOOR || params.hfFloor > MAX_HF_FLOOR) revert LoanManager_InvalidRiskParam(); if (params.restoreBuffer > MAX_RESTORE_BUFFER) revert LoanManager_InvalidRiskParam(); _getStorage().risk = params; emit RiskParamsUpdated(params); } /// @notice Wire the AmishHub. Callable exactly once; afterwards frozen (upgrade to change). The /// one-time set prevents the admin from ever granting the execution path to any address. function setHub(address newHub) external onlyRole(DEFAULT_ADMIN_ROLE) { if (newHub == address(0)) revert LoanManager_ZeroAddress(); if (newHub.code.length == 0) revert LoanManager_NotAContract(); LoanManagerStorage storage $ = _getStorage(); if ($.hub != address(0)) revert LoanManager_HubAlreadySet(); $.hub = newHub; emit HubSet(newHub); } /// @notice Wire the CollateralManager. Callable exactly once; afterwards frozen (upgrade to /// change). This is the vault this manager releases collateral from once a loan is /// repaid; the reverse wire (CollateralManager -> this) lets add-collateral stay in sync. function setCollateralManager(address newCollateralManager) external onlyRole(DEFAULT_ADMIN_ROLE) { if (newCollateralManager == address(0)) revert LoanManager_ZeroAddress(); if (newCollateralManager.code.length == 0) revert LoanManager_NotAContract(); LoanManagerStorage storage $ = _getStorage(); if ($.collateralManager != address(0)) revert LoanManager_CollateralManagerAlreadySet(); $.collateralManager = newCollateralManager; emit CollateralManagerSet(newCollateralManager); } // --- lifecycle: execution & servicing -------------------------------- /// @inheritdoc ILoanManager function execute(bytes32 matchId, ExecutedTerms calldata terms, string calldata apnName, string calldata apnSymbol) external onlyHub nonReentrant returns (address apn) { LoanManagerStorage storage $ = _getStorage(); if (terms.principalChainId != block.chainid) revert LoanManager_WrongChain(); // principal is issued on this chain if ($.loans[matchId].borrower != address(0)) revert LoanManager_LoanExists(matchId); (uint16 originationFeeBps, uint16 interestTakeRateBps) = IFeeRegistry($.feeRegistry).feesFor(terms.principalToken); address treasury = IFeeRegistry($.feeRegistry).treasury(); uint256 originationFee = Math.mulDiv(terms.principalAmount, originationFeeBps, Constants.BPS, Math.Rounding.Floor); // Read the principal token's decimals once and denormalize it into the loan, so later health // and liquidation math never has to make an external call for this constant (mirrors how the // collateral decimals are denormalized). uint8 principalDecimals = IERC20Metadata(terms.principalToken).decimals(); // Effects: record the loan at the deterministic APN address before any external interaction. apn = $.apnImplementation.predictDeterministicAddress(matchId, address(this)); _recordLoan($, matchId, terms, apn, interestTakeRateBps, principalDecimals); // Interactions: deploy + mint the APN, then move principal. address deployed = _createApn($, matchId, terms, apnName, apnSymbol, principalDecimals); assert(deployed == apn); // cloneDeterministic lands at the predicted address _distributePrincipal(terms, treasury, originationFee); emit LoanCreated( matchId, terms.borrower, terms.lender, apn, terms.principalToken, terms.principalAmount, originationFee ); } /// @inheritdoc ILoanManager function repay(bytes32 matchId, uint256 amount) external nonReentrant returns (uint256 interestPortion, uint256 principalPortion) { LoanManagerStorage storage $ = _getStorage(); Loan storage loan = _activeLoan($, matchId); if (amount == 0) revert LoanManager_ZeroAmount(); // Interest bills live in every case — opening an auction never touches `lastAccrualTimestamp` — // so servicing a loan mid-auction costs exactly what servicing a healthy one does. The trigger // is permissionless; anything less would let a borrower open their own auction to stop the clock. uint256 protocolFee; uint256 outstanding; (interestPortion, protocolFee, principalPortion, outstanding) = _applyWaterfall($, loan, amount); emit LoanRepaid(matchId, msg.sender, interestPortion, protocolFee, principalPortion, outstanding); // Fully repaid: close atomically — remediation state dropped, collateral back to the borrower. if (outstanding == 0) { _closeLoan($, matchId); return (interestPortion, principalPortion); } // A partial repay may have restored the position; clear whatever remediation state it cured // (a recovered margin call, a recovered pre-maturity liquidation trigger) so the borrower is // not left holding a stale, fully-elapsed grace/bonus clock. A repay that cures nothing leaves // the clocks alone: a dust repay must not stall a live auction by restarting its ramp, and a // liquidator simply settles the now-reduced live debt. _tryClearRemediation($, loan, matchId); } // --- risk, Zone 2: margin call & partial liquidation ------------------ /// @inheritdoc ILoanManager function triggerMarginCall(bytes32 matchId) external nonReentrant { LoanManagerStorage storage $ = _getStorage(); Loan storage loan = _activeLoan($, matchId); Auction storage auction = $.auctions[matchId]; if (auction.liquidationAt != 0) revert LoanManager_AlreadyTriggered(); if (auction.marginCallAt != 0) revert LoanManager_AlreadyMarginCalled(); (,, uint256 ltv) = _valueAndLtv(loan, _liveDebt(loan)); // Only Zone 2: at/above MCT but below LT (Zone 3 goes straight to {triggerLiquidation}). if (ltv < loan.marginCallThreshold || ltv >= loan.liquidationThreshold) { revert LoanManager_NotInMarginCallZone(); } auction.marginCallAt = uint64(block.timestamp); emit MarginCallTriggered(matchId, block.timestamp, ltv); } /// @inheritdoc ILoanManager /// @dev Repay- and top-up-driven recoveries clear automatically ({_tryClearRemediation}), but a /// purely price-driven recovery writes no transaction, so nothing on-chain can observe it. /// Left uncleared, the stale timestamp would let a re-dip into Zone 2 be liquidated with no /// fresh grace — the borrower or the off-chain monitor calls this on every price recovery. function clearMarginCall(bytes32 matchId) external nonReentrant { LoanManagerStorage storage $ = _getStorage(); Loan storage loan = _existingLoan($, matchId); Auction storage auction = $.auctions[matchId]; if (auction.marginCallAt == 0) revert LoanManager_NotMarginCalled(); // Only clear once the position has genuinely recovered below the margin-call threshold, so a // later re-crossing starts a fresh grace window. (,, uint256 ltv) = _valueAndLtv(loan, _liveDebt(loan)); if (ltv >= loan.marginCallThreshold) revert LoanManager_StillInMarginCallZone(); auction.marginCallAt = 0; emit MarginCallCleared(matchId); } /// @inheritdoc ILoanManager function liquidatePartial(bytes32 matchId, uint256 repayAmount, uint256 minSeized) external nonReentrant returns (uint256 repaid, uint256 collateralSeized) { LoanManagerStorage storage $ = _getStorage(); Loan storage loan = _activeLoan($, matchId); Auction storage auction = $.auctions[matchId]; if (auction.liquidationAt != 0) revert LoanManager_AlreadyTriggered(); if (auction.marginCallAt == 0) revert LoanManager_NotMarginCalled(); if (block.timestamp < uint256(auction.marginCallAt) + $.risk.marginCallDeadline) { revert LoanManager_GracePeriodActive(); } uint256 debt = _liveDebt(loan); (uint256 collValue, uint256 normalizedPrice, uint256 ltv) = _valueAndLtv(loan, debt); // Must still be in Zone 2: below MCT the position has recovered (clear it via {clearMarginCall}), // at/above LT it needs a full liquidation instead. Either way, refuse the partial. if (ltv < loan.marginCallThreshold || ltv >= loan.liquidationThreshold) { revert LoanManager_NotInMarginCallZone(); } uint256 bonus = _bonus($, loan, uint256(auction.marginCallAt) + $.risk.marginCallDeadline, ltv); uint16 targetLtv = loan.marginCallThreshold > $.risk.restoreBuffer ? loan.marginCallThreshold - $.risk.restoreBuffer : 0; uint256 maxRepay = AuctionMath.partialMaxRepay(debt, collValue, targetLtv, bonus); repaid = repayAmount < maxRepay ? repayAmount : maxRepay; if (repaid > debt) repaid = debt; if (repaid == 0) revert LoanManager_NothingToLiquidate(); collateralSeized = LtvMath.valueToCollateral(AuctionMath.grossUpValue(repaid, bonus), normalizedPrice); if (collateralSeized > loan.collateralAmount) { revert LoanManager_SeizeExceedsCollateral(collateralSeized, loan.collateralAmount); } // The floor is a rate quote against `repayAmount`, scaled pro-rata to the actual fill: the // engine caps `repaid` (to the live restoring maximum, or the debt), so an absolute floor // would revert every capped fill even when the caller's exchange rate held exactly. Floor // rounding is guard-lenient by at most one base unit. uint256 seizeFloor = Math.mulDiv(minSeized, repaid, repayAmount); if (collateralSeized < seizeFloor) revert LoanManager_SeizeBelowMinimum(collateralSeized, seizeFloor); // Effects: apply the repayment (interest-first, principal reduced, clock advanced) and reduce // the denormalized collateral before the external seize (checks-effects-interactions). (,,, uint256 outstanding) = _applyWaterfall($, loan, repaid); loan.collateralAmount -= collateralSeized; // Interactions: hand the seized collateral (bonus included) to the liquidator; this also // reduces the CollateralManager's deposit, keeping the two records in sync. ICollateralManager($.collateralManager).seizeCollateral(matchId, msg.sender, collateralSeized); emit LoanLiquidated(matchId, msg.sender, true, repaid, collateralSeized, bonus); // A partial that settles the entire debt (the knife edge where the LTV sits exactly at // 1/(1+bonus)) closes the loan just as {repay} would — otherwise the deposit would strand on // a closed loan that {repay} and {liquidate} both refuse to touch. if (outstanding == 0) { _closeLoan($, matchId); return (repaid, collateralSeized); } // Clear the margin call only if the position genuinely restored below MCT: a partial that // leaves it in Zone 2 keeps the elapsed grace (follow-ups need no fresh 72h wait), and a dust // repayment can no longer zero the clock to grief the lender's remedy. (, uint256 newLtv) = _ltvAtPrice(loan, _liveDebt(loan), normalizedPrice); if (newLtv < loan.marginCallThreshold) { auction.marginCallAt = 0; emit MarginCallCleared(matchId); } } // --- risk, Zone 3 & maturity default: full liquidation ---------------- /// @inheritdoc ILoanManager function triggerLiquidation(bytes32 matchId) external nonReentrant { LoanManagerStorage storage $ = _getStorage(); Loan storage loan = _activeLoan($, matchId); Auction storage auction = $.auctions[matchId]; if (auction.liquidationAt != 0) revert LoanManager_AlreadyTriggered(); uint256 debt = _liveDebt(loan); bool pastMaturity = block.timestamp >= loan.startTimestamp + loan.duration + $.risk.maturityGracePeriod; (,, uint256 ltv) = _valueAndLtv(loan, debt); if (ltv < loan.liquidationThreshold && !pastMaturity) revert LoanManager_NotLiquidatable(); // Open the auction: the reverse-Dutch bonus clock runs from here. Accrual is left untouched, // so every settlement path — repay, clear, or seizure — bills the full elapsed window; `debt` // is emitted only as an observer snapshot. auction.liquidationAt = uint64(block.timestamp); emit LiquidationTriggered(matchId, block.timestamp, debt, loan.maxLiquidationBonus); } /// @inheritdoc ILoanManager function clearLiquidation(bytes32 matchId) external nonReentrant { LoanManagerStorage storage $ = _getStorage(); Loan storage loan = _existingLoan($, matchId); Auction storage auction = $.auctions[matchId]; if (auction.liquidationAt == 0) revert LoanManager_NotTriggered(); // A maturity default is liquidatable regardless of health, so it cannot be cleared. A pure // Zone-3 trigger clears once the price recovers back below the liquidation threshold. bool pastMaturity = block.timestamp >= loan.startTimestamp + loan.duration + $.risk.maturityGracePeriod; if (pastMaturity) revert LoanManager_MaturityDefault(); (,, uint256 ltv) = _valueAndLtv(loan, _liveDebt(loan)); if (ltv >= loan.liquidationThreshold) revert LoanManager_StillLiquidatable(); // Close the auction. Accrual was never paused (`lastAccrualTimestamp` was never moved), so the // whole window the auction was open stays billed — clearing forgives nothing. auction.liquidationAt = 0; emit LiquidationCleared(matchId); } /// @inheritdoc ILoanManager function liquidate(bytes32 matchId, uint256 maxRepay, uint256 minSeized) external nonReentrant returns (uint256 repaid, uint256 collateralSeized) { LoanManagerStorage storage $ = _getStorage(); Loan storage loan = _activeLoan($, matchId); Auction storage auction = $.auctions[matchId]; if (auction.liquidationAt == 0) revert LoanManager_NotTriggered(); // Settle against the live debt: the trigger never paused accrual, so a seizure bills the full // window exactly as {repay} would — self-liquidation is never cheaper than repaying. uint256 debt = _liveDebt(loan); (uint256 collValue, uint256 normalizedPrice, uint256 ltv) = _valueAndLtv(loan, debt); // A Zone-3 trigger whose position has recovered below LT is stale: the borrower must not be // seized on it (clear it via the permissionless {clearLiquidation}). A maturity default stays // liquidatable regardless of health. bool pastMaturity = block.timestamp >= loan.startTimestamp + loan.duration + $.risk.maturityGracePeriod; if (!pastMaturity && ltv < loan.liquidationThreshold) revert LoanManager_NotLiquidatable(); // Price the bonus off the auction clock — except for a trigger that predates the maturity // default and whose Zone-3 distress has since recovered without being cleared: its clock would // arrive fully elapsed and turn the default into an instant max-bonus seizure. Re-base it to // the default deadline so the seizure prices exactly like a freshly triggered maturity-default // auction. A position still at/above its liquidation threshold keeps its genuine running clock. uint256 auctionStart = auction.liquidationAt; if (pastMaturity && ltv < loan.liquidationThreshold) { uint256 defaultAt = loan.startTimestamp + loan.duration + $.risk.maturityGracePeriod; if (auctionStart < defaultAt) auctionStart = defaultAt; } uint256 bonus = _bonus($, loan, auctionStart, ltv); uint256 available = loan.collateralAmount; uint256 desiredSeize = LtvMath.valueToCollateral(AuctionMath.grossUpValue(debt, bonus), normalizedPrice); if (desiredSeize <= available) { // Solvent: repay the full live debt, seize its grossed-up value, return the rest. repaid = debt; collateralSeized = desiredSeize; } else { // Underwater: the collateral cannot cover debt + bonus. Seize all of it and repay only the // amount it backs (collValue / (1 + bonus)); the remaining principal is written down. collateralSeized = available; repaid = Math.mulDiv(collValue, Constants.BPS, Constants.BPS + bonus, Math.Rounding.Floor); if (repaid > debt) repaid = debt; // guard rounding } if (repaid > maxRepay) revert LoanManager_SlippageExceeded(repaid, maxRepay); if (collateralSeized < minSeized) revert LoanManager_SeizeBelowMinimum(collateralSeized, minSeized); // Apply the repayment interest-first against the live legs, then write down any principal the // repayment did not cover and close the loan. _settleLiquidation($, loan, matchId, debt, repaid); // Interactions: seize, then release the remainder to the borrower and clear the deposit. A // zero seize (dust debt, or a stripped position) is skipped — seizeCollateral rejects zero // amounts, and reverting would leave an otherwise-liquidatable loan uncloseable. ICollateralManager cm = ICollateralManager($.collateralManager); if (collateralSeized != 0) cm.seizeCollateral(matchId, msg.sender, collateralSeized); cm.release(matchId); emit LoanLiquidated(matchId, msg.sender, false, repaid, collateralSeized, bonus); emit LoanClosed(matchId); } // --- collateral sync (CollateralManager-driven) ----------------------- /// @inheritdoc ILoanManager function increaseCollateral(bytes32 matchId, uint256 amount) external onlyCollateralManager { LoanManagerStorage storage $ = _getStorage(); Loan storage loan = _existingLoan($, matchId); uint256 newAmount = loan.collateralAmount + amount; loan.collateralAmount = newAmount; emit CollateralIncreased(matchId, amount, newAmount); // A top-up is a deleveraging action just like a repayment: clear whatever remediation state // the added collateral cured, so the borrower's recovery is recorded the moment it happens. _tryClearRemediation($, loan, matchId); } // --- views ------------------------------------------------------------- /// @inheritdoc ILoanManager function unrealizedValue(bytes32 matchId) external view returns (uint256) { LoanManagerStorage storage $ = _getStorage(); Loan storage loan = $.loans[matchId]; uint256 outstanding = loan.outstandingPrincipal; if (outstanding == 0) return 0; // unknown or fully-repaid loan: nothing owed // The lender's share of accrued interest (net of the protocol take), marked to market — live // even during an auction. Rounds down so the NAV is never overstated; principal repayment // moves value from `outstanding` into the note's cash, keeping NAV continuous across repays. uint256 accrued = InterestMath.accrued(outstanding, loan.rate, block.timestamp - loan.lastAccrualTimestamp); uint256 lenderAccrued = Math.mulDiv(accrued, Constants.BPS - loan.interestTakeRateBps, Constants.BPS, Math.Rounding.Floor); return outstanding + lenderAccrued; } /// @inheritdoc ILoanManager function debtOf(bytes32 matchId) external view returns (uint256) { Loan storage loan = _getStorage().loans[matchId]; if (loan.outstandingPrincipal == 0) return 0; // unknown or fully-repaid loan return _liveDebt(loan); } /// @inheritdoc ILoanManager function healthOf(bytes32 matchId) external view returns (uint256 debt, uint256 collateralValue, uint256 ltvBps, uint8 zone) { LoanManagerStorage storage $ = _getStorage(); Loan storage loan = $.loans[matchId]; if (loan.outstandingPrincipal == 0) return (0, 0, 0, 0); // unknown or fully-repaid loan debt = _liveDebt(loan); (collateralValue,, ltvBps) = _valueAndLtv(loan, debt); // Zone = trigger eligibility, matching {triggerLiquidation} / {triggerMarginCall} exactly: // 2 once LTV reaches the liquidation threshold OR the maturity default deadline has passed, // 1 inside the margin-call band [MCT, LT), 0 otherwise. bool pastDefault = block.timestamp >= loan.startTimestamp + loan.duration + $.risk.maturityGracePeriod; if (ltvBps >= loan.liquidationThreshold || pastDefault) zone = 2; else if (ltvBps >= loan.marginCallThreshold) zone = 1; } /// @inheritdoc ILoanManager function auctionOf(bytes32 matchId) external view returns (Auction memory) { return _getStorage().auctions[matchId]; } /// @inheritdoc ILoanManager function riskParams() external view returns (RiskParams memory) { return _getStorage().risk; } /// @inheritdoc ILoanManager function loanOf(bytes32 matchId) external view returns (Loan memory) { return _getStorage().loans[matchId]; } /// @notice The AmishHub wired to this manager. function hub() external view returns (address) { return _getStorage().hub; } /// @notice The FeeRegistry read at execution. function feeRegistry() external view returns (address) { return _getStorage().feeRegistry; } /// @notice The APN template cloned per loan. function apnImplementation() external view returns (address) { return _getStorage().apnImplementation; } /// @notice The CollateralManager this manager releases collateral from. function collateralManager() external view returns (address) { return _getStorage().collateralManager; } // --- internals ---------------------------------------------------------- function _createApn( LoanManagerStorage storage $, bytes32 matchId, ExecutedTerms calldata terms, string calldata apnName, string calldata apnSymbol, uint8 principalDecimals ) private returns (address apn) { apn = $.apnImplementation.cloneDeterministic(matchId); APNImplementation(apn) .initialize( matchId, terms.principalToken, terms.lender, terms.principalAmount, terms.apnTransferable, principalDecimals, apnName, apnSymbol ); } function _recordLoan( LoanManagerStorage storage $, bytes32 matchId, ExecutedTerms calldata terms, address apn, uint16 interestTakeRateBps, uint8 principalDecimals ) private { $.loans[matchId] = Loan({ borrower: terms.borrower, apn: apn, collateralToken: terms.collateralToken, collateralAmount: terms.collateralAmount, collateralChainId: terms.collateralChainId, collateralDecimals: terms.collateralDecimals, oracle: terms.oracle, oracleType: terms.oracleType, maxStaleness: terms.maxStaleness, ltv: terms.ltv, marginCallThreshold: terms.marginCallThreshold, liquidationThreshold: terms.liquidationThreshold, maxLiquidationBonus: terms.maxLiquidationBonus, principalToken: terms.principalToken, principalDecimals: principalDecimals, principalChainId: terms.principalChainId, outstandingPrincipal: terms.principalAmount, rate: terms.rate, rateType: terms.rateType, interestTakeRateBps: interestTakeRateBps, startTimestamp: block.timestamp, duration: terms.duration, lastAccrualTimestamp: block.timestamp }); } /// @dev Moves principal directly from the lender — the origination fee to the treasury and the /// remainder to the borrower — so this contract never custodies principal. The lender must /// have approved this contract for the full principal. Unlike collateral, principal is not /// balance-delta-checked: a non-standard (e.g. fee-on-transfer) principal token only affects /// the borrower who named it in their intent, never the lender's claim or protocol solvency. function _distributePrincipal(ExecutedTerms calldata terms, address treasury, uint256 originationFee) private { IERC20 principal = IERC20(terms.principalToken); if (originationFee != 0) principal.safeTransferFrom(terms.lender, treasury, originationFee); principal.safeTransferFrom(terms.lender, terms.borrower, terms.principalAmount - originationFee); } /// @dev Interest-first repayment waterfall shared by {repay} and {liquidatePartial}: interest /// accrued on the current principal is paid first (split treasury/lender), the remainder /// reduces principal, and the accrual clock advances by only the paid fraction of elapsed time /// when interest is partially paid (a full reset would forgive unpaid interest). Amounts above /// the total owed are capped. Effects precede the token pulls; callers hold `nonReentrant`. function _applyWaterfall(LoanManagerStorage storage $, Loan storage loan, uint256 amount) private returns (uint256 interestPortion, uint256 protocolFee, uint256 principalPortion, uint256 outstanding) { uint256 elapsed = block.timestamp - loan.lastAccrualTimestamp; uint256 accrued = InterestMath.accrued(loan.outstandingPrincipal, loan.rate, elapsed); uint256 totalOwed = loan.outstandingPrincipal + accrued; if (amount > totalOwed) amount = totalOwed; interestPortion = amount < accrued ? amount : accrued; protocolFee = Math.mulDiv(interestPortion, loan.interestTakeRateBps, Constants.BPS, Math.Rounding.Floor); uint256 lenderInterest = interestPortion - protocolFee; // lender takes the rounding dust principalPortion = amount - interestPortion; outstanding = loan.outstandingPrincipal - principalPortion; loan.outstandingPrincipal = outstanding; if (interestPortion == accrued) { loan.lastAccrualTimestamp = block.timestamp; // all accrued interest cleared: reset fully } else { // Partial-interest payment: advance the clock by just the paid fraction so unpaid interest // stays owed. `accrued > interestPortion` here, so no divide-by-zero; flooring is lender-safe. loan.lastAccrualTimestamp += Math.mulDiv(elapsed, interestPortion, accrued, Math.Rounding.Floor); } _payLegs($, loan, protocolFee, lenderInterest + principalPortion); } /// @dev Settle a full liquidation against the live debt legs and close the loan. `repaid` is /// applied interest-first; any principal it does not cover is written down (the loss reaches /// the lender through a lower APN NAV once `outstandingPrincipal` is zeroed). Clears the /// auction. Effects precede the token pulls; the caller holds `nonReentrant`. function _settleLiquidation( LoanManagerStorage storage $, Loan storage loan, bytes32 matchId, uint256 debt, uint256 repaid ) private { uint256 principalOwed = loan.outstandingPrincipal; uint256 interestOwed = debt - principalOwed; // interest accrued live through the auction uint256 interestPortion = repaid < interestOwed ? repaid : interestOwed; uint256 protocolFee = Math.mulDiv(interestPortion, loan.interestTakeRateBps, Constants.BPS, Math.Rounding.Floor); uint256 lenderInterest = interestPortion - protocolFee; uint256 principalPortion = repaid - interestPortion; uint256 shortfall = principalOwed - principalPortion; // Effects: close the loan and drop the freeze before any interaction. A full liquidation removes // all collateral (seized to the liquidator + released to the borrower), so zero the denormalized // amount too rather than leaving a stale figure on the closed loan. loan.outstandingPrincipal = 0; loan.collateralAmount = 0; loan.lastAccrualTimestamp = block.timestamp; delete $.auctions[matchId]; _payLegs($, loan, protocolFee, lenderInterest + principalPortion); if (shortfall != 0) emit LoanWrittenDown(matchId, shortfall); } /// @dev Pull a repayment from the caller: the protocol fee to the treasury and the lender's legs /// (interest + returned principal) into the APN, which custodies them as the liquid part of /// its NAV until holders redeem. function _payLegs(LoanManagerStorage storage $, Loan storage loan, uint256 protocolFee, uint256 apnLeg) private { IERC20 principal = IERC20(loan.principalToken); if (protocolFee != 0) { principal.safeTransferFrom(msg.sender, IFeeRegistry($.feeRegistry).treasury(), protocolFee); } if (apnLeg != 0) principal.safeTransferFrom(msg.sender, loan.apn, apnLeg); } /// @dev Load a loan that was ever created, closed or not. function _existingLoan(LoanManagerStorage storage $, bytes32 matchId) private view returns (Loan storage loan) { loan = $.loans[matchId]; if (loan.borrower == address(0)) revert LoanManager_NoSuchLoan(matchId); } /// @dev Load a loan that exists and still has outstanding principal — the precondition of every /// servicing and risk action. function _activeLoan(LoanManagerStorage storage $, bytes32 matchId) private view returns (Loan storage loan) { loan = _existingLoan($, matchId); if (loan.outstandingPrincipal == 0) revert LoanManager_LoanClosed(matchId); } /// @dev Close a fully-repaid loan: drop any remediation state and release the remaining /// collateral to the borrower. The caller has already settled the loan's legs. function _closeLoan(LoanManagerStorage storage $, bytes32 matchId) private { delete $.auctions[matchId]; ICollateralManager($.collateralManager).release(matchId); emit LoanClosed(matchId); } /// @dev Live debt: outstanding principal plus interest accrued to this block. function _liveDebt(Loan storage loan) private view returns (uint256) { return loan.outstandingPrincipal + InterestMath.accrued(loan.outstandingPrincipal, loan.rate, block.timestamp - loan.lastAccrualTimestamp); } /// @dev Read the oracle (reverting on a dead or stale feed) and value the position in one call: /// collateral value in principal base units, the normalized price (reused by liquidation /// callers to size the seize without a second read), and the LTV of `debt`. function _valueAndLtv(Loan storage loan, uint256 debt) private view returns (uint256 collValue, uint256 normalizedPrice, uint256 ltv) { (uint256 price, uint8 priceDecimals) = OracleLib.readPrice(loan.oracle, loan.collateralToken, loan.principalToken, loan.maxStaleness); normalizedPrice = LtvMath.normalizePrice(price, priceDecimals, loan.collateralDecimals, loan.principalDecimals); (collValue, ltv) = _ltvAtPrice(loan, debt, normalizedPrice); } /// @dev The LTV formula and its zero-guard (zero collateral value reads as maximally leveraged) /// at an already-normalized price — the single home of both; health checks must not /// reimplement them. Token decimals are denormalized into the loan, so no external /// decimals() call is made here. function _ltvAtPrice(Loan storage loan, uint256 debt, uint256 normalizedPrice) private view returns (uint256 collValue, uint256 ltv) { collValue = LtvMath.collateralValue(loan.collateralAmount, normalizedPrice); ltv = collValue == 0 ? type(uint256).max : LtvMath.ltvBps(debt, collValue); } /// @dev Best-effort clearing of remediation state after a deleveraging action (a repayment or a /// collateral top-up): a margin call clears once the position is back below MCT, a /// pre-maturity liquidation trigger once back below LT — the same conditions /// {clearMarginCall} / {clearLiquidation} enforce, applied automatically so a cured borrower /// is not left holding a stale, fully-elapsed grace/bonus clock. A maturity default is never /// cleared. Skipped silently when no fresh price is available, so a dead feed never blocks /// servicing; the permissionless clear functions remain the fallback. function _tryClearRemediation(LoanManagerStorage storage $, Loan storage loan, bytes32 matchId) private { Auction storage auction = $.auctions[matchId]; if (auction.marginCallAt == 0 && auction.liquidationAt == 0) return; (bool ok, uint256 price, uint8 priceDecimals) = OracleLib.tryReadPrice(loan.oracle, loan.collateralToken, loan.principalToken, loan.maxStaleness); if (!ok) return; uint256 normalizedPrice = LtvMath.normalizePrice(price, priceDecimals, loan.collateralDecimals, loan.principalDecimals); (, uint256 ltv) = _ltvAtPrice(loan, _liveDebt(loan), normalizedPrice); if (auction.liquidationAt != 0) { bool pastMaturity = block.timestamp >= loan.startTimestamp + loan.duration + $.risk.maturityGracePeriod; if (!pastMaturity && ltv < loan.liquidationThreshold) { auction.liquidationAt = 0; emit LiquidationCleared(matchId); } } if (auction.marginCallAt != 0 && ltv < loan.marginCallThreshold) { auction.marginCallAt = 0; emit MarginCallCleared(matchId); } } /// @dev The auction bonus (bps) for a loan at the given LTV, `auctionStart`, and current time, /// using the zone-appropriate distress metric (Zone 3 / Zone 2 / healthy-maturity = 0). function _bonus(LoanManagerStorage storage $, Loan storage loan, uint256 auctionStart, uint256 ltv) private view returns (uint256) { uint256 distress; if (ltv >= loan.liquidationThreshold) { distress = AuctionMath.distressZone3(ltv, loan.liquidationThreshold, $.risk.hfFloor); } else if (ltv >= loan.marginCallThreshold) { distress = AuctionMath.distressZone2(ltv, loan.marginCallThreshold, loan.liquidationThreshold); } uint256 timePct = AuctionMath.timePct(block.timestamp - auctionStart, $.risk.auctionDuration); return AuctionMath.bonusBps($.risk.bMin, loan.maxLiquidationBonus, AuctionMath.progress(timePct, distress)); } function _authorizeUpgrade(address) internal override onlyRole(Roles.UPGRADER_ROLE) {} function _getStorage() private pure returns (LoanManagerStorage storage $) { assembly { $.slot := STORAGE_LOCATION } } }