Appearance
L3-2: Lending App
1. Problem
The core challenge of DeFi lending protocols is: how do you safely lend funds in a decentralized, KYC-free environment? Traditional banks rely on credit scores and collateral custody to manage risk, but smart contracts cannot perform off-chain credit assessments. Therefore, DeFi lending must adopt an over-collateralization model — borrowers need to deposit collateral worth more than the loan value. But this raises a cascade of design questions: how to determine the collateral ratio? How should interest rates adjust dynamically? When collateral value drops, how do you protect depositor funds?
A complete decentralized lending protocol must precisely address all of the above while ensuring the contract's logical correctness and fund security. Major protocols like Aave and Compound have validated this model, collectively managing hundreds of billions in TVL.
2. Rationale
Lending is the highest-TVL vertical among DeFi's three pillars (trading, lending, derivatives). Understanding the internal mechanics of lending protocols — from the shares model for deposit interest accrual, to health factor calculation, to liquidation logic — is a core competency for DeFi developers. This knowledge directly applies to evaluating and building lending protocols, leverage strategies, and liquidation bots.
The unique aspect of over-collateralized lending is that it creates a trustless credit market: borrowers do not rely on credit scores but on the transparent value of on-chain collateral. Depositors earn interest by taking on the default risk of borrowers (in the event of liquidation, depositor funds are protected by liquidators buying the collateral). This self-executing liquidation mechanism is the core innovation that distinguishes DeFi lending from traditional finance.
3. Solution
LendingApp.sol implements a simplified lending protocol with four core functions: deposit, borrow, repay, and liquidate.
Deposit Mechanism: users call deposit() to deposit ERC20 tokens into the contract. Deposits serve as the liquidity pool for lending. The total deposited amount is tracked in totalDeposited.
Borrow Mechanism: users call borrow() to borrow tokens. Before borrowing, the health factor must be checked — the ratio of collateral value to borrow value. The contract uses COLLATERAL_RATIO = 150%, meaning you need to deposit $150 in collateral to borrow $100. The health factor is calculated via _healthFactor(): hf = (collateralValue * 100) / debtValue, where debtValue = borrowed * borrowIndex.
Interest Rate Model: uses a simplified linear interest accumulator. borrowIndex starts at 1e18 (1.0) and grows over time. _accrueInterest() is called before every state change, accumulating interest for the elapsed time: annual rate 5%, calculated linearly per second. The borrower's actual debt = borrowed * borrowIndex / borrowerIndexAtBorrowTime.
Liquidation Mechanism: when the health factor falls below COLLATERAL_RATIO (150%), anyone can call liquidate() to repay the borrower's debt and receive the collateral plus a 10% liquidation bonus. The liquidator pays a debt amount equal to the borrower's total borrowed, and receives collateral = debtValue * (100 + 10) / 100, converted back to the deposit token.
Reference source file: src/level3/LendingApp.sol
4. Pitfalls Encountered
- Interest accrual precision loss: integer division in
_accrueInterest()can truncate theinterestFactorto 0 when the time period is short - Numeric overflow during liquidation:
collateralToSeizeis calculated asdebtValue * (RATIO_PRECISION + LIQUIDATION_BONUS) / RATIO_PRECISION, butdebtValueitself may already be a large number due to borrow index inflation - Health factor check after withdrawal: the
withdraw()function must verify the health factor both before and after the hypothetical withdrawal; missing this check could leave the position immediately unhealthy after withdrawal - Double collection from liquidator: in
liquidate(), the liquidator needs totransferFromto pay the debt while the contract also needs totransferto return collateral; iftransferFromfails but collateral state has already been modified, it leads to state inconsistency - Uninitialized borrow index for new users: when a new user borrows,
borrowIndex[user]is 0 and requires special handling to avoid division-by-zero errors
5. Root Causes of Pitfalls
Interest accrual precision loss is the most common issue. In Solidity, integer division truncates toward zero. When INTEREST_RATE * timeElapsed is smaller than 365 days * 100, the interestFactor computes to 0, causing interest to accumulate at zero. This is a classic EVM precision problem — because Solidity has no floating-point numbers, all ratio calculations require scaling to higher precision units before division.
In LendingApp.sol, the _accrueInterest() interest formula is:
solidity
interestFactor = (INTEREST_RATE * timeElapsed * 1e18) / (365 days * 100);
When timeElapsed is small (e.g., one block time of 12 seconds), 5 * 12 * 1e18 / (31536000 * 100) = a very small number. If this value is less than 1 (at 1e18 precision), the division result is 0, and the subsequent borrowIndex = borrowIndex + (borrowIndex * 0) / 1e18 also does not change the index. While the rounding for a single block seems negligible, the cumulative effect can make the actual interest rate far lower than expected.
6. How to Resolve the Pitfalls
Interest accrual: for short periods (minutes to hours), truncating to a 0 rate is acceptable because the rate itself is very small. For scenarios requiring higher precision, you can increase intermediate precision (e.g., using 1e27 precision, i.e., RAY units), or use the exponential accrual formula borrowIndex = borrowIndex * (1e18 + ratePerSecond * elapsed) / 1e18 to reduce the impact of single-step truncation. The key is to check if (interestFactor > 0) in _accrueInterest() and only update the index when interest has actually accrued.
Liquidation calculation: compute the final result before modifying state. Use ReentrancyGuard (already inherited by the contract) to prevent reentrancy attacks. In liquidate(), debtAmount and collateralSeized are computed before transferFrom, and pos.borrowed is zeroed before token transfers, following the CEI (Checks-Effects-Interactions) pattern.
Withdrawal verification: in withdraw(), strictly follow the logical order: first _accrueInterest(), then compute the health factor, then check whether the post-withdrawal health factor meets the requirement, and only finally execute state updates and token transfers. The explicit check remainingCollateral == 0 && pos.borrowed > 0 prevents the case of having debt but zero collateral.
7. Technical Highlights
| Technique | Description |
|---|---|
| Over-collateralization Ratio | 150% (COLLATERAL_RATIO), deposit $150 to borrow $100 |
| Health Factor | collateralValue * 100 / debtValue, below 150% is liquidatable |
| Liquidation Bonus | 10% (LIQUIDATION_BONUS), liquidator receives collateral + 10% premium |
| Interest Rate Model | Simplified linear accrual, 5% annual, per-second calculation |
| borrowIndex | Global borrow index, initialized at 1e18 (18 decimal precision) |
| CEI Pattern | Checks-Effects-Interactions, state updates before external calls |
| ReentrancyGuard | All public functions use the nonReentrant modifier |
| Single Token Model | Deposit and borrow use the same ERC20 token (simplified) |