// SPDX-License-Identifier: MIT pragma solidity ^0.8.30; import {ERC20Upgradeable} from "@openzeppelin-contracts-upgradeable-5.6.1/token/ERC20/ERC20Upgradeable.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 {SafeERC20} from "@openzeppelin-contracts-5.6.1/token/ERC20/utils/SafeERC20.sol"; import {Math} from "@openzeppelin-contracts-5.6.1/utils/math/Math.sol"; import {IAPN} from "./interfaces/IAPN.sol"; import {ILoanManager} from "./interfaces/ILoanManager.sol"; /// @title APNImplementation /// @notice Amish Position Note (APN) — the ERC-20 a lender receives for a loan. /// Deployed once as a template and cloned per loan via EIP-1167 by the LoanManager, so it /// is initializer-based rather than constructor-based. Total supply equals the loan /// principal and is minted to the lender at creation. /// @dev Redemption is **ERC-4626-style** against a mark-to-market NAV, not a par bearer claim: /// NAV = (outstanding principal + lender's accrued-but-unpaid interest) [illiquid claim, /// read live from the LoanManager] /// + principal-token cash already repaid into this note [liquid part] /// `sharePrice = NAV / totalSupply`. Redeeming burns the presented shares and pays /// `shares · sharePrice`, but only up to the cash on hand ({maxRedeem}); the illiquid part of /// NAV raises the price without being directly withdrawable. Because redemption is value-neutral /// (it removes proportional value and shares together) the price is invariant under redemption, /// and interest accrual raises it over the *current* supply — so a holder who exits early forgoes /// the later appreciation, and whoever remains earns proportionally more. No per-holder accounting /// and no transfer hooks are needed: a changing supply is handled by the share price itself. /// There is no separate non-burning "interest claim", which is what made an earlier design /// drainable across repeated calls. Repaid funds live in this note (the LoanManager transfers them /// here); redemption pays out of this note's own balance. contract APNImplementation is IAPN, ERC20Upgradeable, ReentrancyGuardTransient { using SafeERC20 for IERC20; /// @notice The LoanManager that created this note (the caller that initialized the clone); /// also the source of the loan's live outstanding + accrued-interest claim. address public loanManager; /// @notice The loan this note represents. bytes32 public matchId; /// @notice The loan's principal token (the asset repayments/redemptions are denominated in). address public principalToken; /// @notice When false, the note is soulbound to the original lender (regulatory mode). bool public transferable; uint8 private _decimalsValue; event Redeemed(address indexed holder, uint256 shares, uint256 assets); error APNImplementation_NonTransferable(); error APNImplementation_NothingToRedeem(); error APNImplementation_InsufficientCash(uint256 requested, uint256 available); constructor() { // Lock the template so it can never be initialized directly; only clones are usable. _disableInitializers(); } /// @notice Initialize a freshly deployed clone. Callable exactly once (per clone). The /// initializing caller (`msg.sender`, i.e. the LoanManager) becomes `loanManager`. /// @param matchId_ Loan identifier. /// @param principalToken_ Loan principal token. /// @param lender Recipient of the full initial supply. /// @param principalAmount Loan principal, in principal-token base units; the minted supply. /// @param transferable_ False = soulbound to `lender`. /// @param decimals_ Token decimals (mirrors the principal token for 1:1 readability). /// @param name_ ERC-20 name. /// @param symbol_ ERC-20 symbol. function initialize( bytes32 matchId_, address principalToken_, address lender, uint256 principalAmount, bool transferable_, uint8 decimals_, string calldata name_, string calldata symbol_ ) external initializer { __ERC20_init(name_, symbol_); loanManager = msg.sender; matchId = matchId_; principalToken = principalToken_; transferable = transferable_; _decimalsValue = decimals_; _mint(lender, principalAmount); } /// @inheritdoc IAPN function totalAssets() public view returns (uint256) { return ILoanManager(loanManager).unrealizedValue(matchId) + _cash(); } /// @inheritdoc IAPN /// @dev Applies the share price (`totalAssets / totalSupply`): shares → assets. function convertToAssets(uint256 shares) public view returns (uint256) { uint256 supply = totalSupply(); if (supply == 0) return 0; return Math.mulDiv(shares, totalAssets(), supply, Math.Rounding.Floor); } /// @inheritdoc IAPN /// @dev The inverse of {convertToAssets} at the same price: assets → shares. Floors, so the /// returned share count's value never rounds above `assets`. function convertToShares(uint256 assets) public view returns (uint256) { uint256 nav = totalAssets(); if (nav == 0) return 0; return Math.mulDiv(assets, totalSupply(), nav, Math.Rounding.Floor); } /// @inheritdoc IAPN function maxRedeem(address owner) public view returns (uint256) { // The lesser of what the holder owns and what the cash on hand can buy back at the current // price (redemption is cash-capped). `convertToShares(cash)` is cash / sharePrice. uint256 cashShares = convertToShares(_cash()); uint256 bal = balanceOf(owner); return bal < cashShares ? bal : cashShares; } /// @inheritdoc IAPN function redeem(uint256 shares) external nonReentrant returns (uint256 assets) { return _redeem(shares); } /// @inheritdoc IAPN function redeemMax() external nonReentrant returns (uint256 assets) { return _redeem(maxRedeem(msg.sender)); } /// @inheritdoc ERC20Upgradeable function decimals() public view override returns (uint8) { return _decimalsValue; } /// @dev Burn `shares` at the current share price and pay the proportional assets, capped by the /// cash on hand. Effects (burn) precede the transfer; `nonReentrant` guards the entry points. function _redeem(uint256 shares) private returns (uint256 assets) { if (shares == 0) revert APNImplementation_NothingToRedeem(); assets = convertToAssets(shares); // shares -> assets at the current price (0 if supply is 0) // Never burn shares for nothing. `assets` can floor to zero once the share price drops below // one (a loss/write-down leaves NAV < supply) or for sub-price-unit dust; reject rather than // let the caller destroy notes for a zero payout. if (assets == 0) revert APNImplementation_NothingToRedeem(); uint256 cash = _cash(); if (assets > cash) revert APNImplementation_InsufficientCash(assets, cash); _burn(msg.sender, shares); // reverts if the caller lacks the shares emit Redeemed(msg.sender, shares, assets); IERC20(principalToken).safeTransfer(msg.sender, assets); } /// @dev The liquid part of NAV: principal-token repaid into this note and not yet redeemed. function _cash() private view returns (uint256) { return IERC20(principalToken).balanceOf(address(this)); } /// @dev Enforces the soulbound rule: mints (from == 0) and burns (to == 0) are always /// allowed; holder-to-holder transfers are blocked when the note is non-transferable. function _update(address from, address to, uint256 value) internal override { if (!transferable && from != address(0) && to != address(0)) { revert APNImplementation_NonTransferable(); } super._update(from, to, value); } }