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.
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).
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
LendingPoolstays 100% immutable; production features add explicitly scoped, documented trust seams, each fixed at deploy. Seedocs/next-steps.mdfor the live roadmap anddocs/architecture.mdPart II for each design decision.
- Reserve treasury (
reserveRecipient). A fixed, immutable beneficiary set at deploy. It may withdraw ONLY accrued protocol reserves (thereserveFactorslice of interest), capped attotalReservesand at liquidcash. It cannot change parameters, pause the market, or touch supplier/borrower principal. Set it toaddress(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 ONLYsupplyandborrow(new inflows / new debt). It can never blockwithdraw,withdrawCollateral,repay, orliquidate, 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 toaddress(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'sOwnable2Stepowner controls only an advisoryisCuratedflag — 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.
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):
ChainlinkPriceOracleimplements the same minimalIPriceOracle.getPrice()interface, so it is a drop-in replacement — no change to the pool. It prices collateral ascollateral/USD ÷ borrow/USDfrom 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 configuredheartbeat, 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 whilesupplyandrepaykeep working — a deliberate circuit-breaker trade-off favoring safety over availability. Deploy viascript/DeployChainlink.s.sol.
A real deployment is only as trustworthy as its oracle; KinkPool makes that dependency explicit instead of pretending it away.
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.
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
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.
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_KEYThree 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— theMarketFactory; markets are then created permissionlessly viafactory.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.
- Immutable core: no upgrade proxy and no settable parameters on the pool; the only
authorities are the optional, immutable, strictly-bounded
reserveRecipientandpauseGuardian(eachaddress(0)-disableable). - Reentrancy:
nonReentranton every external state-changing function, plus strict checks-effects-interactions ordering. - Safe transfers:
SafeERC20throughout; 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
reserveFactorslice of interest is realized intototalReservesand is withdrawable only by the fixed, immutablereserveRecipient(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.
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 behindIPriceOracle. - Configurable partial liquidations — immutable
closeFactor+ protocolliquidationFeeto a collateral reserve; underwater positions bypass the cap. - Bounded pause guardian — optional, immutable; pauses
supply/borrowonly, never exits. - Multi-market — permissionless
MarketFactory+ curated registry; isolated markets.
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.
MIT.