UnitFlow LogoUnitFlow Docs

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 collateral

LendingPool

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).max to 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 ReserveData struct 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 utilisation

Rates 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_000

PriceOracle

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 debtToCover tokens of debtAsset and receives collateralAsset at a discount. Reverts if the borrower's health factor is ≥ 1.0 or if debtToCover exceeds 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:

RangeColourMeaning
≥ 2.0GreenSafe
1.5 – 2.0YellowModerate risk
1.1 – 1.5OrangeHigh risk
< 1.1Red (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

ComponentFilePurpose
LendingPageapp/lending/page.tsxRoot layout — protocol stats bar, market grid, position/action panels
MarketCardapp/lending/components/MarketCard.tsxPer-reserve card with utilisation ring, APY stats, animated bar
PositionPanelapp/lending/components/PositionPanel.tsxUser's net worth, collateral, debt, health factor, per-asset breakdown
ActionPanelapp/lending/components/ActionPanel.tsxPill-tab interface for Supply / Borrow / Repay / Withdraw
LiquidationDashboardapp/lending/components/LiquidationDashboard.tsxCollapsible table of at-risk positions with one-click liquidate buttons