// 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 {EIP712Upgradeable} from "@openzeppelin-contracts-upgradeable-5.6.1/utils/cryptography/EIP712Upgradeable.sol"; import {ECDSA} from "@openzeppelin-contracts-5.6.1/utils/cryptography/ECDSA.sol"; import {IERC20Metadata} from "@openzeppelin-contracts-5.6.1/token/ERC20/extensions/IERC20Metadata.sol"; import {IAmishHub} from "./interfaces/IAmishHub.sol"; import {ICollateralManager} from "./interfaces/ICollateralManager.sol"; import {ILoanManager} from "./interfaces/ILoanManager.sol"; import {LendingIntent, CollateralEntry, Rule, ExecutedTerms} from "./types/DataTypes.sol"; import {IntentHash} from "./libraries/IntentHash.sol"; import {MatchLib} from "./libraries/MatchLib.sol"; import {LtvMath} from "./libraries/LtvMath.sol"; import {OracleLib} from "./libraries/OracleLib.sol"; import {Roles} from "./libraries/Roles.sol"; /// @title AmishHub /// @notice Entry point and orchestrator. It verifies both signed intents, re-derives the executed /// terms on-chain from the two intents (trusting nothing from the submitter but the maker /// side), accounts for the fill against each intent, then atomically locks collateral and /// creates the loan. The executor is a rotatable policy gate for best-execution, not a /// safety mechanism — every guarantee is enforced here regardless of who submits. /// @dev UUPS-upgradeable; upgrades gated by `UPGRADER_ROLE` (a Timelock in production). The manager /// addresses are fixed at initialization. Storage uses ERC-7201. Replay/over-fill are bounded /// by the per-intent fill ceiling; duplicate matches are rejected downstream by the managers. /// Compliance rules are bound to each signature via `rulesHash` but are NOT evaluated on-chain /// in this version — rule enforcement is performed off-chain by the trusted solver. contract AmishHub is IAmishHub, Initializable, AccessControlUpgradeable, UUPSUpgradeable, ReentrancyGuardTransient, EIP712Upgradeable { /// @custom:storage-location erc7201:amish.storage.AmishHub struct AmishHubStorage { address collateralManager; address loanManager; mapping(bytes32 intentHash => uint256 filled) filled; mapping(bytes32 intentHash => bool cancelled) cancelled; // Collateral drawn from a borrower intent's chosen entry, keyed by // keccak256(intentHash, collateralIndex). Only used by adaptive (non-strict) fills, which // cap total collateral at the chosen entry's signed amount. mapping(bytes32 poolKey => uint256 collateralFilled) collateralFilled; } // keccak256(abi.encode(uint256(keccak256("amish.storage.AmishHub")) - 1)) & ~bytes32(uint256(0xff)) bytes32 private constant STORAGE_LOCATION = 0x512a463caf523fd56a5743d887d99a8aa7f5b86b1979ed884c27ef4eb7de2100; event LoanExecuted( bytes32 indexed matchId, bytes32 indexed borrowerIntentHash, bytes32 indexed lenderIntentHash, uint256 fillAmount ); event IntentCancellation(bytes32 indexed intentHash, address indexed creator); error AmishHub_ZeroAddress(); error AmishHub_NotAContract(); error AmishHub_IntentExpired(); error AmishHub_IntentCancelled(); error AmishHub_CollateralHashMismatch(); error AmishHub_RulesHashMismatch(); error AmishHub_CollateralIndexOutOfBounds(); error AmishHub_InvalidSignature(); error AmishHub_NotIntentCreator(); error AmishHub_UnhealthyPosition(); error AmishHub_CollateralCapExceeded(); constructor() { _disableInitializers(); } /// @param admin Holds DEFAULT_ADMIN_ROLE. /// @param upgrader Holds UPGRADER_ROLE (a Timelock in production). /// @param executor Holds EXECUTOR_ROLE (team-operated, rotatable). /// @param collateralManager_ The CollateralManager singleton. /// @param loanManager_ The LoanManager singleton. function initialize( address admin, address upgrader, address executor, address collateralManager_, address loanManager_ ) external initializer { if (admin == address(0) || upgrader == address(0) || executor == address(0)) { revert AmishHub_ZeroAddress(); } if (collateralManager_.code.length == 0 || loanManager_.code.length == 0) revert AmishHub_NotAContract(); __AccessControl_init(); __EIP712_init("Amish", "1"); _grantRole(DEFAULT_ADMIN_ROLE, admin); _grantRole(Roles.UPGRADER_ROLE, upgrader); _grantRole(Roles.EXECUTOR_ROLE, executor); AmishHubStorage storage $ = _getStorage(); $.collateralManager = collateralManager_; $.loanManager = loanManager_; } /// @inheritdoc IAmishHub function executeLoan(MatchExecution calldata e) external onlyRole(Roles.EXECUTOR_ROLE) nonReentrant returns (bytes32 matchId) { bytes32 borrowerHash = _verifyIntent(e.borrowerIntent, e.borrowerCollateral, e.borrowerRules, e.borrowerSignature); bytes32 lenderHash = _verifyIntent(e.lenderIntent, e.lenderCollateral, e.lenderRules, e.lenderSignature); // The match uses one collateral entry per side; the rest of each array are unused alternatives. if ( e.borrowerCollateralIndex >= e.borrowerCollateral.length || e.lenderCollateralIndex >= e.lenderCollateral.length ) { revert AmishHub_CollateralIndexOutOfBounds(); } AmishHubStorage storage $ = _getStorage(); CollateralEntry calldata bColl = e.borrowerCollateral[e.borrowerCollateralIndex]; ExecutedTerms memory terms = MatchLib.verifyAndDeriveTerms( e.borrowerIntent, bColl, e.lenderIntent, e.lenderCollateral[e.lenderCollateralIndex], e.fillAmount, $.filled[borrowerHash], $.filled[lenderHash], e.makerIsLender ); matchId = IntentHash.deriveMatchId(borrowerHash, lenderHash); // Opening-health gate: size collateral by the borrower's model using the live price and // write the amount to lock into `terms.collateralAmount`. _resolveCollateral( $, e.borrowerIntent.strict, terms, borrowerHash, e.borrowerCollateralIndex, bColl.amount, e.borrowerIntent.principalAmount ); // Effects before interactions: bound each intent's cumulative principal fill. $.filled[borrowerHash] += e.fillAmount; $.filled[lenderHash] += e.fillAmount; ICollateralManager($.collateralManager) .lock(matchId, terms.borrower, terms.collateralToken, terms.collateralChainId, terms.collateralAmount); ILoanManager($.loanManager).execute(matchId, terms, e.apnName, e.apnSymbol); emit LoanExecuted(matchId, borrowerHash, lenderHash, e.fillAmount); } /// @dev Size and validate the collateral backing this fill, writing the amount to lock into /// `terms.collateralAmount`. `strict` locks the pro-rata share (`collateralCap` is the /// chosen entry's full amount; `fullPrincipal` the intent's full principal) and reverts if /// the position would open at or beyond the margin-call threshold; otherwise the collateral /// is sized to open at the target LTV and drawn from `collateralCap`, accounted per /// (intent, entry) so adaptive partial fills cannot over-consume the entry. function _resolveCollateral( AmishHubStorage storage $, bool strict, ExecutedTerms memory terms, bytes32 borrowerHash, uint256 collateralIndex, uint256 collateralCap, uint256 fullPrincipal ) private { (uint256 price, uint8 priceDecimals) = OracleLib.readPrice( terms.oracle, terms.collateralToken, terms.principalToken, terms.maxStaleness ); // Denormalize the collateral decimals into the loan: the collateral token may be remote, so // the principal-chain LTV math cannot read it later. Principal decimals stay local. terms.collateralDecimals = IERC20Metadata(terms.collateralToken).decimals(); uint8 principalDecimals = IERC20Metadata(terms.principalToken).decimals(); // Bound the decimals once, here at creation: they are fixed for the life of the match, so this // keeps the power-of-ten terms in {normalizePrice} overflow-proof without re-checking per call. LtvMath.validateDecimals(priceDecimals, terms.collateralDecimals, principalDecimals); uint256 normalizedPrice = LtvMath.normalizePrice(price, priceDecimals, terms.collateralDecimals, principalDecimals); if (strict) { terms.collateralAmount = MatchLib.proportionalCollateral(collateralCap, terms.principalAmount, fullPrincipal); uint256 collValue = LtvMath.collateralValue(terms.collateralAmount, normalizedPrice); if (collValue == 0 || LtvMath.ltvBps(terms.principalAmount, collValue) >= terms.marginCallThreshold) { revert AmishHub_UnhealthyPosition(); } } else { // Sizing to the target LTV opens the position at <= terms.ltv, and MatchLib's invariant // guarantees terms.ltv < marginCallThreshold, so it is below MCT by construction — no // separate health check is needed here (unlike strict, whose LTV floats with price). uint256 needed = LtvMath.collateralForLtv(terms.principalAmount, terms.ltv, normalizedPrice); bytes32 poolKey = keccak256(abi.encode(borrowerHash, collateralIndex)); uint256 consumed = $.collateralFilled[poolKey] + needed; if (consumed > collateralCap) revert AmishHub_CollateralCapExceeded(); $.collateralFilled[poolKey] = consumed; // effect terms.collateralAmount = needed; } } /// @inheritdoc IAmishHub function cancelIntent(LendingIntent calldata intent) external { if (msg.sender != intent.creator) revert AmishHub_NotIntentCreator(); bytes32 structHash = IntentHash.hashStruct(intent); _getStorage().cancelled[structHash] = true; emit IntentCancellation(structHash, msg.sender); } /// @inheritdoc IAmishHub function filledOf(bytes32 intentHash) external view returns (uint256) { return _getStorage().filled[intentHash]; } /// @notice Whether the intent identified by `intentHash` has been cancelled by its creator. A /// cancelled intent can no longer be filled. function isCancelled(bytes32 intentHash) external view returns (bool) { return _getStorage().cancelled[intentHash]; } /// @notice The EIP-712 digest an intent signer must sign for this deployment. function hashTypedData(LendingIntent calldata intent) external view returns (bytes32) { return _hashTypedDataV4(IntentHash.hashStruct(intent)); } /// @notice The CollateralManager singleton. function collateralManager() external view returns (address) { return _getStorage().collateralManager; } /// @notice The LoanManager singleton. function loanManager() external view returns (address) { return _getStorage().loanManager; } /// @dev Verify an intent's array commitments and signature, and that it has not expired. Returns /// the intent's struct hash (its identity for fill-accounting and matchId derivation). Rules /// are bound to the signature via `rulesHash` but not evaluated on-chain in this version. function _verifyIntent( LendingIntent calldata intent, CollateralEntry[] calldata collateral, Rule[] calldata rules, bytes calldata signature ) private view returns (bytes32 structHash) { if (block.timestamp > intent.deadline) revert AmishHub_IntentExpired(); if (IntentHash.hashCollateral(collateral) != intent.collateralHash) revert AmishHub_CollateralHashMismatch(); if (IntentHash.hashRules(rules) != intent.rulesHash) revert AmishHub_RulesHashMismatch(); structHash = IntentHash.hashStruct(intent); if (_getStorage().cancelled[structHash]) revert AmishHub_IntentCancelled(); address signer = ECDSA.recover(_hashTypedDataV4(structHash), signature); if (signer != intent.creator) revert AmishHub_InvalidSignature(); } function _authorizeUpgrade(address) internal override onlyRole(Roles.UPGRADER_ROLE) {} function _getStorage() private pure returns (AmishHubStorage storage $) { assembly { $.slot := STORAGE_LOCATION } } }