RealLending.sol

Overview

RealLending enables collateralized borrowing against fractional ERC-20 tokens (your RWA fractions). Each deployment manages a single market: one collateral token and one debt token. Borrowers deposit collateral, draw debt up to an LTV ceiling, and can be liquidated when the position’s health falls below a liquidation threshold. An external oracle prices collateral in debt-token units, and an optional compliance hook can restrict participation.

Core design principles:

  • Simple, explicit market: one collateral / one debt asset per contract for clarity and auditability.

  • Deterministic math: basis-points risk params, 18-dec oracle price, easy-to-reason-about health checks.

  • Composable: no custom token hooks required; uses standard ERC-20 transfers/approvals.


Responsibilities

  • Track per-account collateral and debt balances.

  • Enforce LTV on borrow() and preserve health on withdrawCollateral().

  • Allow liquidation of unsafe positions with a configurable bonus for liquidators.

  • Integrate oracle pricing and optional compliance checks.

  • Provide events and views for off-chain monitoring (indexers, UIs, risk dashboards).

Non-goals:

  • Multi-collateral, multi-debt pools (deploy one instance per market or wrap with a higher-level controller).

  • Interest rate modeling, debt accrual, or rate curves (can be layered on later).

  • Cross-margining between markets (keep isolation by default).


Storage and Components

Immutable assets

  • IERC20 collateralToken — fractional RWA token (ERC-20).

  • IERC20 debtToken — borrow currency (e.g., USDC or $REAL).

External hooks

  • IPriceOracle oracle — returns debt units per 1 collateral with 1e18 precision.

  • IComplianceLite compliance (optional) — if permissioned, borrowers must be eligible.

Risk parameters (basis points)

  • ltvBps — max borrow at origination/extension (e.g., 6000 = 60%).

  • liqThresholdBps — liquidation threshold (e.g., 7000 = 70%).

  • liqBonusBps — extra collateral seized by liquidator over the fair value (e.g., 500 = 5%).

Invariants: ltvBps <= liqThresholdBps < 10000.

Accounting

  • collateralOf[user] — collateral amount (in collateral token units).

  • debtOf[user] — outstanding debt (in debt token units).

Roles

  • DEFAULT_ADMIN_ROLE — can grant other roles.

  • PARAM_ADMIN_ROLE — can update risk params, oracle, compliance.

Events

  • CollateralDeposited, CollateralWithdrawn

  • Borrowed, Repaid

  • Liquidated

  • ParamsUpdated, OracleUpdated, ComplianceUpdated


Key Views and Math

Price and Value

  • oracle.price() → p (1e18): debtUnits per 1 collateral.

  • collateralValue(user) = collateralOf[user] * p / 1e18 (in debt units).

LTV and Thresholds

  • maxBorrow(user) = collateralValue(user) * ltvBps / 10000.

  • Healthy if:

    collateralValue(user) * liqThresholdBps / 10000 >= debtOf[user].

Liquidation Seize Calculation

Given a liquidation repayment R (in debt units) by the liquidator:

  • Fair seize, in collateral: S = R / p = R * 1e18 / p.

  • Bonus: B = S * liqBonusBps / 10000.

  • Total seize: T = min(S + B, collateralOf[borrower]).


Control Flow

Deposit Collateral

  1. (Optional) Compliance check.

  2. Pull amount of collateralToken from user.

  3. Increase collateralOf[user].

Borrow

  1. (Optional) Compliance check.

  2. Compute new debt: debtOf[user] + amount and compare to maxBorrow(user).

  3. If within LTV, store new debt and transfer debtToken to user.

Withdraw Collateral

  1. Decrease collateralOf[user] by amount.

  2. Re-check healthy(); if not healthy, revert and restore collateral.

  3. Transfer collateral back to user.

Repay

  1. Pull amount of debtToken from user.

  2. Reduce debtOf[user].

Liquidate

  1. Require borrower not healthy.

  2. Pull repayAmount of debtToken from liquidator.

  3. Decrease borrower debt by repayAmount.

  4. Compute seize T via oracle price and liqBonusBps.

  5. Reduce borrower collateral by T, transfer T to liquidator.


Interactions with the Protocol

  • Tokenization: any ERC-20 compliant fractional token can serve as collateralToken. If that token enforces compliance on transfer, deposits/withdrawals will inherit those rules.

  • Primary Market: newly minted fractions can immediately be pledged as collateral.

  • Secondary Market: users can withdraw collateral, trade, and re-deposit.

  • Yield Distribution: staked tokens are not simultaneously usable as collateral unless you design a wrapper; keep positions isolated for safety.


Security Considerations

  • Oracle quality: the contract trusts oracle.price(). Use a robust, manipulation-resistant feed. For volatile or low-liquidity assets, consider TWAPs or circuit breakers.

  • Compliance surface: if permissioned(), both depositCollateral() and borrow() validate eligibility. Extend checks to other paths if policy requires.

  • Reentrancy: guarded on all state-changing functions.

  • Approvals & pull transfers: standard ERC-20 approvals are required. Avoid fee-on-transfer debt/collateral tokens unless explicitly supported.

  • Param updates: PARAM_ADMIN_ROLE must be restricted (multisig). Updates are immediate—communicate changes to frontends.

  • LTV / Liq Threshold: maintain invariant ltvBps <= liqThresholdBps to avoid unliquidatable states.


Gas and UX Notes

  • All operations are O(1).

  • Oracle call is a single external read; keep it cheap and deterministic.

  • Frontends should precompute health, max borrow, and liquidation outcomes off-chain for better UX.


Extensibility Patterns

  • Interest accrual: add a global borrow index that grows over time; apply on borrow/repay to keep O(1) updates.

  • Multi-market controller: manage many RealLending instances with shared risk/oracle registry and cross-market views.

  • Liquidation auctions: replace fixed liqBonusBps with Dutch auctions for fairer price discovery under stress.

  • Pause/circuit breaker: add Pausable to freeze borrow or liquidations on oracle outages.

  • Multiple oracles / medianizer: aggregate several feeds to reduce manipulation risk.


Minimal Integration Snippets

Deposit and Borrow

// approvals
collateralToken.approve(address(lending), collateralAmt);

// deposit collateral
lending.depositCollateral(collateralAmt);

// compute headroom off-chain; then borrow
lending.borrow(debtAmt);

Repay and Withdraw

debtToken.approve(address(lending), repayAmt);
lending.repay(repayAmt);

// withdraw some collateral while remaining healthy
lending.withdrawCollateral(withdrawAmt);

Liquidation

// liquidator repays part/all of borrower debt and seizes collateral with bonus
debtToken.approve(address(lending), repayAmt);
lending.liquidate(borrower, repayAmt);

Admin (risk/oracle updates)

// only PARAM_ADMIN_ROLE
lending.setParams(6000, 7000, 500);  // LTV=60%, LiqThr=70%, Bonus=5%
lending.setOracle(newOracle);
lending.setCompliance(newCompliance);

Testing and Validation Checklist

  • Risk invariants

    • Enforce ltvBps <= liqThresholdBps < 10000.

    • Borrow cannot exceed maxBorrow().

    • Withdraw cannot make position unhealthy.

  • Liquidation math

    • Seize equals (repay / p) * (1 + bonusBps/10000) capped at available collateral.

    • Post-liquidation health increases or debt/collateral drop to zero as expected.

  • Oracle edge cases

    • Zero or stale price should revert or be handled (consider guardrails in oracle).

    • Big price moves do not overflow; check with fuzzing.

  • Compliance paths

    • Permissioned on: reject ineligible users on deposit/borrow.

    • Permissioned off: flows remain open.

  • Adversarial flows

    • Reentrancy blocked.

    • Large values, rounding bounds.

    • ERC-20 behavior assumptions hold (no fee-on-transfer unless supported).


Why This Design Works

  • Isolation per market minimizes blast radius and simplifies audits.

  • Transparent math (bps + 18-dec prices) makes risk parameters easy to reason about.

  • Composable integrations (oracle + compliance) let you swap implementations without redeploying business logic.

  • Straightforward liquidations keep liveness high during stress while rewarding keepers.


RealLending.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

/**
 * @notice Minimal compliance surface. If permissioned, borrowers must be eligible.
 */
interface IComplianceLite {
    function permissioned() external view returns (bool);
    function isEligible(address account) external view returns (bool);
}

/**
 * @notice Price oracle for collateral expressed in debt token units (1e18 precision).
 * Example: if 1 collateral = 12.34 USDC, oracle returns 12.34e18.
 */
interface IPriceOracle {
    function price() external view returns (uint256);
}

/**
 * @title RealLending
 * @notice Collateralized lending against fractional ERC-20 tokens.
 * Borrowers deposit collateral (fractions) and borrow a debt ERC-20
 * (e.g., $REAL or a stablecoin) up to an LTV limit; liquidators can
 * repay debt and seize collateral when health is below threshold.
 *
 * Key assumptions (simple single-market example):
 * - One collateral token and one debt token per deployment.
 * - Oracle price has 18 decimals: debtUnitsPerOneCollateral * 1e18.
 * - Collateral and debt tokens are standard ERC-20s.
 */
contract RealLending is AccessControl, ReentrancyGuard {
    using SafeERC20 for IERC20;

    // ─────────────────────────────────────────────────────────────────────────────
    // Roles
    // ─────────────────────────────────────────────────────────────────────────────
    bytes32 public constant PARAM_ADMIN_ROLE = keccak256("PARAM_ADMIN_ROLE");

    // ─────────────────────────────────────────────────────────────────────────────
    // Immutable assets
    // ─────────────────────────────────────────────────────────────────────────────
    IERC20 public immutable collateralToken; // e.g., RealFractionalToken (ERC-20)
    IERC20 public immutable debtToken;       // e.g., USDC or $REAL (ERC-20)

    // ─────────────────────────────────────────────────────────────────────────────
    // External hooks
    // ─────────────────────────────────────────────────────────────────────────────
    IComplianceLite public compliance; // optional; if set & permissioned, enforce eligibility
    IPriceOracle    public oracle;     // returns price with 18 decimals (debt units per 1 collateral)

    // ─────────────────────────────────────────────────────────────────────────────
    // Risk params (basis points = /10_000)
    // ─────────────────────────────────────────────────────────────────────────────
    uint256 public ltvBps;          // Max borrow: collateralValue * ltvBps / 10_000
    uint256 public liqThresholdBps; // Liquidation if collateralValue * liqThresholdBps / 10_000 < debt
    uint256 public liqBonusBps;     // Extra collateral seized by liquidator, as % of seized (e.g., 500 = 5%)

    // ─────────────────────────────────────────────────────────────────────────────
    // Accounting
    // ─────────────────────────────────────────────────────────────────────────────
    mapping(address => uint256) public collateralOf; // user -> collateral amount
    mapping(address => uint256) public debtOf;       // user -> debt amount (in debt token units)

    // ─────────────────────────────────────────────────────────────────────────────
    // Events
    // ─────────────────────────────────────────────────────────────────────────────
    event CollateralDeposited(address indexed user, uint256 amount);
    event CollateralWithdrawn(address indexed user, uint256 amount);
    event Borrowed(address indexed user, uint256 amount);
    event Repaid(address indexed user, uint256 amount);
    event Liquidated(address indexed user, address indexed liquidator, uint256 repaid, uint256 seized);
    event ParamsUpdated(uint256 ltvBps, uint256 liqThresholdBps, uint256 liqBonusBps);
    event OracleUpdated(address oracle);
    event ComplianceUpdated(address compliance);

    // ─────────────────────────────────────────────────────────────────────────────
    // Errors
    // ─────────────────────────────────────────────────────────────────────────────
    error Ineligible();
    error BadAmount();
    error Unhealthy();
    error Healthy();
    error BadParams();

    // ─────────────────────────────────────────────────────────────────────────────
    // Constructor
    // ─────────────────────────────────────────────────────────────────────────────
    constructor(
        address admin_,
        IERC20 _collateralToken,
        IERC20 _debtToken,
        IPriceOracle _oracle,
        IComplianceLite _compliance,
        uint256 _ltvBps,
        uint256 _liqThresholdBps,
        uint256 _liqBonusBps
    ) {
        require(admin_ != address(0), "admin=0");
        require(address(_collateralToken) != address(0) && address(_debtToken) != address(0), "token=0");
        require(address(_oracle) != address(0), "oracle=0");
        require(_ltvBps <= _liqThresholdBps && _liqThresholdBps < 10_000, "risk out of range");

        _grantRole(DEFAULT_ADMIN_ROLE, admin_);
        _grantRole(PARAM_ADMIN_ROLE, admin_);

        collateralToken = _collateralToken;
        debtToken = _debtToken;
        oracle = _oracle;
        compliance = _compliance;

        ltvBps = _ltvBps;
        liqThresholdBps = _liqThresholdBps;
        liqBonusBps = _liqBonusBps;
    }

    // ─────────────────────────────────────────────────────────────────────────────
    // Views
    // ─────────────────────────────────────────────────────────────────────────────

    /// @notice Collateral value in debt token units (uses 18-dec oracle scaling).
    function collateralValue(address user) public view returns (uint256) {
        uint256 px = oracle.price(); // debt per 1 collateral (1e18)
        return (collateralOf[user] * px) / 1e18;
    }

    /// @notice Maximum borrowable amount for `user` under current LTV.
    function maxBorrow(address user) public view returns (uint256) {
        return (collateralValue(user) * ltvBps) / 10_000;
    }

    /// @notice Returns true if user is above liquidation threshold.
    function healthy(address user) public view returns (bool) {
        // collateralValue * liqThresholdBps >= debt * 10_000
        return (collateralValue(user) * liqThresholdBps) / 10_000 >= debtOf[user];
    }

    /// @notice Health factor scaled by 1e18: (collateralValue * liqThresholdBps / debt) * 1e18 / 10_000
    function healthFactor(address user) external view returns (uint256) {
        uint256 d = debtOf[user];
        if (d == 0) return type(uint256).max;
        return ((collateralValue(user) * liqThresholdBps * 1e18) / 10_000) / d;
    }

    // ─────────────────────────────────────────────────────────────────────────────
    // Admin
    // ─────────────────────────────────────────────────────────────────────────────

    function setParams(uint256 _ltvBps, uint256 _liqThresholdBps, uint256 _liqBonusBps)
        external
        onlyRole(PARAM_ADMIN_ROLE)
    {
        if (!(_ltvBps <= _liqThresholdBps && _liqThresholdBps < 10_000)) revert BadParams();
        ltvBps = _ltvBps;
        liqThresholdBps = _liqThresholdBps;
        liqBonusBps = _liqBonusBps;
        emit ParamsUpdated(_ltvBps, _liqThresholdBps, _liqBonusBps);
    }

    function setOracle(IPriceOracle _oracle) external onlyRole(PARAM_ADMIN_ROLE) {
        require(address(_oracle) != address(0), "oracle=0");
        oracle = _oracle;
        emit OracleUpdated(address(_oracle));
    }

    function setCompliance(IComplianceLite _c) external onlyRole(PARAM_ADMIN_ROLE) {
        compliance = _c;
        emit ComplianceUpdated(address(_c));
    }

    // ─────────────────────────────────────────────────────────────────────────────
    // Core: collateral
    // ─────────────────────────────────────────────────────────────────────────────

    function depositCollateral(uint256 amount) external nonReentrant {
        if (amount == 0) revert BadAmount();
        if (address(compliance) != address(0) && compliance.permissioned()) {
            if (!compliance.isEligible(msg.sender)) revert Ineligible();
        }
        collateralToken.safeTransferFrom(msg.sender, address(this), amount);
        collateralOf[msg.sender] += amount;
        emit CollateralDeposited(msg.sender, amount);
    }

    function withdrawCollateral(uint256 amount) external nonReentrant {
        if (amount == 0 || amount > collateralOf[msg.sender]) revert BadAmount();

        // optimistically decrease then check health
        collateralOf[msg.sender] -= amount;
        if (!healthy(msg.sender)) {
            collateralOf[msg.sender] += amount; // revert state
            revert Unhealthy();
        }

        collateralToken.safeTransfer(msg.sender, amount);
        emit CollateralWithdrawn(msg.sender, amount);
    }

    // ─────────────────────────────────────────────────────────────────────────────
    // Core: debt
    // ─────────────────────────────────────────────────────────────────────────────

    function borrow(uint256 amount) external nonReentrant {
        if (amount == 0) revert BadAmount();
        if (address(compliance) != address(0) && compliance.permissioned()) {
            if (!compliance.isEligible(msg.sender)) revert Ineligible();
        }

        uint256 newDebt = debtOf[msg.sender] + amount;
        require(newDebt <= maxBorrow(msg.sender), "exceeds LTV");

        debtOf[msg.sender] = newDebt;
        debtToken.safeTransfer(msg.sender, amount);
        emit Borrowed(msg.sender, amount);
    }

    function repay(uint256 amount) external nonReentrant {
        if (amount == 0 || amount > debtOf[msg.sender]) revert BadAmount();
        debtToken.safeTransferFrom(msg.sender, address(this), amount);
        debtOf[msg.sender] -= amount;
        emit Repaid(msg.sender, amount);
    }

    // ─────────────────────────────────────────────────────────────────────────────
    // Liquidation
    // ─────────────────────────────────────────────────────────────────────────────

    /**
     * @notice Liquidate an unhealthy borrower by repaying `repayAmount` of their debt.
     * Seizes collateral valued at the oracle price plus a liquidation bonus.
     *
     * Math:
     *  - seizeBase = repayAmount / price   (in collateral units)
     *  - bonus     = seizeBase * liqBonusBps / 10_000
     *  - totalSeize = min(seizeBase + bonus, borrowerCollateral)
     */
    function liquidate(address borrower, uint256 repayAmount) external nonReentrant {
        if (healthy(borrower)) revert Healthy();
        if (repayAmount == 0 || repayAmount > debtOf[borrower]) revert BadAmount();

        // Pull debt from liquidator
        debtToken.safeTransferFrom(msg.sender, address(this), repayAmount);
        debtOf[borrower] -= repayAmount;

        // Compute collateral to seize
        uint256 px = oracle.price(); // debt per 1 collateral (1e18)
        require(px > 0, "oracle=0");

        uint256 seizeBase = (repayAmount * 1e18) / px; // in collateral units
        uint256 bonus = (seizeBase * liqBonusBps) / 10_000;
        uint256 totalSeize = seizeBase + bonus;

        uint256 available = collateralOf[borrower];
        if (totalSeize > available) totalSeize = available;

        collateralOf[borrower] = available - totalSeize;
        collateralToken.safeTransfer(msg.sender, totalSeize);

        emit Liquidated(borrower, msg.sender, repayAmount, totalSeize);
    }
}

Last updated