Skip to content

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Lendify

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.md gets you from a browser tab to a live liquidation in nine clicks.


What it does

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.


Architecture

                         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

The one idea worth understanding

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.


Health factor

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

LTV and liquidation threshold are two different numbers

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.


Interest rate curve

 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.


Liquidations

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

The 50% close factor

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.

Bad debt, and why the close factor jumps to 100%

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.

The config invariant that makes liquidation safe

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

Security

Ordering — every mutating function, no exceptions

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.

Oracle validation

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.

Donation attack — structurally impossible here

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.

Rounding always favours the protocol

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.

Decimals

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.

Access control

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.


Gas notes

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.


Testing

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.

Adding Foundry tests

forge init --force --no-commit
forge install OpenZeppelin/openzeppelin-contracts
forge remappings > remappings.txt      # required — see the note in foundry.toml
forge build

remappings.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:

  1. sum(user scaled supplies) == totalScaledSupply per reserve
  2. sum(user scaled debts) == totalScaledDebt
  3. token.balanceOf(pool) >= totalSupply - totalDebt — solvency
  4. both indexes monotonically non-decreasing
  5. no user ends a transaction with HF < 1 except via a price move

Known limitations

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.

Future work

Roughly in order of value per unit of effort:

  1. Flash loans — ~80 lines, and a good excuse to write a reentrancy-safe callback
  2. aTokens / variable debt tokens — transferable positions, and it fixes the liquidation liquidity limitation above
  3. Isolation mode — cap exposure to newly listed long-tail assets
  4. UUPS upgradeability with storage gaps
  5. A formal spec (Certora or Halmos) for the solvency invariant
  6. Next.js frontend — wagmi v2 + viem, with a live health-factor preview (LendifyDataProvider.previewHealthFactorAfterBorrow already returns the number)

License

MIT

About

A decentralized lending protocol in Solidity — supply, borrow, repay, and liquidate across cross-asset collateral. Index-based interest accrual, Chainlink price feeds, kinked rate curves, and a full liquidation engine. Runs standalone in Remix, no deploy scripts needed.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages