Skip to content
Developers

Contracts overview

The modules an integrator meets, the call order for every user path, which functions anybody may call, who supplies the signed prices, and what reverts.

Spec v0.9.1, reviewed 2026-09-08

Thirty-two module types, of which an integrator touches perhaps eight. Every one is immutable: no proxy, no owner, no setter, no administrative role. The only access modifiers are between modules, plus one for the Closer key, which appears on no token or vault contract.

The architecture is on Architecture and the full module list on Modules. This page is the integration surface.

What you talk to

ContractHow manyYou call it to
BranchOne per collateral, twentyOpen, adjust, repay, add collateral, withdraw, close, set a rate, claim a surplus
StabilityPoolOne per branchDeposit, withdraw, claim seized collateral
sfyUSDOne per poolThe same, wrapped as a standard tokenised vault
RouterOneEnter or leave the pool in USDG in a single transaction
LiquidationEngineOneFlag a position, then liquidate it
RedemptionRouterOneBurn fyUSD across branches for collateral
PSMOneSwap USDG and fyUSD, or repay a debt in USDG
OracleAdapterOne per branchRead the price view: regime, three prices, confidence, sources
VolOracleOne per branchRead the borrowing limit of the day and the two volatilities
LPVaultOne per branchDeposit a pair, withdraw in kind, claim fees
sFBROneStake, request a withdrawal, withdraw, cancel
FBRDistributorOneClaim a season, liquid or staked

Everything else is internal, permissionless maintenance, or read-only.

The price-update parameter

Two of the three source families arrive as signed messages that somebody has to put on-chain. Rather than naming a keeper, every function that needs a fresh price takes a trailing bytes[] calldata priceUpdates and forwards it to the pull oracle before doing anything else.

What an integrator does with it

fetch the signed messages from the publishers · pass them as priceUpdates


the function verifies them, refreshes the composite, then executes

Passing an empty array is valid. The operation then runs on whatever was last submitted, and if that is older than 900 seconds the source is simply absent, which may move the branch into a lower regime. Nobody is obliged to submit, and a liquidator who wants to liquidate has every reason to.

The on-chain pool family needs a different call: V4Observer.observe(poolId) at least every 600 seconds, costing roughly 30 000 gas. Without it that family goes absent and the branch lives on the signed sources.

The first transaction anybody can send

The protocol is deployed empty. No fyUSD exists, every Stability Pool is empty, and a branch whose pool is empty can lend nothing at all, because its ceiling includes what its own pool can absorb and that term is zero.

The call that opens it is PSM.swapInToSP(branch, usdgAmount, minDeposit). It mints fyUSD against USDG and deposits the result into that branch's pool in one transaction, and anybody may send it. What it opens is borrowing on that branch at 2.5 times the pool it just created on tier 1, and 1.25 times on tiers 2 and 3, under the branch's own dated step.

Everything after that follows from users rather than from anybody's provision: the pool grows, the ceiling grows with it, and the first season's counters are running from the deployment block for whoever is there.

Call order, path by path

Every path below starts with the same implicit head: the price refresh, the volatility sample where relevant, the mint-freeze update, and the collateral-token upgrade check. You do not call those; the function does.

Open a position. token.approve(branch) then Branch.open(coll, debt, rate, hints, priceUpdates). Preconditions: the branch is activated, the regime is not HALT, no corporate action is pending, no freeze applies, the resulting ratio clears the effective minimum, the debt is at least 1,000 fyUSD and under both caps, and the rate is between the tier floor and 100%. The opening fee is 7 days of interest at your own rate, added to the debt.

Adjust. Branch.adjust(collDelta, debtDelta, hints, priceUpdates). The risk-reducing half is applied with no oracle and no state condition; the risk-increasing half is then checked against the resulting state. A resulting debt of zero closes the position.

Repay or add collateral. Branch.repay(onBehalfOf, amount) or Branch.addCollateral(onBehalfOf, amount). Neither reads a price, neither takes priceUpdates, and neither can be blocked by anything.

Close. Branch.close(), after approving the full debt. Refused only in the same block as another touch of the position.

Liquidate. Two transactions, at least 90 seconds apart and at most 1800 seconds: LiquidationEngine.flag(branch, user, priceUpdates), then LiquidationEngine.liquidate(branch, user, maxDebtToRepay, priceUpdates). Both need a regime where liquidation is possible and a position under its threshold at the moment of the call. The pool absorbs first; anything left over is yours to burn if you passed a non-zero maxDebtToRepay. You receive 0.5% of the seized collateral, capped at $200.

Risk

A flag is invalidated wholesale by a halt or by a freeze lasting at least 600 seconds, because the price that justified it no longer exists. Check the live epoch before spending gas on the second transaction.

Redeem. RedemptionRouter.redeem(amount, maxIterationsPerBranch, minCollOut[], maxFeeWad, priceUpdates). The arrays are indexed in the order the eligible-branch view returns. Unconsumed fyUSD is returned to you rather than pushed into another branch.

Deposit into a pool. StabilityPool.provide(amount), or sfyUSD.deposit(assets, receiver), or Router.depositUSDG(...) to arrive in USDG in one transaction. Withdrawal is withdraw, never pausable, refused only in the same block as a liquidation.

Swap. PSM.swapIn(usdg, minOut) or PSM.swapOut(fy, minOut), and PSM.swapInToSP(branch, usdg, minDeposit) to mint and deposit in one call. The exit is never pausable. The entry is refused past the larger of 1,000,000 fyUSD and 30% of supply, and while the USDG feed prints below 0.985.

Provide liquidity. Two approvals then LPVault.deposit(stock, fy, lockTier, minShares). The pair need not match the vault's ratio: what can be paired is taken and the excess is handed straight back. The deposit is refused with PoolOffPrice while the vault's pool sits more than 1% from the composite. Withdrawal is LPVault.withdraw(shares, minStock, minFy), in kind, unlocked shares only, never blocked; if the collateral token has stopped transferring it returns the fyUSD side and leaves the stock side to claimStock.

Stake. sFBR.stake(amount). Leaving is requestUnstake(amount) then withdraw(lotId), priced by the elapsed delay, with cancelUnstake(lotId) available at any point before.

What anybody may call

None of these is paid beyond the liquidation share, none is assigned to a named party, and every one is also invoked implicitly at the head of the operations that depend on it.

FunctionWhy you would call it
OracleAdapter.poke()Refresh the composite and the regime
V4Observer.observe(poolId)Keep the on-chain family alive
PullOracle.update(bytes[])Put signed prices on-chain
VolOracle.sample()Take the day's volatility sample
LiquidityOracle.poke()Fill the depth buffer
Branch.accrue(), updateMintFreeze()Advance interest, refresh the freeze
Branch.activate()Open a dormant branch once all eight criteria pass
Branch.flagShutdown(), shutdown(), settleAfterShutdown(user)Close a branch and settle it. Urgent redemptions open 0 days after an automatic closure and 7 days after one ordered by the Closer key; settlement is 30 days after that
Backstop.coverBadDebt(), seizeFBR(), closeAuction(), redistribute()Work through the loss layers
CollateralSale.buy(), VaultSale.buy()Buy seized or harvested collateral at a discount
LPVault.recenter(), harvest(), checkpoint(), unlockAll()Keep a vault in range, route its fees, count its points
FBRDistributor.settleSeason(k), flush()Close a season, run the buy-back
sFBR.kick(user)Refresh a stale cached weight downward

Guarantee

The protocol works with zero keepers. Every maintenance function is permissionless and called implicitly by the operations that need it, so a user transaction refreshes the state it depends on. If nobody submits signed prices at all, liquidations freeze and every repayment, deposit and withdrawal still succeeds.


Rules R-1.4.2, R-12.6.1

Reverts

Typed custom errors, never a bare string. The ones an integrator meets:

RegimeForbids(op, regime, cause), ICRBelowMint(icr, crMintEff), TCRBelowCCR, DebtBelowMin, CeilingExceeded, AddressCapExceeded, PositionMaxExceeded, SameBlockTouch, RateOutOfBounds(floor, max), RateCooldown(until), NotDelegate, Shutdown, NotActivated, ActivationCriterionFailed(uint8), FrozenByCloser(op, until), UpgradeFreeze(until), CloserExpired, TcrUndefined, BranchLocked, BadHints, NoActiveStake.

From the liquidation engine: NotFlagged, FlagTooRecent, FlagExpired, FlagStaleEpoch, NotLiquidatable(icr, mcr), BucketEmpty, EpisodeCapReached.

From the peg module: UsdgDepeg, and PsmCapExceeded, which now fires past the larger of the absolute franchise of 1,000,000 fyUSD and 30% of supply, rather than past the share alone. From the reserve: NothingToRelease when there is no excess to release, and SunsetNotSettled when a terminal-mode release is attempted early or with bad debt still open. From the pools: SameBlockOffset, LiquidationFrozen. From the vaults: NotActive, PoolOffPrice, NotVault, NothingStuck, Locked, NotShutdown, RecenterConditions. From staking and distribution: ExitWindowElapsed, LpsStakedOnly, SeasonClosed.

PoolOffPrice is the one an interface meets in normal operation: a vault deposit or a recentring is refused while that vault's pool sits further than 1% from the composite. It is transient, and the right message is to try again in a few minutes rather than to report a failure. NotVault comes from the gate hook when anybody other than a vault adds liquidity to a vault pool, including an attempt to initialise one. NothingStuck comes from claiming a stock side that is not stuck.

Two conventions worth knowing. A preview function never reverts: it returns zeros when the operation is impossible. And an authorisation failure is always one of a fixed list of NotX errors, which is fuzzed as an invariant so that no other authorisation path can exist.

Hints

Branch.open, adjust and setRate take a Hints { prev, next } naming the expected neighbours in the rate-ordered list. Wrong hints cost a linear search from prev, bounded at fifty nodes, then revert with BadHints. Compute them off-chain from the same sorted list the redemption walks.

The list is ordered by the rate plus, for a borrower who has staked FBR, up to 2% added to the sort key. The rate paid is unaffected; only the place in the redemption queue is.

Gas, in orders of magnitude

Measured on the specification's own estimates and awaiting confirmation on a fork after the chain's subsidy ends.

OperationRough cost
A price refresh with six sources200 000 to 350 000
One pool observation30 000
A signed update, one attestation and three bundles250 000
Flag and liquidatearound 600 000
A daily volatility sample90 000 to 130 000, once per branch per day
Reading the day's borrowing limitone storage read
A vault deposit or recentring300 000 to 500 000
The depth reading inside a refresh40 000 to 80 000 per pool