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
| Contract | How many | You call it to |
|---|---|---|
Branch | One per collateral, twenty | Open, adjust, repay, add collateral, withdraw, close, set a rate, claim a surplus |
StabilityPool | One per branch | Deposit, withdraw, claim seized collateral |
sfyUSD | One per pool | The same, wrapped as a standard tokenised vault |
Router | One | Enter or leave the pool in USDG in a single transaction |
LiquidationEngine | One | Flag a position, then liquidate it |
RedemptionRouter | One | Burn fyUSD across branches for collateral |
PSM | One | Swap USDG and fyUSD, or repay a debt in USDG |
OracleAdapter | One per branch | Read the price view: regime, three prices, confidence, sources |
VolOracle | One per branch | Read the borrowing limit of the day and the two volatilities |
LPVault | One per branch | Deposit a pair, withdraw in kind, claim fees |
sFBR | One | Stake, request a withdrawal, withdraw, cancel |
FBRDistributor | One | Claim 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.
| Function | Why 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.
| Operation | Rough cost |
|---|---|
| A price refresh with six sources | 200 000 to 350 000 |
| One pool observation | 30 000 |
| A signed update, one attestation and three bundles | 250 000 |
| Flag and liquidate | around 600 000 |
| A daily volatility sample | 90 000 to 130 000, once per branch per day |
| Reading the day's borrowing limit | one storage read |
| A vault deposit or recentring | 300 000 to 500 000 |
| The depth reading inside a refresh | 40 000 to 80 000 per pool |
What is measured before launch
Sixteen measurements that have to come back before any constant is frozen, what each one is worth, and what changes in the design if it comes back badly.
Addresses
Chain details for Robinhood Chain and its testnet, the external contracts the protocol reads, and the address tables that will be filled in at deployment.