A minimal lending and borrowing protocol on Ethereum, built with Foundry and modelled on Aave. Users deposit ERC-20 tokens into markets and earn interest, borrow against their deposits, and unhealthy positions can be liquidated.
Not audited and not safe for real funds. The remaining open points are limitations (a mock-only oracle, bad debt without a safety module, a centralised owner) and are listed in SECURITY.md.
One contract, LendingProtocol, holds every market. Every token is valued in USD through a price oracle (IPriceOracle, 8 decimals), so collateral and debt of different tokens and decimals can be compared.
| Action | Function | Who |
|---|---|---|
| Add a token market | addMarket |
Owner |
| Change risk parameters | setLtv and raiseLiquidationThreshold take effect at once; lowering the liquidation threshold needs proposeLiquidationThresholdDecrease, two days and executeLiquidationThresholdDecrease |
Owner |
| Supply tokens and earn interest | deposit |
Anyone |
| Take tokens out | withdraw (only if the health factor stays at 1 or more and the market has the cash; type(uint256).max withdraws everything) |
Depositor |
| Borrow | borrow (needs available liquidity and enough collateral for the LTV) |
Anyone |
| Repay | repay (type(uint256).max repays the whole debt, interest included) |
Borrower |
| Liquidate an unhealthy position | liquidate (health factor below 1: the liquidator repays up to 50 % of a debt, 100 % below 0.95, and receives the collateral of their choice worth the repaid debt plus a 5 % bonus) |
Anyone |
| Emergency controls | pause, unpause, emergencyRecover (only the surplus the contract holds beyond what it owes to depositors) |
Owner |
The protocol lists at most 16 markets, which keeps the gas of the health checks bounded. A market can only be listed for a contract with decimals() up to 18 and a price in the oracle. Plain ERC-20s only: a token that delivers less than what is sent (fee on transfer) makes deposit, repay and liquidate revert, and rebasing tokens or tokens with transfer hooks or blacklists are not supported.
Per market, ltv (how much you can borrow against it) and liquidationThreshold (when you are liquidated), in basis points with ltv <= liquidationThreshold. The health factor is sum(collateral usd * liquidationThreshold) / sum(debt usd): a position is healthy while it is at least 1 and liquidatable below 1. Borrowing is limited by the LTV, withdrawing and liquidating by the threshold, and the gap between them is the safety margin. With the 5 % liquidation bonus, a threshold can be at most 95.23 %. Collateral is valued rounding down and debt rounding up, always in favour of the protocol. withdraw and repay keep working while the protocol is paused.
Rates are not set by anyone: they are computed from the utilization borrowed / supplied. The borrow rate follows a curve with a kink at 80 % utilization (0 % at no use, 4 % at the kink, 79 % at 100 %), and the supply rate is borrowRate * utilization, so depositors earn everything borrowers pay (no reserve factor). Interest accrues with two indexes per market, liquidityIndex and borrowIndex: balances are stored scaled (amount / index) and read as scaled * index, so growing one number grows the balance of every user. Indexes have 27 decimals (RAY, as in Aave) and are updated on every interaction with the market; the views project them to the current block.
Generated from the constants of the contract with python diagrams/generate_rate_curve.py, so it cannot drift from the code.
Why a debt can grow without touching any account: each user stores a scaled amount and the market keeps one index; the visible balance is stored x index. Alice borrows 400 now and Bob borrows 400 one year later (the numbers of LendingProtocolInterest.t.sol):
sequenceDiagram
participant A as Alice
participant M as Market (borrowIndex)
participant B as Bob
A->>M: borrow 400 at index 1.00, stores 400
Note over M: one year at 2 %, index 1.00 becomes 1.02
B->>M: borrow 400 at index 1.02, stores 400 / 1.02 = 392.16
Note over M: Alice owes 400 x 1.02 = 408, Bob owes 392.16 x 1.02 = 400
Note over M: from here both grow with the same index, each paying only for its own time
// owner: the oracle values every token in USD with 8 decimals
oracle.setPrice(address(usdc), 1e8);
oracle.setPrice(address(weth), 2000e8);
lending.addMarket(address(usdc), 8000, 8500); // ltv 80 %, liquidation threshold 85 %
lending.addMarket(address(weth), 7500, 8000);
// lender supplies USDC liquidity and starts earning interest
usdc.approve(address(lending), 100_000e6);
lending.deposit(address(usdc), 100_000e6);
// borrower posts WETH and borrows USDC (at most 75 % of the collateral value)
weth.approve(address(lending), 10 ether);
lending.deposit(address(weth), 10 ether);
lending.borrow(address(usdc), 10_000e6);
// later: repay everything, interest included, and take the collateral back
usdc.approve(address(lending), type(uint256).max);
lending.repay(address(usdc), type(uint256).max);
lending.withdraw(address(weth), type(uint256).max);git clone --recurse-submodules https://github.com/sergiiogg7/lending-protocol
cd lending-protocol
forge build
forge test -vvv
forge coverage --no-match-coverage "script|test"153 tests: unit, fuzz and invariants (market totals match user balances, the contract holds the cash that backs supplied minus borrowed, indexes only grow), 100 % line coverage on src/. The risk model, liquidation, rates and interest each have their own suite and an end-to-end scenario with exact numbers. Dependencies: OpenZeppelin Contracts v5.4.0 and forge-std (git submodules).
anvil
forge script script/LocalDeploy.s.sol --rpc-url http://127.0.0.1:8545 --broadcast \
--private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80It deploys a mock price oracle (every token at 1 USD), the protocol and two mock tokens with their markets. For a testnet, copy .env.example to .env (set ORACLE to a real price oracle) and run script/Deploy.s.sol.
src/LendingProtocol.sol protocol
src/interfaces/IPriceOracle.sol price source (USD, 8 decimals)
test/ unit, fuzz and invariant tests, mocks (token, fee-on-transfer token and price oracle)
diagrams/ interest-rate-curve.svg and the script that generates it from the contract
script/ Deploy.s.sol, LocalDeploy.s.sol
SECURITY.md measures, invariants and known vulnerabilities
PLAN.md phase 2 roadmap and what was changed
MIT