Skip to content

About

Immutable over-collateralized lending protocol in Solidity/Foundry — kinked rate model, 140 tests with 7 stateful invariants

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

11 Commits

Folders and files

Repository files navigation

KinkPool

An over-collateralized lending protocol in Solidity, built with Foundry — isolated markets (one borrow asset + one collateral asset each), deployable permissionlessly via a factory. Its centerpiece is a standalone kinked interest-rate model, and its defining design choice is that the core pool is immutable: no upgrade proxy and no settable parameters — once deployed, the rules cannot change. The only authorities are explicitly scoped, immutable, and optional (a reserve treasury and an opt-in pause guardian that can halt new supply/borrow but can never trap funds), each set once at deploy and documented below; set them to address(0) for a fully zero-trust market.


The kink: why this curve is the whole point

A lending pool's most important number is utilization — the fraction of supplied liquidity that is currently lent out:

U = borrows / (cash + borrows)

When utilization is low, there is plenty of free liquidity and suppliers can withdraw at will. When utilization approaches 100%, the pool is nearly drained: suppliers who want their money back may not be able to get it. The interest-rate curve is the mechanism that keeps that from happening, and the kink is the heart of it.

KinkPool uses a two-slope ("kinked") borrow-rate curve:

                                         ▲ borrow rate
                                         │                         ╱
                                         │                       ╱   ← slope2 (steep:
                                         │                     ╱        the "wall")
                                         │                   ╱
                                         │                 ╱
              rate at kink ── ── ── ── ──┼───────────────●  ← kink (e.g. 80% utilization)
                                         │          ╱
                                         │     ╱  ← slope1 (gentle)
                              base rate ─┤╱
                                         └──────────────────────────► utilization U
                                         0%        80%            100%
rate(U) = baseRate + slope1 · min(U, kink) + slope2 · max(U − kink, 0)
  • Below the kink the rate rises gently along slope1 — borrowing is cheap, the market is healthy, suppliers earn a modest yield.
  • Above the kink the rate jumps onto a much steeper slope2 — a deliberate wall. Borrowing becomes expensive precisely when withdrawable liquidity is running low. That spike pushes borrowers to repay and pulls new suppliers in, driving utilization back down and protecting suppliers' ability to withdraw.

This is the behavior that actually matters operationally in a lending market: it is the high-utilization regime, not the average case, where a pool either protects its suppliers or runs out of withdrawable cash. KinkPool isolates that logic in its own readable contract — src/InterestRateModel.sol — so the rate behavior can be reviewed and unit-tested in one short, self-contained file.

Reference parameters used in this MVP:

param value meaning
baseRate 0% APR borrow rate at 0% utilization
slope1 4% APR gentle slope below the kink
kink 80% utilization where the curve bends sharply upward
slope2 75% APR steep slope above the kink

All rates are stored per second (APR / 365 days) so interest accrual is a single multiply by elapsed time. Supplier yield is derived from borrower interest: supplyRate = U · borrowRate · (1 − reserveFactor).


Immutable by design — a deliberate trust statement

The core LendingPool has no upgrade slot and no setters. Every risk parameter — collateral factor, liquidation bonus, reserve factor, close factor, liquidation fee, the rate model, the oracle — is fixed in the constructor and can never be changed.

This is intentional, and it is the point. "The rules cannot be changed" is a claim that many protocols make and few actually satisfy, because an upgradeable proxy or an admin key means the rules can be changed by whoever holds that key. KinkPool makes the stronger commitment: once deployed, the contract's parameters and logic are frozen, and no address can seize funds, alter parameters, or trap your withdrawals.

Production needs a few capabilities a purely frozen contract cannot offer (collecting revenue, responding to an emergency). KinkPool adds them as explicitly scoped, immutable, optional authorities rather than hiding centralization: a reserve treasury and a pause guardian (next section), plus the price oracle (the trust seam, below). Each is fixed at deploy, each is strictly bounded, and each can be set to address(0) to opt out entirely.

Production status (Phase 2). KinkPool is being extended toward production grade. The per-market LendingPool stays 100% immutable; production features add explicitly scoped, documented trust seams, each fixed at deploy. See docs/next-steps.md for the live roadmap and docs/architecture.md Part II for each design decision.

Production trust additions

  • Reserve treasury (reserveRecipient). A fixed, immutable beneficiary set at deploy. It may withdraw ONLY accrued protocol reserves (the reserveFactor slice of interest), capped at totalReserves and at liquid cash. It cannot change parameters, pause the market, or touch supplier/borrower principal. Set it to address(0) for a fully immutable, no-treasury market where reserves stay permanently stranded (the MVP behavior). This is a bounded, documented trust seam — not an admin key over the market.
  • Pause guardian (pauseGuardian). An optional, immutable address that can pause ONLY supply and borrow (new inflows / new debt). It can never block withdraw, withdrawCollateral, repay, or liquidate, so it can never trap user funds or stop you from exiting or repaying; it also cannot change any parameter or move any funds. This is the deliberate bound that lets an otherwise-immutable market respond to an emergency without becoming custodial. Set it to address(0) for a permanently unpausable market.
  • Market factory (MarketFactory). Multi-market support via a registry that deploys isolated, individually-immutable markets. Market creation is permissionless — anyone can deploy a market with any config, and a bad market can never affect another (no cross-collateral; the trade-off is no portfolio cross-margin). The factory's Ownable2Step owner controls only an advisory isCurated flag — a frontend endorsement signal with zero effect on any market's behavior or funds. The owner cannot gate creation, alter, pause, or touch any market.

Trust model — the price oracle is the one trust seam

Health checks and liquidations need the collateral price in terms of the borrow asset:

healthFactor(user) = collateralValue · collateralFactor / debt        (healthy iff ≥ 1.0)

For this MVP that price comes from MockPriceOracle, an owner-settable contract. This is the single centralized trust point in the entire system, and it is called out on purpose rather than buried:

  • Unlike the immutable pool, the mock oracle has an owner who can move the price. On a testnet that is exactly what you want — it lets you demonstrate borrowing, health factors, and liquidations on demand.
  • Production path (built): ChainlinkPriceOracle implements the same minimal IPriceOracle.getPrice() interface, so it is a drop-in replacement — no change to the pool. It prices collateral as collateral/USD ÷ borrow/USD from Chainlink feeds (the borrow feed is optional; if omitted the borrow asset is treated as USD-pegged), normalizes feed decimals to 1e18, and is fail-closed: it reverts on a non-positive answer, an incomplete round, a feed stale beyond its configured heartbeat, or a price outside an optional [min, max] band. The adapter is itself immutable (no owner). New trust assumption: the configured Chainlink feeds are live, correct, and refreshed within their heartbeat. When a feed is unhealthy, the pool's price-reading actions (borrow, withdrawCollateral, liquidate, healthFactor) halt while supply and repay keep working — a deliberate circuit-breaker trade-off favoring safety over availability. Deploy via script/DeployChainlink.s.sol.

A real deployment is only as trustworthy as its oracle; KinkPool makes that dependency explicit instead of pretending it away.


How it works

Suppliers deposit the borrow asset (a mock USDC) and earn interest. Borrowers post collateral (a mock WETH) and borrow against it up to a fixed collateral factor (LTV). Interest accrues to global indices, not to individual balances, so the pool charges and pays interest to any number of users in O(1) — no per-user loops.

supplyBalance(u) = scaledSupplyOf[u] · supplyIndex
borrowBalance(u) = scaledBorrowOf[u] · borrowIndex

Each user stores a scaled principal; their live balance is that principal times the current index. Accrual simply grows the indices.

Core actions (all guarded by nonReentrant, ordered accrue → checks → effects → interactions, with SafeERC20 transfers and rounding always in the protocol's favor):

action what it does
supply / withdraw lend the borrow asset / redeem it with accrued interest
depositCollateral / withdrawCollateral post / remove collateral (must stay healthy)
borrow / repay draw a loan against collateral / repay it (max repays in full)
liquidate repay up to closeFactor of an unhealthy borrower's debt and seize collateral at a bonus (minus a protocol fee)

Inbound transfers are measured by balance delta, so a fee-on-transfer token can never desync the pool's internal accounting (verified in tests).

Liquidations are configurable and partial. Each liquidation may repay at most closeFactor · debt (e.g. 50%), spreading liquidations and softening the borrower penalty; the cap is bypassed when a position is underwater (collateralValue ≤ debt) so toxic positions can be fully cleared. A protocol liquidationFee takes a share of the seized collateral (out of the liquidator's bonus) into collateralReserves, withdrawable by the treasury. Both closeFactor and liquidationFee are immutable, fixed at deploy.


Project layout

src/
├── InterestRateModel.sol          # the kinked curve (standalone, pure) — the highlight
├── LendingPool.sol                # immutable over-collateralized core + index accrual
├── MarketFactory.sol              # permissionless deployer + curated registry of markets
├── interfaces/
│   ├── IInterestRateModel.sol
│   ├── IPriceOracle.sol
│   ├── ILendingPool.sol
│   └── AggregatorV3Interface.sol  # minimal local Chainlink ABI
├── oracle/
│   ├── MockPriceOracle.sol        # owner-settable price — the testnet trust seam
│   └── ChainlinkPriceOracle.sol   # production adapter: fail-closed, staleness-checked
└── mocks/
    ├── MockERC20.sol              # 18-decimal faucet token (USDC/WETH stand-ins)
    └── MockChainlinkAggregator.sol

test/                              # 140 tests: unit, scenario, fuzz, 7 stateful invariants
script/
├── Deploy.s.sol                   # demo deploy (mock tokens + mock oracle)
├── DeployChainlink.s.sol          # production deploy (existing assets + Chainlink oracle)
└── DeployFactory.s.sol            # deploy the MarketFactory
docs/                              # architecture.md (as-built, Parts I & II) + next-steps.md

Build & test

This project uses Foundry. Install the toolchain and the two dependencies (OpenZeppelin Contracts v5.1.0, forge-std v1.9.4):

# 1. install Foundry
curl -L https://foundry.paradigm.xyz | bash && foundryup

# 2. restore dependencies into lib/ (gitignored)
forge install foundry-rs/forge-std@v1.9.4
forge install OpenZeppelin/openzeppelin-contracts@v5.1.0

# 3. build & test
forge build
forge test                 # 140 tests, incl. 7 stateful invariants (128k calls each)
forge coverage --ir-minimum   # --ir-minimum is required (the project uses via_ir)

Remappings are declared in foundry.toml / remappings.txt. The project compiles with the IR pipeline (via_ir = true), so coverage must be run with --ir-minimum.


Deploy to Sepolia

cp .env.example .env        # then fill in RPC URL, private key, Etherscan key
source .env

forge script script/Deploy.s.sol:Deploy \
    --rpc-url $SEPOLIA_RPC_URL \
    --private-key $PRIVATE_KEY \
    --broadcast --verify --etherscan-api-key $ETHERSCAN_API_KEY

Three deploy scripts are provided:

  • Deploy.s.sol — demo market with mock tokens + mock oracle (fastest path to a live demo).
  • DeployChainlink.s.sol — production market over existing ERC20s priced by a Chainlink feed.
  • DeployFactory.s.sol — the MarketFactory; markets are then created permissionlessly via factory.createMarket(config).

All risk parameters (collateral factor, liquidation bonus/fee, close factor, reserve factor, reserve recipient, pause guardian) are environment-overridable — see .env.example.

The script deploys both mock tokens, the oracle, the rate model, and the wired LendingPool, printing every address. Verified source on Etherscan is part of the point: with no admin keys, the verified bytecode is the trust guarantee.

A suggested demo loop: supply USDC → post WETH → borrow → lower the oracle price → liquidate.


Security posture

  • Immutable core: no upgrade proxy and no settable parameters on the pool; the only authorities are the optional, immutable, strictly-bounded reserveRecipient and pauseGuardian (each address(0)-disableable).
  • Reentrancy: nonReentrant on every external state-changing function, plus strict checks-effects-interactions ordering.
  • Safe transfers: SafeERC20 throughout; inbound amounts measured by balance delta to tolerate fee-on-transfer tokens.
  • Rounding always favors the protocol: debt rounds up, redemptions round down, seized collateral rounds down — no dust-draining.
  • Reserves: a reserveFactor slice of interest is realized into totalReserves and is withdrawable only by the fixed, immutable reserveRecipient (treasury), capped at the reserve balance and liquid cash. Interest is credited to suppliers/reserves from the realized debt-value delta, so the conservation identities hold exactly under fuzzing.
  • Coverage: 140 tests (95.8% lines / 98.2% functions; InterestRateModel & MarketFactory 100%) including 7 stateful invariants — solvency / no money printed, reserve conservation, scaled-supply & scaled-borrow consistency, monotonic indices, collateral backing — run over 128k randomized calls per invariant, holding exactly with no rounding tolerance.

What Phase 2 added

The MVP shipped a single immutable market with a mock oracle. Production expansion (see docs/next-steps.md and docs/architecture.md Part II) added, each as a logged increment:

  • Reserves & treasury — interest reserves are a real liability, withdrawable by an immutable reserveRecipient. (Interest is credited from the realized debt-value delta, so the solvency/conservation invariants hold exactly under fuzzing.)
  • Chainlink oracle adapter — fail-closed ChainlinkPriceOracle (staleness/heartbeat, positive-answer, bounds), drop-in behind IPriceOracle.
  • Configurable partial liquidations — immutable closeFactor + protocol liquidationFee to a collateral reserve; underwater positions bypass the cap.
  • Bounded pause guardian — optional, immutable; pauses supply/borrow only, never exits.
  • Multi-market — permissionless MarketFactory + curated registry; isolated markets.

Known limitations & future work (out of scope by design)

Recorded here rather than implemented, to keep each market immutable and auditable:

  • Isolated markets only — one borrow asset + one collateral asset per market; no cross-collateral / portfolio cross-margin across markets (the isolation trade-off).
  • 18-decimal assets assumed — the pool rejects non-18-decimal tokens at construction; health-factor math skips decimal normalization. A production build would normalize per token decimals (the Chainlink adapter already normalizes feed decimals).
  • Bad debt can persist — if collateral is fully seized while debt remains (a severe, fast crash), the residual debt stays on the books. No socialized-loss / insurance fund.
  • No flash loans, no rate-model governance, no upgradeability — explicitly excluded; the per-market contract is immutable by design.

License

MIT.

About

Immutable over-collateralized lending protocol in Solidity/Foundry — kinked rate model, 140 tests with 7 stateful invariants

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages