Architecture
UnitFlow Lending is composed of six smart contracts deployed on Arc Testnet, a Next.js frontend, and a set of wagmi-based React hooks. The design follows an Aave v2-inspired pattern: a central pool contract delegates interest rate calculation to a separate model contract and delegates liquidation logic to a dedicated engine.
Contract System
LendingPool ──────────────────────────────────────────────────────┐
│ │
├── reads rates from ──▶ InterestRateModel │
│ └── calculates supply/borrow APR │
│ from utilisation ratio │
├── reads prices from ──▶ PriceOracle │
│ └── 8-decimal USD prices per asset │
├── mints/burns ──────────▶ uToken (uUSDC / uEURC) │
│ └── ERC-20, balance = scaled supply │
├── mints/burns ──────────▶ dToken (dUSDC / dEURC) │
│ └── ERC-20, balance = scaled debt │
└── fees routed to ──────▶ FeeDistributor │
│
LiquidationEngine ◀──────────────────────────────────────────────────┘
└── calls LendingPool.userCollateral() + getUserAccountData()
└── calls LendingPool.liquidate() to seize collateralLendingPool
The single entry point for all user interactions. It maintains a ReserveData struct per asset and a userCollateral mapping tracking each user's deposited balance per asset. On every state-changing call it updates the liquidity index and borrow index before executing the action, ensuring interest is always current.
- supply(asset, amount, onBehalfOf) — transfers tokens in, updates the liquidity index, mints uTokens proportional to the scaled amount.
- withdraw(asset, amount, to) — burns uTokens, redeems underlying. Reverts if the resulting health factor would fall below 1.0.
- borrow(asset, amount, onBehalfOf) — checks available borrow capacity, mints dTokens, transfers underlying to the borrower.
- repay(asset, amount, onBehalfOf) — accepts
type(uint256).maxto repay the full debt. Burns dTokens, transfers tokens back into the pool. - getUserAccountData(user) — returns
(totalCollateralUSD, totalDebtUSD, healthFactor, availableBorrowsUSD)all in 8-decimal USD. - reserves(asset) — returns the full
ReserveDatastruct including indices, rates, totals, and flags. - getUtilizationRate(asset) — returns the current utilisation as a RAY-scaled fraction.
InterestRateModel
Implements a two-slope utilisation curve. Below the optimal utilisation point the borrow rate rises gradually; above it the rate rises steeply to incentivise repayment and attract new supply. The supply rate is derived from the borrow rate weighted by utilisation:
// All values RAY-scaled (1e27)
utilizationRate = totalBorrows / totalLiquidity
if utilizationRate <= OPTIMAL_UTILIZATION:
borrowRate = BASE_RATE + utilizationRate * SLOPE1 / OPTIMAL_UTILIZATION
else:
excessUtil = utilizationRate - OPTIMAL_UTILIZATION
borrowRate = BASE_RATE + SLOPE1 + excessUtil * SLOPE2 / (RAY - OPTIMAL_UTILIZATION)
supplyRate = borrowRate * utilizationRate / RAY // weighted by utilisationRates are expressed as per-second RAY values. The frontend converts them to annual percentages using:
APR% = rateRay * SECONDS_PER_YEAR / RAY * 100
// SECONDS_PER_YEAR = 31_536_000PriceOracle
Returns 8-decimal USD prices for each supported asset. On Arc Testnet, USDC is priced at exactly 100_000_000 ($1.00) and EURC at 108_000_000 ($1.08). The oracle is used by both the pool (for borrow capacity) and the liquidation engine (for collateral valuation).
uTokens — Interest-Bearing Supply Tokens
When a user supplies an asset, the pool mints uTokens (uUSDC or uEURC) representing their scaled share of the pool. The conversion uses the current liquidity index:
scaledAmount = amount * RAY / liquidityIndex // uToken.mint(user, scaledAmount) // At any later time, the redeemable value is: redeemable = scaledAmount * currentLiquidityIndex / RAY
Because the liquidity index only ever increases (interest accrues), the uToken balance in underlying terms grows over time without any rebasing. The ERC-20 balanceOf returns the scaled amount; the pool computes the actual underlying value on demand.
dTokens — Debt Tracking Tokens
Borrowing mints dTokens (dUSDC or dEURC) using the borrow index in the same way:
scaledDebt = amount * RAY / borrowIndex // dToken.mint(user, scaledDebt) // Outstanding debt at any time: debt = scaledDebt * currentBorrowIndex / RAY
dTokens are non-transferable. They exist solely to track each user's debt share so that interest compounds correctly without iterating over all borrowers.
LiquidationEngine
A standalone contract that any wallet can call to liquidate an unhealthy position. It reads health factors from the pool, enforces the 50% close factor, and executes the collateral seizure atomically.
- getHealthFactor(user) — returns the WAD-scaled health factor for any address. Used by the frontend to populate the liquidation dashboard.
- liquidate(borrower, collateralAsset, debtAsset, debtToCover) — the liquidator supplies
debtToCovertokens ofdebtAssetand receivescollateralAssetat a discount. Reverts if the borrower's health factor is ≥ 1.0 or ifdebtToCoverexceeds 50% of outstanding debt.
Index Math — How Interest Accrues
Both the liquidity index and borrow index are RAY-scaled (10²⁷) accumulators that grow monotonically. On every pool interaction the indices are updated:
// Time elapsed since last update Δt = block.timestamp - lastUpdateTimestamp // New index = old index × (1 + rate × Δt) // Using linear approximation (safe for short intervals): newLiquidityIndex = liquidityIndex + liquidityIndex * liquidityRate * Δt / RAY newBorrowIndex = borrowIndex + borrowIndex * borrowRate * Δt / RAY
This means interest compounds on every transaction that touches the pool, not just once per block. The longer the interval between updates, the larger the single step — but the total accrued interest is the same as continuous compounding for practical time horizons.
Health Factor
The health factor is the ratio of risk-adjusted collateral to total debt, both in USD:
healthFactor = (totalCollateralUSD × liquidationThreshold) / totalDebtUSD
= (totalCollateralUSD × 0.6667) / totalDebtUSD
// WAD-scaled: healthFactor = result × 1e18
// healthFactor < 1e18 → position is liquidatable
// healthFactor = MaxUint256 → no debt (displayed as ∞)The frontend colour-codes health factors:
| Range | Colour | Meaning |
|---|---|---|
| ≥ 2.0 | Green | Safe |
| 1.5 – 2.0 | Yellow | Moderate risk |
| 1.1 – 1.5 | Orange | High risk |
| < 1.1 | Red (pulsing) | Critical — near liquidation |
Frontend Architecture
The frontend is built with Next.js 15 (App Router), TypeScript, Tailwind CSS, wagmi v2, viem, TanStack Query, and Framer Motion. All on-chain reads use wagmi's useReadContracts with multicall batching — a single RPC round-trip fetches reserve data for all assets simultaneously.
Data Flow
useLendingPool └── LendingPool.reserves(USDC) → ReserveData (batched) └── LendingPool.reserves(EURC) → ReserveData (batched) └── LendingPool.getUtilizationRate(x2) → RAY utilisation (batched) └── derives: supplyApy, borrowApy, utilizationRate, TVL useUserPosition(address) └── LendingPool.getUserAccountData() → collateral, debt, HF, available └── LendingPool.reserves(x2) → current indices └── uToken.balanceOf(x2) → scaled supply per asset └── derives: netWorth, borrowingPowerUsed%, per-asset breakdown useLiquidatablePositions └── publicClient.getLogs(Supply event) → unique borrower addresses └── LiquidationEngine.getHealthFactor() → HF per borrower (batched) └── LendingPool.getUserAccountData() → debt/collateral for at-risk └── LendingPool.userCollateral() → per-asset collateral amounts
Write Hooks
Each action (supply, borrow, repay, withdraw, liquidate) has a dedicated hook that manages a local state machine with steps: idle → approving → [action] → success | error. After any successful write, queryClient.invalidateQueries() is called with no filter, busting wagmi's entire read cache so all hooks refetch on the next render. Pool state typically updates within one block (~1 second on Arc).
ERC-20 Approval Pattern
Supply, repay, and liquidate all require the user to approve the pool (or liquidation engine) to spend their tokens. The hooks check the current allowance before submitting the action transaction. If approval is needed, the hook executes two sequential transactions: approve(spender, MaxUint256) then the action. The UI shows the current step (Approving… / Supplying…) in the button label.
UI Components
| Component | File | Purpose |
|---|---|---|
| LendingPage | app/lending/page.tsx | Root layout — protocol stats bar, market grid, position/action panels |
| MarketCard | app/lending/components/MarketCard.tsx | Per-reserve card with utilisation ring, APY stats, animated bar |
| PositionPanel | app/lending/components/PositionPanel.tsx | User's net worth, collateral, debt, health factor, per-asset breakdown |
| ActionPanel | app/lending/components/ActionPanel.tsx | Pill-tab interface for Supply / Borrow / Repay / Withdraw |
| LiquidationDashboard | app/lending/components/LiquidationDashboard.tsx | Collapsible table of at-risk positions with one-click liquidate buttons |