A decentralized lending protocol in Solidity. Supply assets to earn interest, borrow against cross-asset collateral, get liquidated at a discount if your position goes underwater.
Aave-shaped, deliberately smaller. Every simplification is a decision, and the ones worth defending are documented below.
Solidity 0.8.28 · Foundry · OpenZeppelin 5.x · runs in Remix with no setup
New here?
REMIX.mdgets you from a browser tab to a live liquidation in nine clicks.
| Supply | Deposit any listed asset, earn the supply rate, use it as collateral |
| Borrow | Take out any other listed asset against your whole portfolio |
| Repay | Partially or in full, from any address |
| Withdraw | Any amount that leaves your health factor above 1 |
| Liquidate | Clear an unhealthy user's debt, take their collateral at a discount |
| Earn | Interest accrues per second via index maths, never by looping over users |
Four markets out of the box — WETH, WBTC, USDC, DAI — with deliberately different decimals (18 / 8 / 6 / 18), because that's what exercises the unit conversions.
LendingPoolConfigurator <- Ownable2Step, risk params
|
v
WETHGateway ------------> LendingPool <---------- LendifyDataProvider
(ETH <-> WETH) (all accounting) (read-only UI views)
/ | \
/ | \
v v v
PriceOracle InterestRateModel ERC-20 assets
| (kinked curve)
v
Chainlink feeds
Contracts
| File | Role |
|---|---|
LendingPool.sol |
Supply, borrow, repay, withdraw, liquidate. All state. |
PriceOracle.sol |
Chainlink adapter. Normalises every feed to 1e18 USD, rejects bad data. |
InterestRateModel.sol |
Kinked rate curve. Stateless, immutable, shared across reserves. |
LendingPoolConfigurator.sol |
Two-step-owned admin surface. Lists markets, sets risk params. |
LendifyDataProvider.sol |
Aggregate views, max-borrow / max-withdraw, health-factor previews. |
WETHGateway.sol |
Native ETH in and out. Keeps payable out of the core. |
Libraries — all internal, so they inline at compile time. Nothing to link.
| File | Role |
|---|---|
ReserveLogic.sol |
Index accrual, rate refresh, available liquidity |
ValidationLogic.sol |
Preconditions and risk-parameter sanity checks |
UserConfiguration.sol |
2-bits-per-reserve position bitmap |
WadRayMath.sol |
1e18 / 1e27 fixed point with explicit rounding direction |
PercentageMath.sol |
Basis points |
MathUtils.sol |
Linear and compounded interest factors |
DataTypes.sol / Errors.sol |
Structs and custom errors |
Interest accrues through indexes, not through loops.
Paying interest by iterating over every supplier would cost unbounded gas and stop working the moment the protocol got popular. Instead each reserve keeps two numbers that only ever go up:
liquidityIndex accrues to suppliers
borrowIndex accrues to borrowers
Balances are stored scaled — divided through by the index at the time of the deposit:
scaledBalance = actualBalance / index
actualBalance = scaledBalance * index
Moving the index accrues interest for every user in the reserve at once, in O(1). A single
SSTORE pays a million lenders.
Worked example:
liquidityIndex = 1.00 you deposit 100 USDC scaledBalance = 100
... a year of interest at 5% ...
liquidityIndex = 1.05 your balance reads back 100 * 1.05 = 105 USDC
You were never touched. Nobody wrote to your slot. The index moved.
Suppliers earn simple interest; borrowers pay compound interest. That asymmetry is deliberate: the difference stays in the pool as surplus, which is the direction that protects solvency rather than eroding it.
HF = SUM(collateral_i_usd * liquidationThreshold_i) / SUM(debt_j_usd)
HF < 1 -> liquidatable
no debt -> HF = infinity
Worked example, WETH at an 82.5% threshold:
supply 10 WETH @ $2,000 = $20,000 collateral
borrow 10,000 USDC = $10,000 debt
HF = (20,000 x 0.825) / 10,000 = 1.65 healthy
ETH falls to $1,200:
HF = (12,000 x 0.825) / 10,000 = 0.99 liquidatable
| WETH | WBTC | USDC | DAI | |
|---|---|---|---|---|
| LTV | 80.0% | 70.0% | 77.0% | 77.0% |
| Liquidation threshold | 82.5% | 75.0% | 80.0% | 80.0% |
| Liquidation bonus | 5.0% | 6.5% | 4.0% | 5.0% |
| Reserve factor | 10% | 20% | 10% | 10% |
You may borrow up to LTV, but you are only liquidated past the threshold. The gap between them is the user's safety buffer — without it, every position would open at exactly the liquidation line and a one-cent price move would wipe it out.
WBTC gets a lower LTV, a wider buffer and a fatter liquidator bonus than WETH, despite being the same asset class, because its on-chain liquidity is thinner. Risk parameters are opinions about how hard something is to sell in a hurry.
borrow rate
^
| /
| / slope2 (steep)
| /
| /
| _______________________ / <- kink at optimal utilization
| _______/ slope1 (gentle)
| /
+------------------------------------------> utilization
0 U_optimal 100%
Below the kink, borrowing is cheap. Above it, the rate climbs hard — which simultaneously pays existing suppliers enough to attract new deposits and prices out marginal borrowers. Both push utilisation back down. That feedback loop is what keeps withdrawal liquidity available without anyone actively managing it.
supplyRate = borrowRate * utilization * (1 - reserveFactor)
Multiplying by utilisation spreads borrower interest across all suppliers, including the idle
portion, and guarantees supplyRate <= borrowRate for every possible input — the solvency
condition for the reserve.
Two curves ship:
| kink | slope1 | slope2 | |
|---|---|---|---|
| Volatile (WETH, WBTC) | 45% | 7% | 300% |
| Stablecoin (USDC, DAI) | 90% | 4% | 75% |
Volatile collateral gets an early kink and a brutal slope2, because withdrawal liquidity has to survive a crash — precisely when everyone wants out at once. Stablecoins can run hot safely because their price doesn't gap.
require HF < 1
closeFactor = HF < 0.95 ? 100% : 50%
debtToCover = min(requested, userDebt * closeFactor)
collateralSeized = debtToCover
* price(debt) / price(collateral)
* 10^collateralDecimals / 10^debtDecimals
* (1 + liquidationBonus)
Worked example — ETH at $1,200, liquidator repays 5,000 USDC:
seized = 5,000 / 1,200 * 1.05 = 4.375 WETH = $5,250
($5,000 back, $250 of bonus)
borrower keeps 10 - 4.375 = 5.625 WETH = $6,750
HF after 6,750 * 0.825 / 5,000 = 1.114 healthy again
A liquidator may normally clear only half a position. That protects the borrower — a small price dip shouldn't cost you everything — and spreads liquidations across competing liquidators.
If the price gaps hard enough that collateral is worth less than debt, a 50% cap can leave a
remainder too small to be worth anyone's gas. That dust sits as bad debt forever. Below
HF = 0.95 the close factor becomes 100% so liquidators can clear the whole position.
ValidationLogic.validateReserveConfig enforces:
liquidationThreshold * (1 + liquidationBonus) <= 1
This guarantees a liquidation can never leave a position less healthy than it started. Without it, a high threshold plus a fat bonus lets each liquidation strip more value than the debt it clears, pushing the user further underwater every time — a death spiral the protocol ends up paying for.
threshold 8250, bonus 500 -> 8250 * 10500 = 86,625,000 <= 100,000,000 allowed
threshold 9600, bonus 500 -> 9600 * 10500 = 100,800,000 > 100,000,000 rejected
1. validate
2. accrue interest <- FIRST, so all maths uses fresh indexes
3. mutate scaled balances
4. refresh interest rates <- AFTER, so utilisation is current
5. check health factor
6. emit
7. transfer tokens <- the only external call, and it goes last
Getting 2 and 4 the wrong way round is the classic bug in this design. Accruing after mutating charges a new balance for time it wasn't there; refreshing rates before mutating prices the old utilisation.
nonReentrant sits on top of all four entry points. It's redundant if CEI is correct — which is
exactly why it's there.
PriceOracle.getAssetPrice reverts rather than returning a number it doesn't trust:
| Check | Why |
|---|---|
answer > 0 |
A zero or negative price values collateral at nothing (mass liquidation) or debt at nothing (free money) |
block.timestamp - updatedAt <= heartbeat |
Pricing live collateral off a dead feed during a crash is how protocols get drained |
answeredInRound >= roundId |
Catches a feed carrying a previous answer forward into a new round |
A bare latestAnswer() with none of these has cost real protocols real money.
Available liquidity is derived from internal accounting (totalSupply - totalDebt), never from
balanceOf(pool). If it used the token balance, anyone could transfer tokens straight to the
pool, inflate liquidity, crash utilisation and cut everyone's borrow rate to nothing for free.
Aave shipped "virtual accounting" in v3.1 for exactly this reason.
Donated tokens here simply sit in the contract and affect nothing.
scenarioAttack_donationDoesNotMoveRates() proves it on-chain.
| Operation | Direction |
|---|---|
| Scaled supply minted on deposit | down |
| Actual supply read on withdraw | down |
| Scaled supply burned on withdraw | up |
| Scaled debt minted on borrow | up |
| Actual debt read on repay | up |
| Scaled debt burned on repay | down |
| Collateral seized in liquidation | down |
Every one rounds so that dust accumulates as surplus rather than as bad debt.
USDC is 6dp, WBTC is 8dp, prices are 8dp from Chainlink, everything internal is 18 or 27. Crossing any two of those mis-values a position by orders of magnitude. The mock tokens ship with four different decimal counts on purpose — a test suite where everything is 18dp passes happily while the protocol is broken for real assets.
initReserve reads decimals() from the token itself rather than trusting the caller.
Ownable2Step throughout. Transferring admin to a mistyped address is unrecoverable and
permanently bricks risk management; requiring the recipient to accept makes that impossible.
LendifySetup hands the configurator role back to a human before it finishes. A setup helper
that keeps keys to the pool it configured is a backdoor, demo or not.
User configuration bitmap. A user's whole cross-asset position map packs into one uint256
— 2 bits per reserve, 128 reserves per slot:
bit 2i user is BORROWING reserve i
bit 2i + 1 user is USING reserve i AS COLLATERAL
The health factor has to be recomputed on every borrow, withdraw and liquidation, and it depends
on the user's entire portfolio. The naive version loops all reserves with an SLOAD and an
oracle call each. This costs one SLOAD total and skips every reserve the user has never
touched — so someone with 2 positions in a 30-asset market pays for 2, not 30.
It also makes "no debt, health factor is infinite" a single comparison instead of a loop.
Other choices: ReserveConfig packs into one slot (uint16 risk params, uint8 decimals,
three bools). Indexes and rates are uint128, timestamp is uint40. Libraries are all
internal so they inline — no DELEGATECALL, no deploy-time linking. Custom errors throughout
rather than revert strings.
contracts/scenarios/LendifyScenarios.sol is a click-to-run integration suite for Remix. Each
function is one end-to-end flow that either completes or reverts with a named assertion — a
green transaction is a passing test. Every intermediate value is emitted as an event, so the
whole computation is readable in the console.
| Scenario | Asserts |
|---|---|
scenario1_supplyAndBorrow |
Collateral values at $20,000, borrowing power is 80% not 82.5%, HF lands on exactly 1.65 |
scenario2_interestAccrues |
Debt never shrinks; totalSupply >= totalDebt; available liquidity reconciles |
scenario3_priceCrash |
ETH $2,000 -> $1,200 takes HF to 0.99 |
scenario4_liquidation |
Close factor caps at 50%, bonus is exactly 5%, HF strictly increases |
scenario5_repayAndWithdrawAll |
No dust debt or dust supply survives a full unwind |
scenarioNegative_cannotBorrowPastLtv |
Over-borrowing reverts |
scenarioNegative_cannotLiquidateHealthy |
Healthy positions are safe |
scenarioAttack_donationDoesNotMoveRates |
Donations move neither utilisation nor rates |
scenario4's third assertion is the important one. A liquidation that leaves a position less
healthy is a death spiral, and it's exactly what the config invariant exists to prevent.
forge init --force --no-commit
forge install OpenZeppelin/openzeppelin-contracts
forge remappings > remappings.txt # required — see the note in foundry.toml
forge buildremappings.txt is generated rather than committed, and is gitignored. If it sits in the repo,
Remix picks it up and rewrites every @openzeppelin/... import to a lib/ path that doesn't
exist in a browser workspace, and nothing compiles. Generate it locally, keep it out of git.
foundry.toml is already configured, including fuzz and invariant profiles. The invariants worth
writing first:
sum(user scaled supplies) == totalScaledSupplyper reservesum(user scaled debts) == totalScaledDebttoken.balanceOf(pool) >= totalSupply - totalDebt— solvency- both indexes monotonically non-decreasing
- no user ends a transaction with
HF < 1except via a price move
Deliberate, and each one is a decision rather than an oversight.
- Fee-on-transfer and rebasing tokens are unsupported. The pool credits
amount, not the delta it actually received. Supporting them means measuring balance before and after every transfer, which costs gas on every path to accommodate a handful of tokens. - Liquidators receive underlying, so a liquidation reverts if the collateral reserve happens to be fully utilised — the pool has the accounting but not the tokens. Aave avoids this by paying liquidators in aTokens, a bookkeeping transfer needing no liquidity. The honest fix here is an option to receive the position itself.
- Compound interest is a 3-term binomial approximation. Accurate to under a basis point at realistic update frequencies, but it drifts to about −154 bps over a year with no interaction at a 75% rate, and it always under-charges. Any supply/borrow/repay resets the clock, so real gaps are minutes. Stated rather than hidden.
- No aTokens or debt tokens. Positions aren't transferable or composable.
- Not upgradeable. Risk parameters are mutable; nothing else is.
- Single oracle source. No fallback feed, no TWAP cross-check.
- Unaudited. Portfolio project. Do not put real money in it.
Roughly in order of value per unit of effort:
- Flash loans — ~80 lines, and a good excuse to write a reentrancy-safe callback
- aTokens / variable debt tokens — transferable positions, and it fixes the liquidation liquidity limitation above
- Isolation mode — cap exposure to newly listed long-tail assets
- UUPS upgradeability with storage gaps
- A formal spec (Certora or Halmos) for the solvency invariant
- Next.js frontend — wagmi v2 + viem, with a live health-factor preview
(
LendifyDataProvider.previewHealthFactorAfterBorrowalready returns the number)
MIT