RFQ-based fixed-rate lending: lend/borrow terms are negotiated off-chain via request-for-quote, signed as EIP-712 intents, and settled and enforced on-chain with collateral, maturity, and repayment. Fully oracle-free — no price is ever read on-chain.
Inspired by Morpho Midnight (fixed-rate, fixed-maturity credit) and Polymarket-style off-chain order flow with on-chain settlement. Built as an engineering artifact, not for TVL.
- A borrower posts an RFQ (loan asset, collateral asset, principal, collateral, maturity) to the RFQ service.
- Makers (lenders) respond with signed
Quotes — EIP-712 structs naming exactprincipalandrepaymentamounts. The rate is implicit, zero-coupon style: no APR, day-count, or rate math exists on-chain. - The borrower picks a quote and calls
Noctua.fill(quote, signature): collateral is escrowed, principal moves maker → borrower. - Until maturity (inclusive) anyone may
repayon the borrower's behalf: repayment moves to the maker, collateral returns to the borrower. - Enforcement is default-at-maturity only: strictly after maturity, anyone may trigger the permissionless
claimDefault; all collateral goes to the maker, pawn-style (surplus is forfeited to the lender). - Makers cancel a single quote by hash, or all outstanding quotes by bumping their nonce.
Every loan is pawn-style — repay by maturity or forfeit the collateral. Quotes may be reserved for a specific taker or left open.
| Path | What |
|---|---|
contracts/ |
Foundry — Noctua.sol settlement + enforcement, QuoteLib EIP-712 hashing |
packages/shared |
TS types, EIP-712 signing/hashing helpers (viem), must byte-match QuoteLib |
services/rfq |
Minimal RFQ service — in-memory MVP: post RFQs, collect signed quotes, validate signatures |
pnpm install
pnpm build:contracts && pnpm test:contracts # forge
pnpm -r build && pnpm -r test # TS workspaces
pnpm check # biomeThe web app and RFQ service are fully env-driven — no code changes are needed for a real deploy, only the steps below.
-
Deploy the contracts. Put a funded deployer key and an Etherscan v2 API key (one key covers all chains) in
contracts/.env(seecontracts/.env.example):DEPLOYER_PRIVATE_KEY=0x... ETHERSCAN_API_KEY=...
Then, from
contracts/(forge auto-loads.env; the RPC alias and Etherscan config live infoundry.toml):forge script script/DeployTestnet.s.sol:DeployTestnet \ --rpc-url base_sepolia --broadcast --verify
This deploys
Noctuaand twoERC20Mocks ("Noctua Mock USDT" / "USDT", "Noctua Mock WETH" / "WETH"). The script logs all three addresses. Note the block number the deploy transactions landed in — that'sSTART_BLOCKbelow. -
Configure the RFQ service (
services/rfq, config insrc/config.ts, all env-driven already):RPC_URL=https://sepolia.base.org \ CHAIN_ID=84532 \ NOCTUA_ADDRESS=<deployed Noctua address> \ START_BLOCK=<deploy block number> \ CONFIRMATIONS=1 \ node services/rfq/dist/index.js
CONFIRMATIONS=1(or a few more) is a reasonable default on a public testnet, vs.0for instant-finality local anvil. -
Configure the web app (
services/web, see.env.example):VITE_CHAIN_ID=84532 VITE_RPC_URL=https://sepolia.base.org VITE_NOCTUA_ADDRESS=<deployed Noctua address> VITE_LOAN_ADDRESS=<deployed USDT mock address> VITE_COLLATERAL_ADDRESS=<deployed WETH mock address>
Connect an injected wallet (MetaMask or similar) on Base Sepolia and use the faucet button in the header to mint test USDT/WETH to your connected address —
ERC20Mock.mintis public, so no separate funding step is required.
The repo ships a single-service Dockerfile: one Node process serves the API (under /api),
runs the chain watcher, and serves the built frontend from the same origin (no CORS, no second
deployment). SQLite lives on a volume.
On Railway: create a service from this repo (it picks up the Dockerfile), attach a volume
mounted at /data, and set these service variables — the VITE_* ones are consumed at image
build time, the rest at runtime:
# build-time (frontend)
VITE_CHAIN_ID=84532
VITE_RPC_URL=https://sepolia.base.org
VITE_NOCTUA_ADDRESS=... VITE_LOAN_ADDRESS=...
VITE_COLLATERAL_ADDRESS=...
# runtime (service + watcher)
CHAIN_ID=84532
RPC_URL=https://sepolia.base.org
NOCTUA_ADDRESS=...
START_BLOCK=<deploy block>
CONFIRMATIONS=1
# eth_getLogs range per call — set to your RPC provider's cap (Alchemy free tier: 10)
MAX_BLOCK_RANGE=2000PORT, DB_PATH=/data/noctua-rfq.db, and STATIC_DIR are preset in the image. GET /health
responds for healthchecks. The public Base Sepolia RPC is rate-limited — a free Alchemy/QuickNode
endpoint is a drop-in RPC_URL/VITE_RPC_URL swap if the watcher gets flaky.
- Implicit zero-coupon over stored-rate or tick-priced units: quotes state amounts, not rates. Tick/unit fungibility (Midnight-style) only pays off in an open orderbook; RFQ quotes are bespoke, so the machinery is dropped.
- Oracle-free by design: no price is ever read on-chain — there's no
oraclefield, no LTV math, no margin-call path. The trust surface is just two signatures and the chain. Lenders price path risk themselves via spread and collateral ratio when they quote, rather than delegating it to an on-chain price feed. - Whole-position fills — no partial fills.
- Calldata-over-storage: the contract stores only
(borrower, status)per loan;repay/claimDefaulttake the fullQuoteagain and re-derive its hash. - Fee-on-transfer / rebasing tokens unsupported. Reentrancy handled by strict checks-effects-interactions.
Not yet built: partial fills, Postgres persistence (SQLite via node:sqlite today), maker deposits/reputation, surplus auction on default.