Sepolia

Architecture

Three layers live in bivium-protocol: the Core that holds series and their accounting, the Exchange that matches signed offers on the tokens the core mints, and a periphery of narrow-permission helpers. Everything else in the stack is a configuration of these, or a reader of them.

Core

The series

Series(lockedAsset A, lockedId, strikeAsset B, ratio, mintEnd, exerciseStart, exerciseEnd, controller)

lockedAsset is an ERC-20, or the core itself when the locked thing is one of the core's own long tokens (lockedId names which). That is the one level of nesting the core allows, and it is what makes a collar expressible: a call written against a lending long. strikeAsset is always an ERC-20. ratio is B per unit of A, scaled by RATIO_SCALE = 1e36, in base units.

A series is identified by the hash of its fields, so the same terms always name the same series and anyone may create it. There is no registry to be admitted to.

Long and short

Minting locks units of A and issues units of long and a share of short. The long is the right to pay the strike and take A out. The short is a pooled claim, and pooled is the important word: shorts are fungible, so two lenders who wrote the same series hold the same token and the same claim. The accounting that makes this O(1) is an aPerShare factor plus a B accumulator, rather than per-writer buckets — which is why a market maker can quote the short side as a single depth and a vault can hold a ladder of them without a position list.

Token ids

A series id is keccak256(abi.encode(series, chainid, core)) & ~1 — domain-bound, so the same terms on another chain or another core deployment are a different series and nothing replays across them. The low bit is cleared, and the pair uses it: the long is seriesId, the short is seriesId | 1. One hash gives both tokens, and id & ~1 recovers the series from either, which is why a lending market keyed by series id is keyed by the long and the short at once.

Generations

When an exercise takes the last A out of a series, that series' generation closes. Its shares retire owed exactly the B they accrued, collectable with collect, and the series restarts at one share per unit of A so new mints can use it again. Holders settle lazily, when next touched, so this stays O(1).

This is what keeps a fully repaid lending market reusable instead of permanently dust-locked, and it is the reason sweep exists: sweep exercises every outstanding long at once at the caller's expense, giving the shorts their B and setting the longs' A aside under claimA, closing the generation. Nobody receives less than if each holder had exercised individually. A controlled series asks ISweepController.canSweep first, and a controller that does not implement it refuses.

TbvController (bivium-protocol-tbv) is such a controller, so a TBV series is never swept. It is still reused: a TBV repay is an exercise through the app, so the repay that takes a market's last vault out closes its generation and the market lends again. Only the floor below has no remedy there, and whole-vault loans keep it out of reach in practice — A per share falls only with repeated near-total repayment while one small loan stays open, all before the market matures. A TBV deployment needs a core with generations; one without them leaves a fully repaid TBV market unable to lend again.

Why the share count has a floor

Two bounds protect the share math, and the first of them answers a real attack rather than a rounding worry.

Every strike payment reaches the shorts through the B accumulator, which divides it across all outstanding shares. Let the share count run far enough ahead of the A behind it and a whole payment rounds to zero per share: the B arrives in the core and no short can ever collect it.

Nothing stops the count from getting there on its own. Exercises take A out while shares stay, so A per share only falls — and a mint into a hollowed-out series is priced at that low A per share, so it issues shares out of all proportion to the A it adds. Repeated hollow-and-refill cycles grow the count geometrically and reach zero-credit territory within a handful of cycles.

MIN_A_PER_SHARE (1e9) is the floor that breaks it: no mint issues more than 1e9 shares per unit of A it adds, so the count grows with the A actually put in rather than geometrically. Below the floor a series is spent — it keeps settling what it owes but takes no new mints, which is exactly what sweep and lending's open sweep exist to clear.

MAX_SHARES (1e36, per generation) is the second bound, because the floor ties the count to the A put in but not to a number — how much A can be put in is the token's supply, not the core's. An ordinary series never approaches it; at one share per unit that is 1e18 whole 18-decimal tokens.

The time fee

Dormant, and bounded in three directions at once so that what a position can ever be charged is knowable when it is opened:

  • Prepaid at mint for the position's whole remaining life, refunded pro-rata on exit.
  • Rate capped at 1% a year of the A locked (MAX_FEE_RATE), and a series keeps the rate in force when its first mint created it — for its whole life and for every later mint into it.
  • Life capped at ten years (MAX_TTM), which with the rate cap bounds the total at 10% of the A locked however high the switch is set. Uncapped, a century-long series at the top rate would prepay as much as the A itself.

The interesting part is the three-day notice on increases. Because a series keeps its creation rate for good, a raise applied immediately could be dropped in front of a user's mint and would then sit in that series permanently. So a raise is announced with the moment it takes effect, no series created before that moment gets it, a raise sent in the same block applies to nobody, and raising again while one is pending restarts the notice rather than inheriting it. Anyone who sees a raise coming has that long to open the series they want at the current rate. Decreases need no notice, since they can only make a new series cheaper.

Flash accounting

All of this runs inside a single unlock entry point with transient per-caller deltas that must net to zero before the session ends. One session can therefore mint, sell the result, and pay for it with the proceeds, with no intermediate approvals or balances. The Exchange runs inside an existing session when there is one, which is how a borrower mints and sells credit in one transaction without ever holding it.

Operations

mint, burnPair, exercise, sweep, collect, claimA, redeem, over ERC-6909 long and short tokens, under a single owner with no other roles.

The controller hook

A series may name a controller. When it does, the core asks it on every mint and every exercise, and asks canReceive of every long or short recipient — whoever the caller is. Exits (burnPair, collect, redeem) never consult it.

This hook is the entire product-extension surface, and the "whoever the caller is" part is what makes it work: because the controller is consulted on the core's operations rather than at a product entry point, the Exchange, Router and MaturitySettler operate on a product's series without knowing anything about that product. A product repo supplies a controller and a terms-to-series mapping. It does not fork the core and it does not need the periphery to be taught about it.

Two products, two controllers

The stack has two lending controllers, and the difference between them is the clearest illustration of what the hook can and cannot express.

LendingController (bivium-lending) is the open case. Markets register permissionlessly, the market id is the series id, borrows and repayments move in whole lots, and an optional AllowlistGate answers canBorrow / canLend. The long is an ordinary transferable token: anyone may repay on a borrower's behalf, because the core's rule is that the collateral goes back to the holder regardless of who paid.

TbvController (bivium-protocol-tbv) is the closed case, and it exists because that last rule is an attack on a TBV market. There, one long covers a group of whole BTC vaults tracked in the app's own ledger. An outsider exercising part of that long without naming a group would leave the app's long balance out of step with its ledger, and an honest borrower could later find themselves unable to repay. So TbvController restricts L1 to being minted to, held by, and exercised by the app alone. Credit (S1) stays unrestricted, so lenders, lenses and market makers see an ordinary market.

A gate on LendingController cannot express this, because canExercise does not look at the caller. That is the boundary: a gate decides who may participate, a controller decides what shape the series' operations may take. Needing the second is what justifies a product's own controller rather than a gate.

Restricting the long costs a borrower nothing it could use elsewhere, because repayment returns each vault to its original depositor whoever pays, so a TBV long has no value outside the app. Its one value inside the app — the upside above the debt — the app sells itself: TbvCallBook writes calls on whole vaults and collars on loan groups as records the app enforces (Lifecycle). That is why bivium-option's CollarRouter, which takes the borrower's L1, has no TBV path, and why the relayer refuses Exchange collar calls on TBV markets. For the same reason MaturitySettler cannot repay a TBV loan: it takes the holder's long, and here only the app holds it. TBV's auto-repay is therefore its own contract, TbvAutoRepay, which the app deploys and drives through a hook only it may call. It pays the keeper in TBVBTC because no part of a vault can be paid out (Lifecycle). The protocol-level default of a transferable long is unaffected everywhere else.

The layering is enforced by CI, not by discipline

tools/check-layering.py fails the build if lending vocabulary appears anywhere in the core's own identifiers. Not a convention — a gate, and the same gate runs in bivium-option.

The reasoning is worth having, because it is the reason this document leads with the derivation direction. A lending noun inside src/ is the product's vocabulary reaching down into the primitive, and it is cheap to let one in and expensive to notice later — the name reads perfectly fine to anyone who arrives thinking of this as a lending protocol, which the sibling lending repository and most writing about the product actively encourage.

Comments are exempt on purpose. Naming the product in a comment is how the layering gets explained; it is identifiers that carry it.

Exchange

One signed Offer, one fill. Prices sit on a logistic tick grid, capped at par. Cost is face × tickToPrice(tick), where face is the token's notional in the quote token — and for a short, face is its share of the still-locked A, so B already accrued stays with the seller.

The Offer is the Exchange's whole surface, and reading its fields is the fastest way to see what the venue can express:

Field What it decides
maker, buy who, and which way
series, long which of the two tokens — the long or the short of that series
quoteToken the series' strike asset, or its locked asset
tick, start, expiry the price on the grid, and the window it is live
group the consumption and cancellation key shared by offers meant to cancel each other
maxUnits / maxAssets exactly one is nonzero: a cap in token units, or in quote-token amount
ratifier who attests it
callback optional just-in-time funding — must be the maker or hold the maker's Exchange operator grant
receiver where the proceeds or tokens go; zero means the maker
allowMint a selling maker may mint the shortfall of its inventory
reduceOnly sell: inventory only, never mint. Buy: only up to the maker's opposite position
fromDeposit a buying maker pays from its Exchange deposit
permit2 how the maker's own tokens are pulled when it funds itself: 0 allowance, 1 Permit2 allowance, 2 a Permit2 signature for this offer alone

Mode 2 fills at most once, because the permit's nonce is spent — the difference between a resting quote and a single-shot one is a field, not a different code path.

Three things are worth knowing from outside:

  • Mint-on-shortfall. If the seller does not hold the token, the fill mints it inside the core session. Primary issuance and secondary transfer are the same call, which is why "borrow" and "buy someone's existing credit" are one code path.
  • Ratifiers. An offer names who attests it. SignatureRatifier takes an EIP-712 signature; SetterRatifier takes on-chain approval, which is the only option open to a contract maker. A product can supply its own: the Earn pool is its own ratifier, which is how its mandate becomes a precondition of every fill rather than a promise.
  • The funding callback (the callback field above) is what lets a vault keep its capital earning elsewhere and draw it just in time, instead of parking it idle to be quotable.

Periphery

Deliberately small, and each piece holds the narrowest right that does the job:

Contract Does Permission it needs
Router One-call mint and exercise for EOAs None on the core — exercise goes through the donation path
MaturitySettler Keeper exercises in the final window, paid in freed A on a Dutch schedule bounded by the holder's floor A per-id allowance, not operator rights
FlashExerciser Exercise, sell A through an ISwapAdapter, pay the strike, return the profit A per-id allowance; calls only the adapter interface, so an adapter cannot reach other allowances

MaturitySettler is how a holder who does nothing still gets the better of the two outcomes, without anyone having custody. FlashExerciser is cash-like settlement without an oracle.

Cash-settled products are out of scope for this core.

Oracle-free on-chain, priced off-chain

"Oracle-free" is a property of the contracts, and it is exact: settlement reads whether the strike was paid, never what anything is worth. No price can make a position wrong, so there is nothing to liquidate and no feed to manipulate.

It is not a claim that the system never needs a price. Deciding what to charge for credit is pricing an option, and that needs a view of the collateral. The design does not pretend otherwise — it puts every price outside the contracts, where being wrong costs someone a bad quote instead of costing everyone a bad settlement:

On-chain Off-chain
Settlement repay-or-deliver; reads no price —
What a rung may pay the mandate's yield floor, a constant the module enforces —
What to quote — the market maker and the curator, from a venue index
Whether a bid is fair — the app's lend door, from realised volatility

There are several such inputs, they are not interchangeable, and every one of them fails closed; see Integration.

The consequence worth internalising: a price feed going down cannot hurt an open position here. It stops new quoting, which is the failure you want.

Provenance

bivium-protocol is original work under MIT, depending only on OpenZeppelin (MIT) and one vendored file, SignedWadMath.sol, from Solmate (MIT, attributed in the file).

Two designs resemble public ones at the level of ideas and not of source: the core's flash-accounting session is a pattern Uniswap v4 also uses, and the Exchange's signed offers on a logistic tick grid are a design Morpho Midnight also uses. Neither is derived from those projects' code (Uniswap v4: BUSL-1.1; Midnight: BUSL-1.1 core, GPL-2.0 periphery). The same statement is recorded in Core.sol, Exchange.sol and TickLib.sol as @custom:provenance.

This too is a CI gate, tools/check-provenance.py, and both of its rules explain something:

  • A file that resembles a public design must carry the statement itself. Stated once, in the file, where a reviewer's suspicion actually lands — and because a statement kept anywhere else does not travel when the code is moved.
  • No references to unreleased predecessors, checked across every tracked file and not only Solidity, because READMEs and CI comments are where such references accumulate. "Ported from" or "the earlier repo" points at something the reader cannot look up, which invites exactly the question a provenance statement exists to answer.

That second rule is why this stack is described on its own terms rather than against the earlier bivium-core lending protocol, which is a different codebase reached by a different derivation and is not modified by anything here.

Vendored from bivium-protocol-doc adde07689a26, synced 2026-10-27. Where this and a repository disagree about that repository, the repository wins.