// 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 {SafeERC20} from "@openzeppelin-contracts-5.6.1/token/ERC20/utils/SafeERC20.sol"; import {ICollateralManager} from "./interfaces/ICollateralManager.sol"; import {ILoanManager} from "./interfaces/ILoanManager.sol"; import {Roles} from "./libraries/Roles.sol"; /// @title CollateralManager /// @notice Singleton that custodies all borrower collateral, keyed by loan (`matchId`). Collateral /// is pulled in when a loan is created and can only leave through the defined release, /// liquidation, and reclaim paths — there is no admin withdrawal of locked funds. /// @dev UUPS-upgradeable; upgrades are gated by `UPGRADER_ROLE` (a Timelock in production), which /// is the only privileged path that can affect locked funds. Storage uses ERC-7201. contract CollateralManager is ICollateralManager, Initializable, AccessControlUpgradeable, UUPSUpgradeable, ReentrancyGuardTransient { using SafeERC20 for IERC20; /// @custom:storage-location erc7201:amish.storage.CollateralManager struct CollateralManagerStorage { mapping(bytes32 matchId => CollateralDeposit) deposits; address hub; address loanManager; } // keccak256(abi.encode(uint256(keccak256("amish.storage.CollateralManager")) - 1)) & ~bytes32(uint256(0xff)) bytes32 private constant STORAGE_LOCATION = 0xb3d198342462c9361866f7ba18c272721a24481f29cce8588bbe5d91779c7600; event CollateralLocked(bytes32 indexed matchId, address indexed borrower, address indexed token, uint256 amount); event CollateralAdded(bytes32 indexed matchId, address indexed from, uint256 amount, uint256 newAmount); event CollateralReleased(bytes32 indexed matchId, address indexed borrower, address indexed token, uint256 amount); event CollateralSeized(bytes32 indexed matchId, address indexed recipient, address indexed token, uint256 amount); event HubSet(address indexed hub); event LoanManagerSet(address indexed loanManager); error CollateralManager_ZeroAddress(); error CollateralManager_ZeroAmount(); error CollateralManager_WrongChain(); error CollateralManager_DepositExists(bytes32 matchId); error CollateralManager_NoDeposit(bytes32 matchId); error CollateralManager_UnexpectedBalanceDelta(uint256 expected, uint256 received); error CollateralManager_SeizeExceedsDeposit(uint256 amount, uint256 available); error CollateralManager_NotHub(); error CollateralManager_NotLoanManager(); error CollateralManager_HubAlreadySet(); error CollateralManager_LoanManagerAlreadySet(); error CollateralManager_NotAContract(); modifier onlyHub() { if (msg.sender != _getStorage().hub) revert CollateralManager_NotHub(); _; } modifier onlyLoanManager() { if (msg.sender != _getStorage().loanManager) revert CollateralManager_NotLoanManager(); _; } 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). function initialize(address admin, address upgrader) external initializer { if (admin == address(0) || upgrader == address(0)) revert CollateralManager_ZeroAddress(); __AccessControl_init(); _grantRole(DEFAULT_ADMIN_ROLE, admin); _grantRole(Roles.UPGRADER_ROLE, upgrader); } /// @notice Wire the AmishHub. Callable exactly once; afterwards the connection is frozen and /// can only change through an upgrade. The one-time set is what prevents the admin from /// ever granting the collateral-locking path to an arbitrary address. function setHub(address newHub) external onlyRole(DEFAULT_ADMIN_ROLE) { if (newHub == address(0)) revert CollateralManager_ZeroAddress(); if (newHub.code.length == 0) revert CollateralManager_NotAContract(); CollateralManagerStorage storage $ = _getStorage(); if ($.hub != address(0)) revert CollateralManager_HubAlreadySet(); $.hub = newHub; emit HubSet(newHub); } /// @notice Wire the LoanManager. Callable exactly once; afterwards frozen (upgrade to change). /// The LoanManager is the only caller allowed to release collateral, and the only address /// the borrower-facing {addCollateral} notifies to keep the denormalized amount in sync. function setLoanManager(address newLoanManager) external onlyRole(DEFAULT_ADMIN_ROLE) { if (newLoanManager == address(0)) revert CollateralManager_ZeroAddress(); if (newLoanManager.code.length == 0) revert CollateralManager_NotAContract(); CollateralManagerStorage storage $ = _getStorage(); if ($.loanManager != address(0)) revert CollateralManager_LoanManagerAlreadySet(); $.loanManager = newLoanManager; emit LoanManagerSet(newLoanManager); } /// @notice The AmishHub wired to this manager. function hub() external view returns (address) { return _getStorage().hub; } /// @notice The LoanManager wired to this manager. function loanManager() external view returns (address) { return _getStorage().loanManager; } /// @inheritdoc ICollateralManager /// @dev Records the deposit before pulling tokens (checks-effects-interactions) and verifies /// the exact `amount` was received, which rejects fee-on-transfer / rebasing tokens. function lock(bytes32 matchId, address borrower, address token, uint256 chainId, uint256 amount) external onlyHub nonReentrant { if (borrower == address(0) || token == address(0)) revert CollateralManager_ZeroAddress(); if (chainId != block.chainid) revert CollateralManager_WrongChain(); // collateral is custodied on this chain if (amount == 0) revert CollateralManager_ZeroAmount(); CollateralManagerStorage storage $ = _getStorage(); if ($.deposits[matchId].borrower != address(0)) revert CollateralManager_DepositExists(matchId); $.deposits[matchId] = CollateralDeposit({borrower: borrower, token: token, amount: amount}); uint256 balanceBefore = IERC20(token).balanceOf(address(this)); IERC20(token).safeTransferFrom(borrower, address(this), amount); uint256 received = IERC20(token).balanceOf(address(this)) - balanceBefore; if (received != amount) revert CollateralManager_UnexpectedBalanceDelta(amount, received); emit CollateralLocked(matchId, borrower, token, amount); } /// @inheritdoc ICollateralManager /// @dev Clears the deposit before transferring (checks-effects-interactions). The LoanManager /// gates this on the loan's principal being fully repaid; this contract does not re-check it. /// A fully-seized deposit (an underwater liquidation takes all of it) has nothing left to /// move, so the transfer is skipped: tokens that revert on zero-value transfers must not be /// able to block closing the loan. function release(bytes32 matchId) external onlyLoanManager nonReentrant { CollateralManagerStorage storage $ = _getStorage(); CollateralDeposit memory d = $.deposits[matchId]; if (d.borrower == address(0)) revert CollateralManager_NoDeposit(matchId); delete $.deposits[matchId]; if (d.amount != 0) IERC20(d.token).safeTransfer(d.borrower, d.amount); emit CollateralReleased(matchId, d.borrower, d.token, d.amount); } /// @inheritdoc ICollateralManager /// @dev Clears the seized amount from the deposit before transferring (checks-effects-interactions). /// The LoanManager authorizes this only for a liquidatable loan and keeps its denormalized /// collateral amount in sync; this contract enforces only that the seizure fits the deposit. function seizeCollateral(bytes32 matchId, address recipient, uint256 amount) external onlyLoanManager nonReentrant { if (recipient == address(0)) revert CollateralManager_ZeroAddress(); if (amount == 0) revert CollateralManager_ZeroAmount(); CollateralManagerStorage storage $ = _getStorage(); CollateralDeposit storage d = $.deposits[matchId]; if (d.borrower == address(0)) revert CollateralManager_NoDeposit(matchId); if (amount > d.amount) revert CollateralManager_SeizeExceedsDeposit(amount, d.amount); address token = d.token; d.amount -= amount; IERC20(token).safeTransfer(recipient, amount); emit CollateralSeized(matchId, recipient, token, amount); } /// @inheritdoc ICollateralManager /// @dev Pulls the additional collateral from the caller (verifying the exact amount arrived, so /// fee-on-transfer / rebasing tokens are rejected as in {lock}), then syncs the LoanManager's /// denormalized amount atomically so the two records never diverge. function addCollateral(bytes32 matchId, uint256 amount) external nonReentrant { if (amount == 0) revert CollateralManager_ZeroAmount(); CollateralManagerStorage storage $ = _getStorage(); CollateralDeposit storage d = $.deposits[matchId]; if (d.borrower == address(0)) revert CollateralManager_NoDeposit(matchId); address token = d.token; // Interaction first, then effects: pull the tokens (verifying the exact amount arrived, which // rejects fee-on-transfer / rebasing tokens) before touching either record, so a token with a // transfer hook cannot observe or act on a half-updated deposit-vs-loan state. uint256 balanceBefore = IERC20(token).balanceOf(address(this)); IERC20(token).safeTransferFrom(msg.sender, address(this), amount); uint256 received = IERC20(token).balanceOf(address(this)) - balanceBefore; if (received != amount) revert CollateralManager_UnexpectedBalanceDelta(amount, received); uint256 newAmount = d.amount + amount; d.amount = newAmount; ILoanManager($.loanManager).increaseCollateral(matchId, amount); emit CollateralAdded(matchId, msg.sender, amount, newAmount); } /// @inheritdoc ICollateralManager function depositOf(bytes32 matchId) external view returns (CollateralDeposit memory) { return _getStorage().deposits[matchId]; } function _authorizeUpgrade(address) internal override onlyRole(Roles.UPGRADER_ROLE) {} function _getStorage() private pure returns (CollateralManagerStorage storage $) { assembly { $.slot := STORAGE_LOCATION } } }