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
(Optional) Compliance check.
Pull amount of collateralToken from user.
Increase collateralOf[user].
Borrow
(Optional) Compliance check.
Compute new debt: debtOf[user] + amount and compare to maxBorrow(user).
If within LTV, store new debt and transfer debtToken to user.
Withdraw Collateral
Decrease collateralOf[user] by amount.
Re-check healthy(); if not healthy, revert and restore collateral.
Transfer collateral back to user.
Repay
Pull amount of debtToken from user.
Reduce debtOf[user].
Liquidate
Require borrower not healthy.
Pull repayAmount of debtToken from liquidator.
Decrease borrower debt by repayAmount.
Compute seize T via oracle price and liqBonusBps.
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