Fluid is a decentralized protocol that allows SEI holders to obtain maximum liquidity against their collateral without paying interest. After locking up SEI as collateral in a smart contract and creating an individual position called a "trove", the user can get instant liquidity by minting SAI, a USD-pegged stablecoin. Each trove is required to be collateralized at a minimum of 110%. Any owner of SAI can redeem their stablecoins for the underlying collateral at any time. The redemption mechanism along with algorithmically adjusted fees guarantee a minimum stablecoin value of USD 1.
An unprecedented liquidation mechanism based on incentivized stability deposits and a redistribution cycle from riskier to safer troves provides stability at a much lower collateral ratio than current systems. Stability is maintained via economically-driven user interactions and arbitrage, rather than by active governance or monetary interventions.
The protocol has built-in incentives that encourage both early adoption and the operation of multiple front ends, enhancing decentralization.
Visit fluid.org to find out more and join the discussion.
- Disclaimer
- Fluid Overview
- Liquidation and the Stability Pool
- Gains From Liquidations
- SAI Token Redemption
- Recovery Mode
- Project Structure
- FLO Token Architecture
- Core System Architecture
- Core Smart Contracts
- Data and Value Silo Contracts
- Contract Interfaces
- PriceFeed and Oracle
- PriceFeed Logic
- Testnet PriceFeed and PriceFeed tests
- PriceFeed limitations and known issues
- Keeping a sorted list of Troves ordered by ICR
- Flow of SEI in Fluid
- Flow of SAI tokens in Fluid
- Flow of FLO Tokens in Fluid
- Expected User Behaviors
- Contract Ownership and Function Permissions
- Deployment to a Development Blockchain
- Running Tests
- System Quantities - Units and Representation
- Public Data
- Public User-Facing Functions
- Borrower (Trove) Operations -
BorrowerOperations.sol
- TroveManager Functions -
TroveManager.sol
- Hint Helper Functions -
HintHelpers.sol
- Stability Pool Functions -
StabilityPool.sol
- FLO Staking Functions
FLOStaking.sol
- Lockup Contract Factory
LockupContractFactory.sol
- Lockup contract -
LockupContract.sol
- SAI token
SAIToken.sol
and FLO tokenFLOToken.sol
- Borrower (Trove) Operations -
- Supplying Hints to Trove operations
- Gas compensation
- The Stability Pool
- FLO Issuance to Stability Providers
- FLO issuance to fluid providers
- Fluid System Fees
- Redistributions and Corrected Stakes
- Math Proofs
- Definitions
- Development
- Running a frontend with Docker
- Known Issues
- Disclaimer
Fluid is a collateralized debt platform. Users can lock up SEI, and issue stablecoin tokens (SAI) to their own SEI V2 EVM address, and subsequently transfer those tokens to any other SEI V2 EVM address. The individual collateralized debt positions are called Troves.
The stablecoin tokens are economically geared towards maintaining value of 1 SAI = $1 USD, due to the following properties:
-
The system is designed to always be over-collateralized - the dollar value of the locked SEI exceeds the dollar value of the issued stablecoins
-
The stablecoins are fully redeemable - users can always swap $x worth of SAI for $x worth of SEI (minus fees), directly with the system.
-
The system algorithmically controls the generation of SAI through a variable issuance fee.
After opening a Trove with some SEI, users may issue ("borrow") tokens such that the collateralization ratio of their Trove remains above 110%. A user with $1000 worth of SEI in a Trove can issue up to 909.09 SAI.
The tokens are freely exchangeable - anyone with an SEI V2 EVM address can send or receive SAI tokens, whether they have an open Trove or not. The tokens are burned upon repayment of a Trove's debt.
The Fluid system regularly updates the SEI:USD price via a decentralized data feed. When a Trove falls below a minimum collateralization ratio (MCR) of 110%, it is considered under-collateralized, and is vulnerable to liquidation.
Fluid utilizes a two-step liquidation mechanism in the following order of priority:
-
Offset under-collateralized Troves against the Stability Pool containing SAI tokens
-
Redistribute under-collateralized Troves to other borrowers if the Stability Pool is emptied
Fluid primarily uses the SAI tokens in its Stability Pool to absorb the under-collateralized debt, i.e. to repay the liquidated borrower's liability.
Any user may deposit SAI tokens to the Stability Pool. This allows them to earn the collateral from the liquidated Trove. When a liquidation occurs, the liquidated debt is cancelled with the same amount of SAI in the Pool (which is burned as a result), and the liquidated SEI is proportionally distributed to depositors.
Stability Pool depositors can expect to earn net gains from liquidations, as in most cases, the value of the liquidated SEI will be greater than the value of the cancelled debt (since a liquidated Trove will likely have an ICR just slightly below 110%).
If the liquidated debt is higher than the amount of SAI in the Stability Pool, the system tries to cancel as much debt as possible with the tokens in the Stability Pool, and then redistributes the remaining liquidated collateral and debt across all active Troves.
Anyone may call the public liquidateTroves()
function, which will check for under-collateralized Troves, and liquidate them. Alternatively they can call batchLiquidateTroves()
with a custom list of trove addresses to attempt to liquidate.
Currently, mass liquidations performed via the above functions cost 60-65k gas per trove. Thus the system can liquidate up to a maximum of 95-105 troves in a single transaction.
The precise behavior of liquidations depends on the ICR of the Trove being liquidated and global system conditions: the total collateralization ratio (TCR) of the system, the size of the Stability Pool, etc.
Here is the liquidation logic for a single Trove in Normal Mode and Recovery Mode. SP.SAI
represents the SAI in the Stability Pool.
Condition | Liquidation behavior |
---|---|
ICR < MCR & SP.SAI >= trove.debt | SAI in the StabilityPool equal to the Trove's debt is offset with the Trove's debt. The Trove's SEI collateral is shared between depositors. |
ICR < MCR & SP.SAI < trove.debt | The total StabilityPool SAI is offset with an equal amount of debt from the Trove. A fraction of the Trove's collateral (equal to the ratio of its offset debt to its entire debt) is shared between depositors. The remaining debt and collateral (minus SEI gas compensation) is redistributed to active Troves |
ICR < MCR & SP.SAI = 0 | Redistribute all debt and collateral (minus SEI gas compensation) to active Troves. |
ICR >= MCR | Do nothing. |
Condition | Liquidation behavior |
---|---|
ICR <=100% | Redistribute all debt and collateral (minus SEI gas compensation) to active Troves. |
100% < ICR < MCR & SP.SAI > trove.debt | SAI in the StabilityPool equal to the Trove's debt is offset with the Trove's debt. The Trove's SEI collateral (minus SEI gas compensation) is shared between depsitors. |
100% < ICR < MCR & SP.SAI < trove.debt | The total StabilityPool SAI is offset with an equal amount of debt from the Trove. A fraction of the Trove's collateral (equal to the ratio of its offset debt to its entire debt) is shared between depositors. The remaining debt and collateral (minus SEI gas compensation) is redistributed to active troves |
MCR <= ICR < TCR & SP.SAI >= trove.debt | The Pool SAI is offset with an equal amount of debt from the Trove. A fraction of SEI collateral with dollar value equal to 1.1 * debt is shared between depositors. Nothing is redistributed to other active Troves. Since it's ICR was > 1.1, the Trove has a collateral remainder, which is sent to the CollSurplusPool and is claimable by the borrower. The Trove is closed. |
MCR <= ICR < TCR & SP.SAI < trove.debt | Do nothing. |
ICR >= TCR | Do nothing. |
Stability Pool depositors gain SEI over time, as liquidated debt is cancelled with their deposit. When they withdraw all or part of their deposited tokens, or top up their deposit, the system sends them their accumulated SEI gains.
Similarly, a Trove's accumulated gains from liquidations are automatically applied to the Trove when the owner performs any operation - e.g. adding/withdrawing collateral, or issuing/repaying SAI.
Any SAI holder (whether or not they have an active Trove) may redeem their SAI directly with the system. Their SAI is exchanged for SEI, at face value: redeeming x SAI tokens returns $x worth of SEI (minus a redemption fee).
When SAI is redeemed for SEI, the system cancels the SAI with debt from Troves, and the SEI is drawn from their collateral.
In order to fulfill the redemption request, Troves are redeemed from in ascending order of their collateralization ratio.
A redemption sequence of n
steps will fully redeem from up to n-1
Troves, and, and partially redeems from up to 1 Trove, which is always the last Trove in the redemption sequence.
Redemptions are blocked when TCR < 110% (there is no need to restrict ICR < TCR). At that TCR redemptions would likely be unprofitable, as SAI is probably trading above $1 if the system has crashed that badly, but it could be a way for an attacker with a lot of SAI to lower the TCR even further.
Note that redemptions are disabled during the first 14 days of operation since deployment of the Fluid protocol to protect the monetary system in its infancy.
Most redemption transactions will include a partial redemption, since the amount redeemed is unlikely to perfectly match the total debt of a series of Troves.
The partially redeemed Trove is re-inserted into the sorted list of Troves, and remains active, with reduced collateral and debt.
A Trove is defined as “fully redeemed from” when the redemption has caused (debt-200) of its debt to absorb (debt-200) SAI. Then, its 200 SAI Liquidation Reserve is cancelled with its remaining 200 debt: the Liquidation Reserve is burned from the gas address, and the 200 debt is zero’d.
Before closing, we must handle the Trove’s collateral surplus: that is, the excess SEI collateral remaining after redemption, due to its initial over-collateralization.
This collateral surplus is sent to the CollSurplusPool
, and the borrower can reclaim it later. The Trove is then fully closed.
Economically, the redemption mechanism creates a hard price floor for SAI, ensuring that the market price stays at or near to $1 USD.
Recovery Mode kicks in when the total collateralization ratio (TCR) of the system falls below 150%.
During Recovery Mode, liquidation conditions are relaxed, and the system blocks borrower transactions that would further decrease the TCR. New SAI may only be issued by adjusting existing Troves in a way that improves their ICR, or by opening a new Trove with an ICR of >=150%. In general, if an existing Trove's adjustment reduces its ICR, the transaction is only executed if the resulting TCR is above 150%
Recovery Mode is structured to incentivize borrowers to behave in ways that promptly raise the TCR back above 150%, and to incentivize SAI holders to replenish the Stability Pool.
Economically, Recovery Mode is designed to encourage collateral top-ups and debt repayments, and also itself acts as a self-negating deterrent: the possibility of it occurring actually guides the system away from ever reaching it.
papers
- Whitepaper and math papers: a proof of Fluid's trove order invariant, and a derivation of the scalable Stability Pool staking formulapackages/dev-frontend/
- Fluid Developer UI: a fully functional React app used for interfacing with the smart contracts during developmentpackages/fuzzer/
- A very simple, purpose-built tool based on Fluid middleware for randomly interacting with the systempackages/lib-base/
- Common interfaces and classes shared by the otherlib-
packagespackages/lib-ethers/
- Ethers-based middleware that can read Fluid state and send transactionspackages/lib-react/
- Components and hooks that React-based apps can use to view Fluid contract statepackages/lib-subgraph/
- Apollo Client-based middleware backed by the Fluid subgraph that can read Fluid statepackages/providers/
- Subclassed Ethers providers used by the frontendpackages/subgraph/
- Subgraph for querying Fluid state as well as historical data like transaction historypackages/contracts/
- The backend development folder, contains the Hardhat project, contracts and testspackages/contracts/contracts/
- The core back end smart contracts written in Soliditypackages/contracts/test/
- JS test suite for the system. Tests run in Mocha/Chaipackages/contracts/tests/
- Python test suite for the system. Tests run in Browniepackages/contracts/gasTest/
- Non-assertive tests that return gas costs for Fluid operations under various scenariospackages/contracts/fuzzTests/
- Echidna tests, and naive "random operation" testspackages/contracts/migrations/
- contains Hardhat script for deploying the smart contracts to the blockchainpackages/contracts/utils/
- external Hardhat and node scripts - deployment helpers, gas calculators, etc
Backend development is done in the Hardhat framework, and allows Fluid to be deployed on the Hardhat EVM network for fast compilation and test execution.
As of 18/01/2021, the current working branch is main
. master
is out of date.
The Fluid system incorporates a secondary token, FLO. This token entitles the holder to a share of the system revenue generated by redemption fees and issuance fees.
To earn a share of system fees, the FLO holder must stake their FLO in a staking contract.
Fluid also issues FLO to Stability Providers, in a continous time-based manner.
The FLO contracts consist of:
FLOStaking.sol
- the staking contract, containing stake and unstake functionality for FLO holders. This contract receives SEI fees from redemptions, and SAI fees from new debt issuance.
CommunityIssuance.sol
- This contract handles the issuance of FLO tokens to Stability Providers as a function of time. It is controlled by the StabilityPool
. Upon system launch, the CommunityIssuance
automatically receives 32 million FLO - the “community issuance” supply. The contract steadily issues these FLO tokens to the Stability Providers over time.
FLOToken.sol
- This is the FLO ERC20 contract. It has a hard cap supply of 100 million, and during the first year, restricts transfers from the Fluid admin address, a regular SEI V2 EVM address controlled by the project company Fluid AG. Note that the Fluid admin address has no extra privileges and does not retain any control over the Fluid protocol once deployed.
Some FLO is reserved for team members and partners, and is locked up for one year upon system launch. Additionally, some team members receive FLO vested on a monthly basis, which during the first year, is transferred directly to their lockup contract.
In the first year after launch:
-
All team members and partners are unable to access their locked up FLO tokens
-
The Fluid admin address may transfer tokens only to verified lockup contracts with an unlock date at least one year after system deployment
Also, separate FLO allocations are made at deployment to an EOA that will hold an amount of FLO for bug bounties/hackathons and to a Uniswap LP reward contract. Aside from these allocations, the only FLO made freely available in this first year is the FLO that is publically issued to Stability Providers via the CommunityIssuance
contract.
A LockupContractFactory
is used to deploy LockupContracts
in the first year. During the first year, the FLOToken
checks that any transfer from the Fluid admin address is to a valid LockupContract
that is registered in and was deployed through the LockupContractFactory
.
- Fluid admin deploys
LockupContractFactory
- Fluid admin deploys
CommunityIssuance
- Fluid admin deploys
FLOStaking
- Fluid admin creates a Pool in Uniswap for SAI/SEI and deploys
Unipool
(LP rewards contract), which knows the address of the Pool - Fluid admin deploys
FLOToken
, which upon deployment:
- Stores the
CommunityIssuance
andLockupContractFactory
addresses - Mints FLO tokens to
CommunityIssuance
, the Fluid admin address, theUnipool
LP rewards address, and the bug bounty address
- Fluid admin sets
FLOToken
address inLockupContractFactory
,CommunityIssuance
,FLOStaking
, andUnipool
- Fluid admin tells
LockupContractFactory
to deploy aLockupContract
for each beneficiary, with anunlockTime
set to exactly one year after system deployment - Fluid admin transfers FLO to each
LockupContract
, according to their entitlement
- Fluid admin deploys the Fluid core system
- Fluid admin connects Fluid core system internally (with setters)
- Fluid admin connects
FLOStaking
to Fluid core contracts andFLOToken
- Fluid admin connects
CommunityIssuance
to Fluid core contracts andFLOToken
- Fluid admin periodically transfers newly vested tokens to team & partners’
LockupContracts
, as per their vesting schedules - Fluid admin may only transfer FLO to
LockupContracts
- Anyone may deploy new
LockupContracts
via the Factory, setting anyunlockTime
that is >= 1 year from system deployment
- All beneficiaries may withdraw their entire entitlements
- Fluid admin address restriction on FLO transfers is automatically lifted, and Fluid admin may now transfer FLO to any address
- Anyone may deploy new
LockupContracts
via the Factory, setting anyunlockTime
in the future
- Fluid admin periodically transfers newly vested tokens to team & partners, directly to their individual addresses, or to a fresh lockup contract if required.
NOTE: In the final architecture, a multi-sig contract will be used to move FLO Tokens, rather than the single Fluid admin EOA. It will be deployed at the start of the sequence, and have its address recorded in FLOToken
in step 4, and receive FLO tokens. It will be used to move FLO in step 7, and during & after the lockup period. The Fluid admin EOA will only be used for deployment of contracts in steps 1-4 and 9.
The current code does not utilize a multi-sig. It implements the launch architecture outlined above.
Additionally, a LP staking contract will receive the initial LP staking reward allowance, rather than an EOA. It will be used to hold and issue FLO to users who stake LP tokens that correspond to certain pools on DEXs.
The core Fluid system consists of several smart contracts, which are deployable to the SEI V2 blockchain.
All application logic and data is contained in these contracts - there is no need for a separate database or back end logic running on a web server. In effect, the SEI V2 network is itself the Fluid back end. As such, all balances and contract data are public.
The system has no admin key or human governance. Once deployed, it is fully automated, decentralized and no user holds any special privileges in or control over the system.
The three main contracts - BorrowerOperations.sol
, TroveManager.sol
and StabilityPool.sol
- hold the user-facing public functions, and contain most of the internal system logic. Together they control Trove state updates and movements of SEI and SAI tokens around the system.
BorrowerOperations.sol
- contains the basic operations by which borrowers interact with their Trove: Trove creation, SEI top-up / withdrawal, stablecoin issuance and repayment. It also sends issuance fees to the FLOStaking
contract. BorrowerOperations functions call in to TroveManager, telling it to update Trove state, where necessary. BorrowerOperations functions also call in to the various Pools, telling them to move SEI/Tokens between Pools or between Pool <> user, where necessary.
TroveManager.sol
- contains functionality for liquidations and redemptions. It sends redemption fees to the FLOStaking
contract. Also contains the state of each Trove - i.e. a record of the Trove’s collateral and debt. TroveManager does not hold value (i.e. SEI / other tokens). TroveManager functions call in to the various Pools to tell them to move SEI/tokens between Pools, where necessary.
FluidBase.sol
- Both TroveManager and BorrowerOperations inherit from the parent contract FluidBase, which contains global constants and some common functions.
StabilityPool.sol
- contains functionality for Stability Pool operations: making deposits, and withdrawing compounded deposits and accumulated SEI and FLO gains. Holds the SAI Stability Pool deposits, and the SEI gains for depositors, from liquidations.
SAIToken.sol
- the stablecoin token contract, which implements the ERC20 fungible token standard in conjunction with EIP-2612 and a mechanism that blocks (accidental) transfers to addresses like the StabilityPool and address(0) that are not supposed to receive funds through direct transfers. The contract mints, burns and transfers SAI tokens.
SortedTroves.sol
- a doubly linked list that stores addresses of Trove owners, sorted by their individual collateralization ratio (ICR). It inserts and re-inserts Troves at the correct position, based on their ICR.
PriceFeed.sol
- Contains functionality for obtaining the current SEI:USD price, which the system uses for calculating collateralization ratios.
HintHelpers.sol
- Helper contract, containing the read-only functionality for calculation of accurate hints to be supplied to borrower operations and redemptions.
Along with StabilityPool.sol
, these contracts hold SEI and/or tokens for their respective parts of the system, and contain minimal logic:
ActivePool.sol
- holds the total SEI balance and records the total stablecoin debt of the active Troves.
DefaultPool.sol
- holds the total SEI balance and records the total stablecoin debt of the liquidated Troves that are pending redistribution to active Troves. If a Trove has pending sei/debt “rewards” in the DefaultPool, then they will be applied to the Trove when it next undergoes a borrower operation, a redemption, or a liquidation.
CollSurplusPool.sol
- holds the SEI surplus from Troves that have been fully redeemed from as well as from Troves with an ICR > MCR that were liquidated in Recovery Mode. Sends the surplus back to the owning borrower, when told to do so by BorrowerOperations.sol
.
GasPool.sol
- holds the total SAI liquidation reserves. SAI is moved into the GasPool
when a Trove is opened, and moved out when a Trove is liquidated or closed.
ITroveManager.sol
, IPool.sol
etc. These provide specification for a contract’s functions, without implementation. They are similar to interfaces in Java or C#.
Fluid functions that require the most current SEI:USD price data fetch the price dynamically, as needed, via the core PriceFeed.sol
contract using the Chainlink SEI:USD reference contract as its primary and Tellor's SEI:USD price feed as its secondary (fallback) data source. PriceFeed is stateful, i.e. it records the last good price that may come from either of the two sources based on the contract's current state.
The fallback logic distinguishes 3 different failure modes for Chainlink and 2 failure modes for Tellor:
Frozen
(for both oracles): last price update more than 4 hours agoBroken
(for both oracles): response call reverted, invalid timeStamp that is either 0 or in the future, or reported price is non-positive (Chainlink) or zero (Tellor). Chainlink is considered broken if either the response for the latest round or the response for the round before the latest fails one of these conditions.PriceChangeAboveMax
(Chainlink only): higher than 50% deviation between two consecutive price updates
There is also a return condition bothOraclesLiveAndUnbrokenAndSimilarPrice
which is a function returning true if both oracles are live and not broken, and the percentual difference between the two reported prices is below 5%.
The current PriceFeed.sol
contract has an external fetchPrice()
function that is called by core Fluid functions which require a current SEI:USD price. fetchPrice()
calls each oracle's proxy, asserts on the responses, and converts returned prices to 18 digits.
Fluid sees a Tellor SEI-USD price that is at least 15 minutes old. This is because Tellor operates via proof-of-stake, and some dispute period is needed in which fake prices can be disputed. When a Tellor price is disputed, it is removed from the list of prices that Fluid sees. This dispute period ensures that, given at least one responsive disputer who disputes fake SEI prices, Fluid will never consume fake price data from Tellor.
The choice of 15 minutes for the dispute period was based on careful analysis of the impact of a delayed SEI price on a Fluid system. We used historical SEI price data and looked at the impact of different delay lengths. 15 minutes was chosen as a sweet spot that gives plenty of time for disputers to respond to fake prices, while keeping any adverse impacts on Fluid to a minimum.
The PriceFeed contract fetches the current price and previous price from Chainlink and changes its state (called Status
) based on certain conditions.
Initial PriceFeed state: chainlinkWorking
. The initial system state that is maintained as long as Chainlink is working properly, i.e. neither broken nor frozen nor exceeding the maximum price change threshold between two consecutive rounds. PriceFeed then obeys the logic found in this table:
https://docs.google.com/spreadsheets/d/18fdtTUoqgmsK3Mb6LBO-6na0oK-Y9LWBqnPCJRp5Hsg/edit?usp=sharing
The PriceFeedTestnet.sol
is a mock PriceFeed for testnet and general back end testing purposes, with no oracle connection. It contains a manual price setter, setPrice()
, and a getter, getPrice()
, which returns the latest stored price.
The mainnet PriceFeed is tested in test/PriceFeedTest.js
, using a mock Chainlink aggregator and a mock TellorMaster contract.
The purpose of the PriceFeed is to be at least as good as an immutable PriceFeed that relies purely on Chainlink, while also having some resilience in case of Chainlink failure / timeout, and chance of recovery.
The PriceFeed logic consists of automatic on-chain decision-making for obtaining fallback price data from Tellor, and if possible, for returning to Chainlink if/when it recovers.
The PriceFeed logic is complex, and although we would prefer simplicity, it does allow the system a chance of switching to an accurate price source in case of a Chainlink failure or timeout, and also the possibility of returning to an honest Chainlink price after it has failed and recovered.
We believe the benefit of the fallback logic is worth the complexity, given that our system is entirely immutable - if we had no fallback logic and Chainlink were to be hacked or permanently fail, Fluid would become permanently unusable anyway.
Chainlink Decimals: the PriceFeed
checks for and uses the latest decimals
value reported by the Chainlink aggregator in order to calculate the Chainlink price at 18-digit precision, as needed by Fluid. PriceFeed
does not assume a value for decimals and can handle the case where Chainlink change their decimal value.
However, the check chainlinkIsBroken
uses both the current response from the latest round and the response previous round. Since decimals
is not attached to round data, Fluid has no way of knowing whether decimals has changed between the current round and the previous round, so we assume it is the same. Fluid assumes the current return value of decimals() applies to both current round i
and previous round i-1
.
This means that a decimal change that coincides with a Fluid price fetch could cause Fluid to assert that the Chainlink price has deviated too much, and fall back to Tellor. There is nothing we can do about this. We hope/expect Chainlink to never change their decimals()
return value (currently 8), and if a hack/technical error causes Chainlink's decimals to change, Fluid may fall back to Tellor.
To summarize the Chainlink decimals issue:
- Fluid can handle the case where Chainlink decimals changes across two consecutive rounds
i
andi-1
which are not used in the same Fluid price fetch - If Fluid fetches the price at round
i
, it will not know if Chainlink decimals changed across roundi-1
to roundi
, and the consequent price scaling distortion may cause Fluid to fall back to Tellor - Fluid will always calculate the correct current price at 18-digit precision assuming the current return value of
decimals()
is correct (i.e. is the value used by the nodes).
Tellor Decimals: Tellor uses 6 decimal precision for their SEIUSD price as determined by a social consensus of Tellor miners/data providers, and shown on Tellor's price feed page. Their decimals value is not offered in their on-chain contracts. We rely on the continued social consensus around 6 decimals for their SEIUSD price feed. Tellor have informed us that if there was demand for an SEIUSD price at different precision, they would simply create a new requestId
, and make no attempt to alter the social consensus around the precision of the current SEIUSD requestId
(1) used by Fluid.
Fluid relies on a particular data structure: a sorted doubly-linked list of Troves that remains ordered by individual collateralization ratio (ICR), i.e. the amount of collateral (in USD) divided by the amount of debt (in SAI).
This ordered list is critical for gas-efficient redemption sequences and for the liquidateTroves
sequence, both of which target Troves in ascending order of ICR.
The sorted doubly-linked list is found in SortedTroves.sol
.
Nodes map to active Troves in the system - the ID property is the address of a trove owner. The list accepts positional hints for efficient O(1) insertion - please see the hints section for more details.
ICRs are computed dynamically at runtime, and not stored on the node. This is because ICRs of active Troves change dynamically, when:
- The SEI:USD price varies, altering the USD of the collateral of every Trove
- A liquidation that redistributes collateral and debt to active Troves occurs
The list relies on the fact that a collateral and debt redistribution due to a liquidation preserves the ordering of all active Troves (though it does decrease the ICR of each active Trove above the MCR).
The fact that ordering is maintained as redistributions occur, is not immediately obvious: please see the mathematical proof which shows that this holds in Fluid.
A node inserted based on current ICR will maintain the correct position, relative to its peers, as liquidation gains accumulate, as long as its raw collateral and debt have not changed.
Nodes also remain sorted as the SEI:USD price varies, since price fluctuations change the collateral value of each Trove by the same proportion.
Thus, nodes need only be re-inserted to the sorted list upon a Trove operation - when the owner adds or removes collateral or debt to their position.
SEI in the system lives in four Pools: the ActivePool, the DefaultPool, the StabilityPool and the CollSurplusPool, plus FLOStaking contract. When an operation is made, SEI is transferred in one of three ways:
- From a user to a Pool
- From a Pool to a user
- From one Pool to another Pool
SEI is recorded on an individual level, but stored in aggregate in a Pool. An active Trove with collateral and debt has a struct in the TroveManager that stores its sei collateral value in a uint, but its actual SEI is in the balance of the ActivePool contract.
Likewise, the StabilityPool holds the total accumulated SEI gains from liquidations for all depositors.
FLOStaking receives SEI coming from redemption fees.
Borrower Operations
Function | SEI quantity | Path |
---|---|---|
openTrove | msg.value | msg.sender->BorrowerOperations->ActivePool |
addColl | msg.value | msg.sender->BorrowerOperations->ActivePool |
withdrawColl | _collWithdrawal parameter | ActivePool->msg.sender |
adjustTrove: adding SEI | msg.value | msg.sender->BorrowerOperations->ActivePool |
adjustTrove: withdrawing SEI | _collWithdrawal parameter | ActivePool->msg.sender |
closeTrove | All remaining | ActivePool->msg.sender |
claimCollateral | CollSurplusPool.balance[msg.sender] | CollSurplusPool->msg.sender |
Trove Manager
Function | SEI quantity | Path |
---|---|---|
liquidate (offset) | collateral to be offset | ActivePool->StabilityPool |
liquidate (redistribution) | collateral to be redistributed | ActivePool->DefaultPool |
liquidateTroves (offset) | collateral to be offset | ActivePool->StabilityPool |
liquidateTroves (redistribution) | collateral to be redistributed | ActivePool->DefaultPool |
batchLiquidateTroves (offset) | collateral to be offset | ActivePool->StabilityPool |
batchLiquidateTroves (redistribution). | collateral to be redistributed | ActivePool->DefaultPool |
redeemCollateral | collateral to be swapped with redeemer | ActivePool->msg.sender |
redeemCollateral | redemption fee | ActivePool->FLOStaking |
redeemCollateral | trove's collateral surplus | ActivePool->CollSurplusPool |
Stability Pool
Function | SEI quantity | Path |
---|---|---|
provideToSP | depositor's accumulated SEI gain | StabilityPool -> msg.sender |
withdrawFromSP | depositor's accumulated SEI gain | StabilityPool -> msg.sender |
withdrawSEIGainToTrove | depositor's accumulated SEI gain | StabilityPool -> BorrowerOperations -> ActivePool |
FLO Staking
Function | SEI quantity | Path |
---|---|---|
stake | staker's accumulated SEI gain from system fees | FLOStaking ->msg.sender |
unstake | staker's accumulated SEI gain from system fees | FLOStaking ->msg.sender |
When a user issues debt from their Trove, SAI tokens are minted to their own address, and a debt is recorded on the Trove. Conversely, when they repay their Trove’s SAI debt, SAI is burned from their address, and the debt on their Trove is reduced.
Redemptions burn SAI from the redeemer’s balance, and reduce the debt of the Trove redeemed against.
Liquidations that involve a Stability Pool offset burn tokens from the Stability Pool’s balance, and reduce the SAI debt of the liquidated Trove.
The only time SAI is transferred to/from a Fluid contract, is when a user deposits SAI to, or withdraws SAI from, the StabilityPool.
Borrower Operations
Function | SAI Quantity | ERC20 Operation |
---|---|---|
openTrove | Drawn SAI | SAI._mint(msg.sender, _SAIAmount) |
Issuance fee | SAI._mint(FLOStaking, SAIFee) | |
withdrawSAI | Drawn SAI | SAI._mint(msg.sender, _SAIAmount) |
Issuance fee | SAI._mint(FLOStaking, SAIFee) | |
repaySAI | Repaid SAI | SAI._burn(msg.sender, _SAIAmount) |
adjustTrove: withdrawing SAI | Drawn SAI | SAI._mint(msg.sender, _SAIAmount) |
Issuance fee | SAI._mint(FLOStaking, SAIFee) | |
adjustTrove: repaying SAI | Repaid SAI | SAI._burn(msg.sender, _SAIAmount) |
closeTrove | Repaid SAI | SAI._burn(msg.sender, _SAIAmount) |
Trove Manager
Function | SAI Quantity | ERC20 Operation |
---|---|---|
liquidate (offset) | SAI to offset with debt | SAI._burn(stabilityPoolAddress, _debtToOffset); |
liquidateTroves (offset) | SAI to offset with debt | SAI._burn(stabilityPoolAddress, _debtToOffset); |
batchLiquidateTroves (offset) | SAI to offset with debt | SAI._burn(stabilityPoolAddress, _debtToOffset); |
redeemCollateral | SAI to redeem | SAI._burn(msg.sender, _SAI) |
Stability Pool
Function | SAI Quantity | ERC20 Operation |
---|---|---|
provideToSP | deposit / top-up | SAI._transfer(msg.sender, stabilityPoolAddress, _amount); |
withdrawFromSP | withdrawal | SAI._transfer(stabilityPoolAddress, msg.sender, _amount); |
FLO Staking
Function | SAI Quantity | ERC20 Operation |
---|---|---|
stake | staker's accumulated SAI gain from system fees | SAI._transfer(FLOStakingAddress, msg.sender, SAIGain); |
unstake | staker's accumulated SAI gain from system fees | SAI._transfer(FLOStakingAddress, msg.sender, SAIGain); |
Stability Providers and Frontend Operators receive FLO gains according to their share of the total SAI deposits, and the FLO community issuance schedule. Once obtained, FLO can be staked and unstaked with the FLOStaking
contract.
Stability Pool
Function | FLO Quantity | ERC20 Operation |
---|---|---|
provideToSP | depositor FLO gain | FLO._transfer(stabilityPoolAddress, msg.sender, depositorFLOGain); |
front end FLO gain | FLO._transfer(stabilityPoolAddress, _frontEnd, frontEndFLOGain); | |
withdrawFromSP | depositor FLO gain | FLO._transfer(stabilityPoolAddress, msg.sender, depositorFLOGain); |
front end FLO gain | FLO._transfer(stabilityPoolAddress, _frontEnd, frontEndFLOGain); | |
withdrawSEIGainToTrove | depositor FLO gain | FLO._transfer(stabilityPoolAddress, msg.sender, depositorFLOGain); |
front end FLO gain | FLO._transfer(stabilityPoolAddress, _frontEnd, frontEndFLOGain); |
FLO Staking Contract
Function | FLO Quantity | ERC20 Operation |
---|---|---|
stake | staker's FLO deposit / top-up | FLO._transfer(msg.sender, FLOStakingAddress, _amount); |
unstake | staker's FLO withdrawal | FLO._transfer(FLOStakingAddress, msg.sender, _amount); |
Generally, borrowers call functions that trigger Trove operations on their own Trove. Stability Pool users (who may or may not also be borrowers) call functions that trigger Stability Pool operations, such as depositing or withdrawing tokens to/from the Stability Pool.
Anyone may call the public liquidation functions, and attempt to liquidate one or several Troves.
SAI token holders may also redeem their tokens, and swap an amount of tokens 1-for-1 in value (minus fees) with SEI.
FLO token holders may stake their FLO, to earn a share of the system fee revenue, in SEI and SAI.
All the core smart contracts inherit from the OpenZeppelin Ownable.sol
contract template. As such all contracts have a single owning address, which is the deploying address. The contract's ownership is renounced either upon deployment, or immediately after its address setter has been called, connecting it to the rest of the core Fluid system.
Several public and external functions have modifiers such as requireCallerIsTroveManager
, requireCallerIsActivePool
, etc - ensuring they can only be called by the respective permitted contract.
The Hardhat migrations script and deployment helpers in utils/deploymentHelpers.js
deploy all contracts, and connect all contracts to their dependency contracts, by setting the necessary deployed addresses.
The project is deployed on the Ropsten testnet.
Run all tests with npx hardhat test
, or run a specific test with npx hardhat test ./test/contractTest.js
Tests are run against the Hardhat EVM.
There are some special tests that are using Brownie framework.
To test, install brownie with:
python3 -m pip install --user pipx
python3 -m pipx ensurepath
pipx install eth-brownie
and add numpy with:
pipx inject eth-brownie numpy
Add OpenZeppelin package:
brownie pm install OpenZeppelin/openzeppelin-contracts@3.3.0
Run, from packages/contracts/
:
brownie test -s
Add the local node as a live
network at ~/.brownie/network-config.yaml
:
(...)
- name: Local Openseiv2
chainid: 31337
id: Openseiv2
host: http://localhost:8545
Make sure state is cleaned up first:
rm -Rf build/deployments/*
Start Openthereum node from this repo’s root with:
yarn start-dev-chain:openethereum
Then, again from packages/contracts/
, run it with:
brownie test -s --network openethereum
To stop the Openethereum node, you can do it with:
yarn stop-dev-chain
To check test coverage you can run:
yarn coverage
You can see the coverage status at mainnet deployment here.
There’s also a pull request to increase the coverage, but it hasn’t been merged yet because it modifies some smart contracts (mostly removing unnecessary checks).
Several ratios and the SEI:USD price are integer representations of decimals, to 18 digits of precision. For example:
uint representation of decimal | Number |
---|---|
1100000000000000000 | 1.1 |
200000000000000000000 | 200 |
1000000000000000000 | 1 |
5432100000000000000 | 5.4321 |
34560000000 | 0.00000003456 |
370000000000000000000 | 370 |
1 | 1e-18 |
etc.
All data structures with the ‘public’ visibility specifier are ‘gettable’, with getters automatically generated by the compiler. Simply call TroveManager::MCR()
to get the MCR, etc.
openTrove(uint _maxFeePercentage, uint _SAIAmount, address _upperHint, address _lowerHint)
: payable function that creates a Trove for the caller with the requested debt, and the SEI received as collateral. Successful execution is conditional mainly on the resulting collateralization ratio which must exceed the minimum (110% in Normal Mode, 150% in Recovery Mode). In addition to the requested debt, extra debt is issued to pay the issuance fee, and cover the gas compensation. The borrower has to provide a _maxFeePercentage
that he/she is willing to accept in case of a fee slippage, i.e. when a redemption transaction is processed first, driving up the issuance fee.
addColl(address _upperHint, address _lowerHint))
: payable function that adds the received SEI to the caller's active Trove.
withdrawColl(uint _amount, address _upperHint, address _lowerHint)
: withdraws _amount
of collateral from the caller’s Trove. Executes only if the user has an active Trove, the withdrawal would not pull the user’s Trove below the minimum collateralization ratio, and the resulting total collateralization ratio of the system is above 150%.
function withdrawSAI(uint _maxFeePercentage, uint _SAIAmount, address _upperHint, address _lowerHint)
: issues _amount
of SAI from the caller’s Trove to the caller. Executes only if the Trove's collateralization ratio would remain above the minimum, and the resulting total collateralization ratio is above 150%. The borrower has to provide a _maxFeePercentage
that he/she is willing to accept in case of a fee slippage, i.e. when a redemption transaction is processed first, driving up the issuance fee.
repaySAI(uint _amount, address _upperHint, address _lowerHint)
: repay _amount
of SAI to the caller’s Trove, subject to leaving 50 debt in the Trove (which corresponds to the 50 SAI gas compensation).
_adjustTrove(address _borrower, uint _collWithdrawal, uint _debtChange, bool _isDebtIncrease, address _upperHint, address _lowerHint, uint _maxFeePercentage)
: enables a borrower to simultaneously change both their collateral and debt, subject to all the restrictions that apply to individual increases/decreases of each quantity with the following particularity: if the adjustment reduces the collateralization ratio of the Trove, the function only executes if the resulting total collateralization ratio is above 150%. The borrower has to provide a _maxFeePercentage
that he/she is willing to accept in case of a fee slippage, i.e. when a redemption transaction is processed first, driving up the issuance fee. The parameter is ignored if the debt is not increased with the transaction.
closeTrove()
: allows a borrower to repay all debt, withdraw all their collateral, and close their Trove. Requires the borrower have a SAI balance sufficient to repay their trove's debt, excluding gas compensation - i.e. (debt - 50)
SAI.
claimCollateral(address _user)
: when a borrower’s Trove has been fully redeemed from and closed, or liquidated in Recovery Mode with a collateralization ratio above 110%, this function allows the borrower to claim their SEI collateral surplus that remains in the system (collateral - debt upon redemption; collateral - 110% of the debt upon liquidation).
liquidate(address _borrower)
: callable by anyone, attempts to liquidate the Trove of _user
. Executes successfully if _user
’s Trove meets the conditions for liquidation (e.g. in Normal Mode, it liquidates if the Trove's ICR < the system MCR).
liquidateTroves(uint n)
: callable by anyone, checks for under-collateralized Troves below MCR and liquidates up to n
, starting from the Trove with the lowest collateralization ratio; subject to gas constraints and the actual number of under-collateralized Troves. The gas costs of liquidateTroves(uint n)
mainly depend on the number of Troves that are liquidated, and whether the Troves are offset against the Stability Pool or redistributed. For n=1, the gas costs per liquidated Trove are roughly between 215K-400K, for n=5 between 80K-115K, for n=10 between 70K-82K, and for n=50 between 60K-65K.
batchLiquidateTroves(address[] calldata _troveArray)
: callable by anyone, accepts a custom list of Troves addresses as an argument. Steps through the provided list and attempts to liquidate every Trove, until it reaches the end or it runs out of gas. A Trove is liquidated only if it meets the conditions for liquidation. For a batch of 10 Troves, the gas costs per liquidated Trove are roughly between 75K-83K, for a batch of 50 Troves between 54K-69K.
redeemCollateral(uint _SAIAmount, address _firstRedemptionHint, address _upperPartialRedemptionHint, address _lowerPartialRedemptionHint, uint _partialRedemptionHintNICR, uint _maxIterations, uint _maxFeePercentage)
: redeems _SAIamount
of stablecoins for sei from the system. Decreases the caller’s SAI balance, and sends them the corresponding amount of SEI. Executes successfully if the caller has sufficient SAI to redeem. The number of Troves redeemed from is capped by _maxIterations
. The borrower has to provide a _maxFeePercentage
that he/she is willing to accept in case of a fee slippage, i.e. when another redemption transaction is processed first, driving up the redemption fee.
getCurrentICR(address _user, uint _price)
: computes the user’s individual collateralization ratio (ICR) based on their total collateral and total SAI debt. Returns 2^256 -1 if they have 0 debt.
getTroveOwnersCount()
: get the number of active Troves in the system.
getPendingSEIReward(address _borrower)
: get the pending SEI reward from liquidation redistribution events, for the given Trove.
getPendingSAIDebtReward(address _borrower)
: get the pending Trove debt "reward" (i.e. the amount of extra debt assigned to the Trove) from liquidation redistribution events.
getEntireDebtAndColl(address _borrower)
: returns a Trove’s entire debt and collateral, which respectively include any pending debt rewards and SEI rewards from prior redistributions.
getEntireSystemColl()
: Returns the systemic entire collateral allocated to Troves, i.e. the sum of the SEI in the Active Pool and the Default Pool.
getEntireSystemDebt()
Returns the systemic entire debt assigned to Troves, i.e. the sum of the SAIDebt in the Active Pool and the Default Pool.
getTCR()
: returns the total collateralization ratio (TCR) of the system. The TCR is based on the entire system debt and collateral (including pending rewards).
checkRecoveryMode()
: reveals whether or not the system is in Recovery Mode (i.e. whether the Total Collateralization Ratio (TCR) is below the Critical Collateralization Ratio (CCR)).
function getApproxHint(uint _CR, uint _numTrials, uint _inputRandomSeed)
: helper function, returns a positional hint for the sorted list. Used for transactions that must efficiently re-insert a Trove to the sorted list.
getRedemptionHints(uint _SAIamount, uint _price, uint _maxIterations)
: helper function specifically for redemptions. Returns three hints:
firstRedemptionHint
is a positional hint for the first redeemable Trove (i.e. Trove with the lowest ICR >= MCR).partialRedemptionHintNICR
is the final nominal ICR of the last Trove after being hit by partial redemption, or zero in case of no partial redemption (see Hints forredeemCollateral
).truncatedSAIamount
is the maximum amount that can be redeemed out of the provided_SAIamount
. This can be lower than_SAIamount
when redeeming the full amount would leave the last Trove of the redemption sequence with less debt than the minimum allowed value.
The number of Troves to consider for redemption can be capped by passing a non-zero value as _maxIterations
, while passing zero will leave it uncapped.
provideToSP(uint _amount, address _frontEndTag)
: allows stablecoin holders to deposit _amount
of SAI to the Stability Pool. It sends _amount
of SAI from their address to the Pool, and tops up their SAI deposit by _amount
and their tagged front end’s stake by _amount
. If the depositor already has a non-zero deposit, it sends their accumulated SEI and FLO gains to their address, and pays out their front end’s FLO gain to their front end.
withdrawFromSP(uint _amount)
: allows a stablecoin holder to withdraw _amount
of SAI from the Stability Pool, up to the value of their remaining Stability deposit. It decreases their SAI balance by _amount
and decreases their front end’s stake by _amount
. It sends the depositor’s accumulated SEI and FLO gains to their address, and pays out their front end’s FLO gain to their front end. If the user makes a partial withdrawal, their deposit remainder will earn further gains. To prevent potential loss evasion by depositors, withdrawals from the Stability Pool are suspended when there are liquidable Troves with ICR < 110% in the system.
withdrawSEIGainToTrove(address _hint)
: sends the user's entire accumulated SEI gain to the user's active Trove, and updates their Stability deposit with its accumulated loss from debt absorptions. Sends the depositor's FLO gain to the depositor, and sends the tagged front end's FLO gain to the front end.
registerFrontEnd(uint _kickbackRate)
: Registers an address as a front end and sets their chosen kickback rate in range [0,1]
.
getDepositorSEIGain(address _depositor)
: returns the accumulated SEI gain for a given Stability Pool depositor
getDepositorFLOGain(address _depositor)
: returns the accumulated FLO gain for a given Stability Pool depositor
getFrontEndFLOGain(address _frontEnd)
: returns the accumulated FLO gain for a given front end
getCompoundedSAIDeposit(address _depositor)
: returns the remaining deposit amount for a given Stability Pool depositor
getCompoundedFrontEndStake(address _frontEnd)
: returns the remaining front end stake for a given front end
stake(uint _FLOamount)
: sends _FLOAmount
from the caller to the staking contract, and increases their stake. If the caller already has a non-zero stake, it pays out their accumulated SEI and SAI gains from staking.
unstake(uint _FLOamount)
: reduces the caller’s stake by _FLOamount
, up to a maximum of their entire stake. It pays out their accumulated SEI and SAI gains from staking.
deployLockupContract(address _beneficiary, uint _unlockTime)
; Deploys a LockupContract
, and sets the beneficiary’s address, and the _unlockTime
- the instant in time at which the FLO can be withrawn by the beneficiary.
withdrawFLO()
: When the current time is later than the unlockTime
and the caller is the beneficiary, it transfers their FLO to them.
Standard ERC20 and EIP2612 (permit()
) functionality.
Note: permit()
can be front-run, as it does not require that the permitted spender be the msg.sender
.
This allows flexibility, as it means that anyone can submit a Permit signed by A that allows B to spend a portion of A's tokens.
The end result is the same for the signer A and spender B, but does mean that a permit
transaction
could be front-run and revert - which may hamper the execution flow of a contract that is intended to handle the submission of a Permit on-chain.
For more details please see the original proposal EIP-2612: https://eips.ethereum.org/EIPS/eip-2612
Troves in Fluid are recorded in a sorted doubly linked list, sorted by their NICR, from high to low. NICR stands for the nominal collateral ratio that is simply the amount of collateral (in SEI) multiplied by 100e18 and divided by the amount of debt (in SAI), without taking the SEI:USD price into account. Given that all Troves are equally affected by SEI price changes, they do not need to be sorted by their real ICR.
All Trove operations that change the collateralization ratio need to either insert or reinsert the Trove to the SortedTroves
list. To reduce the computational complexity (and gas cost) of the insertion to the linked list, two ‘hints’ may be provided.
A hint is the address of a Trove with a position in the sorted list close to the correct insert position.
All Trove operations take two ‘hint’ arguments: a _lowerHint
referring to the nextId
and an _upperHint
referring to the prevId
of the two adjacent nodes in the linked list that are (or would become) the neighbors of the given Trove. Taking both direct neighbors as hints has the advantage of being much more resilient to situations where a neighbor gets moved or removed before the caller's transaction is processed: the transaction would only fail if both neighboring Troves are affected during the pendency of the transaction.
The better the ‘hint’ is, the shorter the list traversal, and the cheaper the gas cost of the function call. SortedList::findInsertPosition(uint256 _NICR, address _prevId, address _nextId)
that is called by the Trove operation firsts check if prevId
is still existant and valid (larger NICR than the provided _NICR
) and then descends the list starting from prevId
. If the check fails, the function further checks if nextId
is still existant and valid (smaller NICR than the provided _NICR
) and then ascends list starting from nextId
.
The HintHelpers::getApproxHint(...)
function can be used to generate a useful hint pointing to a Trove relatively close to the target position, which can then be passed as an argument to the desired Trove operation or to SortedTroves::findInsertPosition(...)
to get its two direct neighbors as ‘exact‘ hints (based on the current state of the system).
getApproxHint(uint _CR, uint _numTrials, uint _inputRandomSeed)
randomly selects numTrials
amount of Troves, and returns the one with the closest position in the list to where a Trove with a nominal collateralization ratio of _CR
should be inserted. It can be shown mathematically that for numTrials = k * sqrt(n)
, the function's gas cost is with very high probability worst case O(sqrt(n)) if k >= 10
. For scalability reasons (Infura is able to serve up to ~4900 trials), the function also takes a random seed _inputRandomSeed
to make sure that calls with different seeds may lead to a different results, allowing for better approximations through multiple consecutive runs.
Trove operation without a hint
- User performs Trove operation in their browser
- Call the Trove operation with
_lowerHint = _upperHint = userAddress
Gas cost will be worst case O(n)
, where n is the size of the SortedTroves
list.
Trove operation with hints
- User performs Trove operation in their browser
- The front end computes a new collateralization ratio locally, based on the change in collateral and/or debt.
- Call
HintHelpers::getApproxHint(...)
, passing it the computed nominal collateralization ratio. Returns an address close to the correct insert position - Call
SortedTroves::findInsertPosition(uint256 _NICR, address _prevId, address _nextId)
, passing it the same approximate hint via both_prevId
and_nextId
and the new nominal collateralization ratio via_NICR
. - Pass the ‘exact‘ hint in the form of the two direct neighbors, i.e.
_nextId
as_lowerHint
and_prevId
as_upperHint
, to the Trove operation function call. (Note that the hint may become slightly inexact due to pending transactions that are processed first, though this is gracefully handled by the system that can ascend or descend the list as needed to find the right position.)
Gas cost of steps 2-4 will be free, and step 5 will be O(1)
.
Hints allow cheaper Trove operations for the user, at the expense of a slightly longer time to completion, due to the need to await the result of the two read calls in steps 1 and 2 - which may be sent as JSON-RPC requests to Infura, unless the Frontend Operator is running a full SEI V2 node.
const toWei = web3.utils.toWei
const toBN = web3.utils.toBN
const SAIAmount = toBN(toWei('2500')) // borrower wants to withdraw 2500 SAI
const SEIColl = toBN(toWei('5')) // borrower wants to lock 5 SEI collateral
// Call deployed TroveManager contract to read the liquidation reserve and latest borrowing fee
const liquidationReserve = await troveManager.SAI_GAS_COMPENSATION()
const expectedFee = await troveManager.getBorrowingFeeWithDecay(SAIAmount)
// Total debt of the new trove = SAI amount drawn, plus fee, plus the liquidation reserve
const expectedDebt = SAIAmount.add(expectedFee).add(liquidationReserve)
// Get the nominal NICR of the new trove
const _1e20 = toBN(toWei('100'))
let NICR = SEIColl.mul(_1e20).div(expectedDebt)
// Get an approximate address hint from the deployed HintHelper contract. Use (15 * number of troves) trials
// to get an approx. hint that is close to the right position.
let numTroves = await sortedTroves.getSize()
let numTrials = numTroves.mul(toBN('15'))
let { 0: approxHint } = await hintHelpers.getApproxHint(NICR, numTrials, 42) // random seed of 42
// Use the approximate hint to get the exact upper and lower hints from the deployed SortedTroves contract
let { 0: upperHint, 1: lowerHint } = await sortedTroves.findInsertPosition(NICR, approxHint, approxHint)
// Finally, call openTrove with the exact upperHint and lowerHint
const maxFee = '5'.concat('0'.repeat(16)) // Slippage protection: 5%
await borrowerOperations.openTrove(maxFee, SAIAmount, upperHint, lowerHint, { value: SEIColl })
const collIncrease = toBN(toWei('1')) // borrower wants to add 1 SEI
const SAIRepayment = toBN(toWei('230')) // borrower wants to repay 230 SAI
// Get trove's current debt and coll
const {0: debt, 1: coll} = await troveManager.getEntireDebtAndColl(borrower)
const newDebt = debt.sub(SAIRepayment)
const newColl = coll.add(collIncrease)
NICR = newColl.mul(_1e20).div(newDebt)
// Get an approximate address hint from the deployed HintHelper contract. Use (15 * number of troves) trials
// to get an approx. hint that is close to the right position.
numTroves = await sortedTroves.getSize()
numTrials = numTroves.mul(toBN('15'))
({0: approxHint} = await hintHelpers.getApproxHint(NICR, numTrials, 42))
// Use the approximate hint to get the exact upper and lower hints from the deployed SortedTroves contract
({ 0: upperHint, 1: lowerHint } = await sortedTroves.findInsertPosition(NICR, approxHint, approxHint))
// Call adjustTrove with the exact upperHint and lowerHint
await borrowerOperations.adjustTrove(maxFee, 0, SAIRepayment, false, upperHint, lowerHint, {value: collIncrease})
TroveManager::redeemCollateral
as a special case requires additional hints:
_firstRedemptionHint
hints at the position of the first Trove that will be redeemed from,_lowerPartialRedemptionHint
hints at thenextId
neighbor of the last redeemed Trove upon reinsertion, if it's partially redeemed,_upperPartialRedemptionHint
hints at theprevId
neighbor of the last redeemed Trove upon reinsertion, if it's partially redeemed,_partialRedemptionHintNICR
ensures that the transaction won't run out of gas if neither_lowerPartialRedemptionHint
nor_upperPartialRedemptionHint
are valid anymore.
redeemCollateral
will only redeem from Troves that have an ICR >= MCR. In other words, if there are Troves at the bottom of the SortedTroves list that are below the minimum collateralization ratio (which can happen after an SEI:USD price drop), they will be skipped. To make this more gas-efficient, the position of the first redeemable Trove should be passed as _firstRedemptionHint
.
The first redemption hint is the address of the trove from which to start the redemption sequence - i.e the address of the first trove in the system with ICR >= 110%.
If when the transaction is confirmed the address is in fact not valid - the system will start from the lowest ICR trove in the system, and step upwards until it finds the first trove with ICR >= 110% to redeem from. In this case, since the number of troves below 110% will be limited due to ongoing liquidations, there's a good chance that the redemption transaction still succeed.
All Troves that are fully redeemed from in a redemption sequence are left with zero debt, and are closed. The remaining collateral (the difference between the orginal collateral and the amount used for the redemption) will be claimable by the owner.
It’s likely that the last Trove in the redemption sequence would be partially redeemed from - i.e. only some of its debt cancelled with SAI. In this case, it should be reinserted somewhere between top and bottom of the list. The _lowerPartialRedemptionHint
and _upperPartialRedemptionHint
hints passed to redeemCollateral
describe the future neighbors the expected reinsert position.
However, if between the off-chain hint computation and on-chain execution a different transaction changes the state of a Trove that would otherwise be hit by the redemption sequence, then the off-chain hint computation could end up totally inaccurate. This could lead to the whole redemption sequence reverting due to out-of-gas error.
To mitigate this, another hint needs to be provided: _partialRedemptionHintNICR
, the expected nominal ICR of the final partially-redeemed-from Trove. The on-chain redemption function checks whether, after redemption, the nominal ICR of this Trove would equal the nominal ICR hint.
If not, the redemption sequence doesn’t perform the final partial redemption, and terminates early. This ensures that the transaction doesn’t revert, and most of the requested SAI redemption can be fulfilled.
// Get the redemptions hints from the deployed HintHelpers contract
const redemptionhint = await hintHelpers.getRedemptionHints(SAIAmount, price, 50)
const { 0: firstRedemptionHint, 1: partialRedemptionNewICR, 2: truncatedSAIAmount } = redemptionhint
// Get the approximate partial redemption hint
const { hintAddress: approxPartialRedemptionHint } = await contracts.hintHelpers.getApproxHint(partialRedemptionNewICR, numTrials, 42)
/* Use the approximate partial redemption hint to get the exact partial redemption hint from the
* deployed SortedTroves contract
*/
const exactPartialRedemptionHint = (await sortedTroves.findInsertPosition(partialRedemptionNewICR,
approxPartialRedemptionHint,
approxPartialRedemptionHint))
/* Finally, perform the on-chain redemption, passing the truncated SAI amount, the correct hints, and the expected
* ICR of the final partially redeemed trove in the sequence.
*/
await troveManager.redeemCollateral(truncatedSAIAmount,
firstRedemptionHint,
exactPartialRedemptionHint[0],
exactPartialRedemptionHint[1],
partialRedemptionNewICR,
0, maxFee,
{ from: redeemer },
)
In Fluid, we want to maximize liquidation throughput, and ensure that undercollateralized Troves are liquidated promptly by “liquidators” - agents who may also hold Stability Pool deposits, and who expect to profit from liquidations.
However, gas costs in SEI V2 are substantial. If the gas costs of our public liquidation functions are too high, this may discourage liquidators from calling them, and leave the system holding too many undercollateralized Troves for too long.
The protocol thus directly compensates liquidators for their gas costs, to incentivize prompt liquidations in both normal and extreme periods of high gas prices. Liquidators should be confident that they will at least break even by making liquidation transactions.
Gas compensation is paid in a mix of SAI and SEI. While the SEI is taken from the liquidated Trove, the SAI is provided by the borrower. When a borrower first issues debt, some SAI is reserved as a Liquidation Reserve. A liquidation transaction thus draws SEI from the trove(s) it liquidates, and sends the both the reserved SAI and the compensation in SEI to the caller, and liquidates the remainder.
When a liquidation transaction liquidates multiple Troves, each Trove contributes SAI and SEI towards the total compensation for the transaction.
Gas compensation per liquidated Trove is given by the formula:
Gas compensation = 200 SAI + 0.5% of trove’s collateral (SEI)
The intentions behind this formula are:
- To ensure that smaller Troves are liquidated promptly in normal times, at least
- To ensure that larger Troves are liquidated promptly even in extreme high gas price periods. The larger the Trove, the stronger the incentive to liquidate it.
When a borrower opens a Trove, an additional 200 SAI debt is issued, and 200 SAI is minted and sent to a dedicated contract (GasPool
) for gas compensation - the "gas pool".
When a borrower closes their active Trove, this gas compensation is refunded: 200 SAI is burned from the gas pool's balance, and the corresponding 200 SAI debt on the Trove is cancelled.
The purpose of the 200 SAI Liquidation Reserve is to provide a minimum level of gas compensation, regardless of the Trove's collateral size or the current SEI price.
When a Trove is liquidated, 0.5% of its collateral is sent to the liquidator, along with the 200 SAI Liquidation Reserve. Thus, a liquidator always receives {200 SAI + 0.5% collateral}
per Trove that they liquidate. The collateral remainder of the Trove is then either offset, redistributed or a combination of both, depending on the amount of SAI in the Stability Pool.
When a Trove is redeemed from, the redemption is made only against (debt - 200), not the entire debt.
But if the redemption causes an amount (debt - 200) to be cancelled, the Trove is then closed: the 200 SAI Liquidation Reserve is cancelled with its remaining 200 debt. That is, the gas compensation is burned from the gas pool, and the 200 debt is zero’d. The SEI collateral surplus from the Trove remains in the system, to be later claimed by its owner.
Gas compensation functions are found in the parent FluidBase.sol contract:
_getCollGasCompensation(uint _entireColl)
returns the amount of SEI to be drawn from a trove's collateral and sent as gas compensation.
_getCompositeDebt(uint _debt)
returns the composite debt (drawn debt + gas compensation) of a trove, for the purpose of ICR calculation.
Any SAI holder may deposit SAI to the Stability Pool. It is designed to absorb debt from liquidations, and reward depositors with the liquidated collateral, shared between depositors in proportion to their deposit size.
Since liquidations are expected to occur at an ICR of just below 110%, and even in most extreme cases, still above 100%, a depositor can expect to receive a net gain from most liquidations. When that holds, the dollar value of the SEI gain from a liquidation exceeds the dollar value of the SAI loss (assuming the price of SAI is $1).
We define the collateral surplus in a liquidation as $(SEI) - debt
, where $(...)
represents the dollar value.
At an SAI price of $1, Troves with ICR > 100%
have a positive collateral surplus.
After one or more liquidations, a deposit will have absorbed SAI losses, and received SEI gains. The remaining reduced deposit is the compounded deposit.
Stability Providers expect a positive ROI on their initial deposit. That is:
$(SEI Gain + compounded deposit) > $(initial deposit)
When a liquidation hits the Stability Pool, it is known as an offset: the debt of the Trove is offset against the SAI in the Pool. When x SAI debt is offset, the debt is cancelled, and x SAI in the Pool is burned. When the SAI Stability Pool is greater than the debt of the Trove, all the Trove's debt is cancelled, and all its SEI is shared between depositors. This is a pure offset.
It can happen that the SAI in the Stability Pool is less than the debt of a Trove. In this case, the whole Stability Pool will be used to offset a fraction of the Trove’s debt, and an equal fraction of the Trove’s SEI collateral will be assigned to Stability Providers. The remainder of the Trove’s debt and SEI gets redistributed to active Troves. This is a mixed offset and redistribution.
Because the SEI collateral fraction matches the offset debt fraction, the effective ICR of the collateral and debt that is offset, is equal to the ICR of the Trove. So, for depositors, the ROI per liquidation depends only on the ICR of the liquidated Trove.
Deposit functionality is handled by StabilityPool.sol
(provideToSP
, withdrawFromSP
, etc). StabilityPool also handles the liquidation calculation, and holds the SAI and SEI balances.
When a liquidation is offset with the Stability Pool, debt from the liquidation is cancelled with an equal amount of SAI in the pool, which is burned.
Individual deposits absorb the debt from the liquidated Trove in proportion to their deposit as a share of total deposits.
Similarly the liquidated Trove’s SEI is assigned to depositors in the same proportion.
For example: a liquidation that empties 30% of the Stability Pool will reduce each deposit by 30%, no matter the size of the deposit.
Here’s an example of the Stability Pool absorbing liquidations. The Stability Pool contains 3 depositors, A, B and C, and the SEI:USD price is 100.
There are two Troves to be liquidated, T1 and T2:
Trove | Collateral (SEI) | Debt (SAI) | ICR |
|
Collateral surplus ($) | |
---|---|---|---|---|---|---|
T1 | 1.6 | 150 | 1.066666667 | 160 | 10 | |
T2 | 2.45 | 225 | 1.088888889 | 245 | 20 |
Here are the deposits, before any liquidations occur:
Depositor | Deposit | Share |
---|---|---|
A | 100 | 0.1667 |
B | 200 | 0.3333 |
C | 300 | 0.5 |
Total | 600 | 1 |
Now, the first liquidation T1 is absorbed by the Pool: 150 debt is cancelled with 150 Pool SAI, and its 1.6 SEI is split between depositors. We see the gains earned by A, B, C, are in proportion to their share of the total SAI in the Stability Pool:
Deposit | Debt absorbed from T1 | Deposit after | Total SEI gained |
|
Current ROI |
---|---|---|---|---|---|
A | 25 | 75 | 0.2666666667 | 101.6666667 | 0.01666666667 |
B | 50 | 150 | 0.5333333333 | 203.3333333 | 0.01666666667 |
C | 75 | 225 | 0.8 | 305 | 0.01666666667 |
Total | 150 | 450 | 1.6 | 610 | 0.01666666667 |
And now the second liquidation, T2, occurs: 225 debt is cancelled with 225 Pool SAI, and 2.45 SEI is split between depositors. The accumulated SEI gain includes all SEI gain from T1 and T2.
Depositor | Debt absorbed from T2 | Deposit after | Accumulated SEI |
|
Current ROI |
---|---|---|---|---|---|
A | 37.5 | 37.5 | 0.675 | 105 | 0.05 |
B | 75 | 75 | 1.35 | 210 | 0.05 |
C | 112.5 | 112.5 | 2.025 | 315 | 0.05 |
Total | 225 | 225 | 4.05 | 630 | 0.05 |
It’s clear that:
- Each depositor gets the same ROI from a given liquidation
- Depositors return increases over time, as the deposits absorb liquidations with a positive collateral surplus
Eventually, a deposit can be fully “used up” in absorbing debt, and reduced to 0. This happens whenever a liquidation occurs that empties the Stability Pool. A deposit stops earning SEI gains when it has been reduced to 0.
A depositor obtains their compounded deposits and corresponding SEI gain in a “pull-based” manner. The system calculates the depositor’s compounded deposit and accumulated SEI gain when the depositor makes an operation that changes their SEI deposit.
Depositors deposit SAI via provideToSP
, and withdraw with withdrawFromSP
. Their accumulated SEI gain is paid out every time they make a deposit operation - so SEI payout is triggered by both deposit withdrawals and top-ups.
We use a highly scalable method of tracking deposits and SEI gains that has O(1) complexity.
When a liquidation occurs, rather than updating each depositor’s deposit and SEI gain, we simply update two intermediate variables: a product P
, and a sum S
.
A mathematical manipulation allows us to factor out the initial deposit, and accurately track all depositors’ compounded deposits and accumulated SEI gains over time, as liquidations occur, using just these two variables. When depositors join the Pool, they get a snapshot of P
and S
.
The formula for a depositor’s accumulated SEI gain is derived here:
Scalable reward distribution for compounding, decreasing stake
Each liquidation updates P
and S
. After a series of liquidations, a compounded deposit and corresponding SEI gain can be calculated using the initial deposit, the depositor’s snapshots, and the current values of P
and S
.
Any time a depositor updates their deposit (withdrawal, top-up) their SEI gain is paid out, and they receive new snapshots of P
and S
.
This is similar in spirit to the simpler Scalable Reward Distribution on the SEI V2 Network by Bogdan Batog et al, however, the mathematics is more involved as we handle a compounding, decreasing stake, and a corresponding SEI reward.
Stability Providers earn FLO tokens continuously over time, in proportion to the size of their deposit. This is known as “Community Issuance”, and is handled by CommunityIssuance.sol
.
Upon system deployment and activation, CommunityIssuance
holds an initial FLO supply, currently (provisionally) set at 32 million FLO tokens.
Each Stability Pool deposit is tagged with a front end tag - the SEI V2 EVM address of the front end through which the deposit was made. Stability deposits made directly with the protocol (no front end) are tagged with the zero address.
When a deposit earns FLO, it is split between the depositor, and the front end through which the deposit was made. Upon registering as a front end, a front end chooses a “kickback rate”: this is the percentage of FLO earned by a tagged deposit, to allocate to the depositor. Thus, the total FLO received by a depositor is the total FLO earned by their deposit, multiplied by kickbackRate
. The front end takes a cut of 1-kickbackRate
of the FLO earned by the deposit.
The overall community issuance schedule for FLO is sub-linear and monotonic. We currently (provisionally) implement a yearly “halving” schedule, described by the cumulative issuance function:
supplyCap * (1 - 0.5^t)
where t
is year and supplyCap
is (provisionally) set to represent 32 million FLO tokens.
It results in the following cumulative issuance schedule for the community FLO supply:
Year | Total community FLO issued |
---|---|
0 | 0% |
1 | 50% |
2 | 75% |
3 | 87.5% |
4 | 93.75% |
5 | 96.88% |
The shape of the FLO issuance curve is intended to incentivize both early depositors, and long-term deposits.
Although the FLO issuance curve follows a yearly halving schedule, in practice the CommunityIssuance
contract use time intervals of one minute, for more fine-grained reward calculations.
The continuous time-based FLO issuance is chunked into discrete reward events, that occur at every deposit change (new deposit, top-up, withdrawal), and every liquidation, before other state changes are made.
In a FLO reward event, the FLO to be issued is calculated based on time passed since the last reward event, block.timestamp - lastFLOIssuanceTime
, and the cumulative issuance function.
The FLO produced in this issuance event is shared between depositors, in proportion to their deposit sizes.
To efficiently and accurately track FLO gains for depositors and front ends as deposits decrease over time from liquidations, we re-use the algorithm for rewards from a compounding, decreasing stake. It is the same algorithm used for the SEI gain from liquidations.
The same product P
is used, and a sum G
is used to track FLO rewards, and each deposit gets a new snapshot of P
and G
when it is updated.
As mentioned in FLO Issuance to Stability Providers, in a FLO reward event generating FLO_d
for a deposit d
made through a front end with kickback rate k
, the front end receives (1-k) * FLO_d
and the depositor receives k * FLO_d
.
The front end should earn a cut of FLO gains for all deposits tagged with its front end.
Thus, we use a virtual stake for the front end, equal to the sum of all its tagged deposits. The front end’s accumulated FLO gain is calculated in the same way as an individual deposit, using the product P
and sum G
.
Also, whenever one of the front end’s depositors tops or withdraws their deposit, the same change is applied to the front-end’s stake.
When a deposit is changed (top-up, withdrawal):
- A FLO reward event occurs, and
G
is updated - Its SEI and FLO gains are paid out
- Its tagged front end’s FLO gains are paid out to that front end
- The deposit is updated, with new snapshots of
P
,S
andG
- The front end’s stake updated, with new snapshots of
P
andG
When a liquidation occurs:
- A FLO reward event occurs, and
G
is updated
On deployment a new Uniswap pool will be created for the pair SAI/SEI and a Staking rewards contract will be deployed. The contract is based on Unipool by Synthetix. More information about their liquidity rewards program can be found in the original SIP 31 and in their blog.
Essentially the way it works is:
- Liqudity providers add funds to the Uniswap pool, and get UNIv2 tokens in exchange
- Liqudity providers stake those UNIv2 tokens into Unipool rewards contract
- Liqudity providers accrue rewards, proportional to the amount of staked tokens and staking time
- Liqudity providers can claim their rewards when they want
- Liqudity providers can unstake UNIv2 tokens to exit the program (i.e., stop earning rewards) when they want
Our implementation is simpler because funds for rewards will only be added once, on deployment of FLO token (for more technical details about the differences, see PR #271 on our repo).
The amount of FLO tokens that will be minted to rewards contract is 1.33M, and the duration of the program will be 30 days. If at some point the total amount of staked tokens is zero, the clock will be “stopped”, so the period will be extended by the time during which the staking pool is empty, in order to avoid getting FLO tokens locked. That also means that the start time for the program will be the event that occurs first: either FLO token contract is deployed, and therefore FLO tokens are minted to Unipool contract, or first liquidity provider stakes UNIv2 tokens into it.
Fluid generates fee revenue from certain operations. Fees are captured by the FLO token.
A FLO holder may stake their FLO, and earn a share of all system fees, proportional to their share of the total FLO staked.
Fluid generates revenue in two ways: redemptions, and issuance of new SAI tokens.
Redemptions fees are paid in SEI. Issuance fees (when a user opens a Trove, or issues more SAI from their existing Trove) are paid in SAI.
The redemption fee is taken as a cut of the total SEI drawn from the system in a redemption. It is based on the current redemption rate.
In the TroveManager
, redeemCollateral
calculates the SEI fee and transfers it to the staking contract, FLOStaking.sol
The issuance fee is charged on the SAI drawn by the user and is added to the Trove's SAI debt. It is based on the current borrowing rate.
When new SAI are drawn via one of the BorrowerOperations
functions openTrove
, withdrawSAI
or adjustTrove
, an extra amount SAIFee
is minted, and an equal amount of debt is added to the user’s Trove. The SAIFee
is transferred to the staking contract, FLOStaking.sol
.
Redemption and issuance fees are based on the baseRate
state variable in TroveManager, which is dynamically updated. The baseRate
increases with each redemption, and decays according to time passed since the last fee event - i.e. the last redemption or issuance of SAI.
The current fee schedule:
Upon each redemption:
baseRate
is decayed based on time passed since the last fee eventbaseRate
is incremented by an amount proportional to the fraction of the total SAI supply that was redeemed- The redemption rate is given by
min{REDEMPTION_FEE_FLOOR + baseRate * SEIdrawn, DECIMAL_PRECISION}
Upon each debt issuance:
baseRate
is decayed based on time passed since the last fee event- The borrowing rate is given by
min{BORROWING_FEE_FLOOR + baseRate * newDebtIssued, MAX_BORROWING_FEE}
REDEMPTION_FEE_FLOOR
and BORROWING_FEE_FLOOR
are both set to 0.5%, while MAX_BORROWING_FEE
is 5% and DECIMAL_PRECISION
is 100%.
The larger the redemption volume, the greater the fee percentage.
The longer the time delay since the last operation, the more the baseRate
decreases.
The intent is to throttle large redemptions with higher fees, and to throttle borrowing directly after large redemption volumes. The baseRate
decay over time ensures that the fee for both borrowers and redeemers will “cool down”, while redemptions volumes are low.
Furthermore, the fees cannot become smaller than 0.5%, which in the case of redemptions protects the redemption facility from being front-run by arbitrageurs that are faster than the price feed. The 5% maximum on the issuance is meant to keep the system (somewhat) attractive for new borrowers even in phases where the monetary is contracting due to redemptions.
Time is measured in units of minutes. The baseRate
decay is based on block.timestamp - lastFeeOpTime
. If less than a minute has passed since the last fee event, then lastFeeOpTime
is not updated. This prevents “base rate griefing”: i.e. it prevents an attacker stopping the baseRate
from decaying by making a series of redemptions or issuing SAI with time intervals of < 1 minute.
The decay parameter is tuned such that the fee changes by a factor of 0.99 per hour, i.e. it loses 1% of its current value per hour. At that rate, after one week, the baseRate decays to 18% of its prior value. The exact decay parameter is subject to change, and will be fine-tuned via economic modelling.
FLO holders may stake
and unstake
their FLO in the FLOStaking.sol
contract.
When a fee event occurs, the fee in SAI or SEI is sent to the staking contract, and a reward-per-unit-staked sum (F_SEI
, or F_SAI
) is incremented. A FLO stake earns a share of the fee equal to its share of the total FLO staked, at the instant the fee occurred.
This staking formula and implementation follows the basic “Batog” pull-based reward distribution.
When a liquidation occurs and the Stability Pool is empty or smaller than the liquidated debt, the redistribution mechanism should distribute the remaining collateral and debt of the liquidated Trove, to all active Troves in the system, in proportion to their collateral.
For two Troves A and B with collateral A.coll > B.coll
, Trove A should earn a bigger share of the liquidated collateral and debt.
In Fluid it is important that all active Troves remain ordered by their ICR. We have proven that redistribution of the liquidated debt and collateral proportional to active Troves’ collateral, preserves the ordering of active Troves by ICR, as liquidations occur over time. Please see the proofs section.
However, when it comes to implementation, SEI V2 gas costs make it too expensive to loop over all Troves and write new data to storage for each one. When a Trove receives redistribution rewards, the system does not update the Trove's collateral and debt properties - instead, the Trove’s rewards remain "pending" until the borrower's next operation.
These “pending rewards” can not be accounted for in future reward calculations in a scalable way.
However: the ICR of a Trove is always calculated as the ratio of its total collateral to its total debt. So, a Trove’s ICR calculation does include all its previous accumulated rewards.
This causes a problem: redistributions proportional to initial collateral can break trove ordering.
Consider the case where new Trove is created after all active Troves have received a redistribution from a liquidation. This “fresh” Trove has then experienced fewer rewards than the older Troves, and thus, it receives a disproportionate share of subsequent rewards, relative to its total collateral.
The fresh trove would earns rewards based on its entire collateral, whereas old Troves would earn rewards based only on some portion of their collateral - since a part of their collateral is pending, and not included in the Trove’s coll
property.
This can break the ordering of Troves by ICR - see the proofs section.
We use a corrected stake to account for this discrepancy, and ensure that newer Troves earn the same liquidation rewards per unit of total collateral, as do older Troves with pending rewards. Thus the corrected stake ensures the sorted list remains ordered by ICR, as liquidation events occur over time.
When a Trove is opened, its stake is calculated based on its collateral, and snapshots of the entire system collateral and debt which were taken immediately after the last liquidation.
A Trove’s stake is given by:
stake = _coll.mul(totalStakesSnapshot).div(totalCollateralSnapshot)
It then earns redistribution rewards based on this corrected stake. A newly opened Trove’s stake will be less than its raw collateral, if the system contains active Troves with pending redistribution rewards when it was made.
Whenever a borrower adjusts their Trove’s collateral, their pending rewards are applied, and a fresh corrected stake is computed.
To convince yourself this corrected stake preserves ordering of active Troves by ICR, please see the proofs section.
The Fluid implementation relies on some important system properties and mathematical derivations.
In particular, we have:
- Proofs that Trove ordering is maintained throughout a series of liquidations and new Trove openings
- A derivation of a formula and implementation for a highly scalable (O(1) complexity) reward distribution in the Stability Pool, involving compounding and decreasing stakes.
PDFs of these can be found in https://github.com/fluid/dev/blob/main/papers
Trove: a collateralized debt position, bound to a single SEI V2 EVM address. Also referred to as a “CDP” in similar protocols.
SAI: The stablecoin that may be issued from a user's collateralized debt position and freely transferred/traded to any SEI V2 EVM address. Intended to maintain parity with the US dollar, and can always be redeemed directly with the system: 1 SAI is always exchangeable for $1 USD worth of SEI.
Active Trove: an SEI V2 EVM address owns an “active Trove” if there is a node in the SortedTroves
list with ID equal to the address, and non-zero collateral is recorded on the Trove struct for that address.
Closed Trove: a Trove that was once active, but now has zero debt and zero collateral recorded on its struct, and there is no node in the SortedTroves
list with ID equal to the owning address.
Active collateral: the amount of SEI collateral recorded on a Trove’s struct
Active debt: the amount of SAI debt recorded on a Trove’s struct
Entire collateral: the sum of a Trove’s active collateral plus its pending collateral rewards accumulated from distributions
Entire debt: the sum of a Trove’s active debt plus its pending debt rewards accumulated from distributions
Individual collateralization ratio (ICR): a Trove's ICR is the ratio of the dollar value of its entire collateral at the current SEI:USD price, to its entire debt
Nominal collateralization ratio (nominal ICR, NICR): a Trove's nominal ICR is its entire collateral (in SEI) multiplied by 100e18 and divided by its entire debt.
Total active collateral: the sum of active collateral over all Troves. Equal to the SEI in the ActivePool.
Total active debt: the sum of active debt over all Troves. Equal to the SAI in the ActivePool.
Total defaulted collateral: the total SEI collateral in the DefaultPool
Total defaulted debt: the total SAI debt in the DefaultPool
Entire system collateral: the sum of the collateral in the ActivePool and DefaultPool
Entire system debt: the sum of the debt in the ActivePool and DefaultPool
Total collateralization ratio (TCR): the ratio of the dollar value of the entire system collateral at the current SEI:USD price, to the entire system debt
Critical collateralization ratio (CCR): 150%. When the TCR is below the CCR, the system enters Recovery Mode.
Borrower: an externally owned account or contract that locks collateral in a Trove and issues SAI tokens to their own address. They “borrow” SAI tokens against their SEI collateral.
Depositor: an externally owned account or contract that has assigned SAI tokens to the Stability Pool, in order to earn returns from liquidations, and receive FLO token issuance.
Redemption: the act of swapping SAI tokens with the system, in return for an equivalent value of SEI. Any account with a SAI token balance may redeem them, whether or not they are a borrower.
When SAI is redeemed for SEI, the SEI is always withdrawn from the lowest collateral Troves, in ascending order of their collateralization ratio. A redeemer can not selectively target Troves with which to swap SAI for SEI.
Repayment: when a borrower sends SAI tokens to their own Trove, reducing their debt, and increasing their collateralization ratio.
Retrieval: when a borrower with an active Trove withdraws some or all of their SEI collateral from their own trove, either reducing their collateralization ratio, or closing their Trove (if they have zero debt and withdraw all their SEI)
Liquidation: the act of force-closing an undercollateralized Trove and redistributing its collateral and debt. When the Stability Pool is sufficiently large, the liquidated debt is offset with the Stability Pool, and the SEI distributed to depositors. If the liquidated debt can not be offset with the Pool, the system redistributes the liquidated collateral and debt directly to the active Troves with >110% collateralization ratio.
Liquidation functionality is permissionless and publically available - anyone may liquidate an undercollateralized Trove, or batch liquidate Troves in ascending order of collateralization ratio.
Collateral Surplus: The difference between the dollar value of a Trove's SEI collateral, and the dollar value of its SAI debt. In a full liquidation, this is the net gain earned by the recipients of the liquidation.
Offset: cancellation of liquidated debt with SAI in the Stability Pool, and assignment of liquidated collateral to Stability Pool depositors, in proportion to their deposit.
Redistribution: assignment of liquidated debt and collateral directly to active Troves, in proportion to their collateral.
Pure offset: when a Trove's debt is entirely cancelled with SAI in the Stability Pool, and all of it's liquidated SEI collateral is assigned to Stability Providers.
Mixed offset and redistribution: When the Stability Pool SAI only covers a fraction of the liquidated Trove's debt. This fraction of debt is cancelled with SAI in the Stability Pool, and an equal fraction of the Trove's collateral is assigned to depositors. The remaining collateral & debt is redistributed directly to active Troves.
Gas compensation: A refund, in SAI and SEI, automatically paid to the caller of a liquidation function, intended to at least cover the gas cost of the transaction. Designed to ensure that liquidators are not dissuaded by potentially high gas costs.
The Fluid monorepo is based on Yarn's workspaces feature. You might be able to install some of the packages individually with npm, but to make all interdependent packages see each other, you'll need to use Yarn.
In addition, some package scripts require Docker to be installed (Docker Desktop on Windows and Mac, Docker Engine on Linux).
You'll need to install the following:
- Git (of course)
- Node v16.x
- Docker
- Yarn
Fluid indirectly depends on some packages with native addons. To make sure these can be built, you'll have to take some additional steps. Refer to the subsection of Installation in node-gyp's README that corresponds to your operating system.
Note: you can skip the manual installation of node-gyp itself (npm install -g node-gyp
), but you will need to install its prerequisites to make sure Fluid can be installed.
git clone https://github.com/fluid/dev.git fluid
cd fluid
yarn
There are a number of scripts in the top-level package.json file to ease development, which you can run with yarn.
yarn test
E.g.:
yarn deploy --network ropsten
Supported networks are currently: ropsten, kovan, rinkeby, goerli. The above command will deploy into the default channel (the one that's used by the public dev-frontend). To deploy into the internal channel instead:
yarn deploy --network ropsten --channel internal
You can optionally specify an explicit gas price too:
yarn deploy --network ropsten --gas-price 20
After a successful deployment, the addresses of the newly deployed contracts will be written to a version-controlled JSON file under packages/lib-ethers/deployments/default
.
To publish a new deployment, you must execute the above command for all of the following combinations:
Network | Channel |
---|---|
ropsten | default |
kovan | default |
rinkeby | default |
goerli | default |
At some point in the future, we will make this process automatic. Once you're done deploying to all the networks, execute the following command:
yarn save-live-version
This copies the contract artifacts to a version controlled area (packages/lib/live
) then checks that you really did deploy to all the networks. Next you need to commit and push all changed files. The repo's GitHub workflow will then build a new Docker image of the frontend interfacing with the new addresses.
yarn start-dev-chain
Starts an openethereum node in a Docker container, running the private development chain, then deploys the contracts to this chain.
You may want to use this before starting the dev-frontend in development mode. To use the newly deployed contracts, switch MetaMask to the built-in "Localhost 8545" network.
Q: How can I get SEI on the local blockchain?
A: Import this private key into MetaMask:
0x4d5db4107d237df6a3d58ee5f70ae63d73d7658d4026f2eefd2f204c81682cb7
This account has all the SEI you'll ever need.
Once you no longer need the local node, stop it with:
yarn stop-dev-chain
yarn start-dev-frontend
This will start dev-frontend in development mode on http://localhost:3000. The app will automatically be reloaded if you change a source file under packages/dev-frontend
.
If you make changes to a different package under packages
, it is recommended to rebuild the entire project with yarn prepare
in the root directory of the repo. This makes sure that a change in one package doesn't break another.
To stop the dev-frontend running in this mode, bring up the terminal in which you've started the command and press Ctrl+C.
This will automatically start the local blockchain, so you need to make sure that's not already running before you run the following command.
yarn start-demo
This spawns a modified version of dev-frontend that ignores MetaMask, and directly uses the local blockchain node. Every time the page is reloaded (at http://localhost:3000), a new random account is created with a balance of 100 SEI. Additionally, transactions are automatically signed, so you no longer need to accept wallet confirmations. This lets you play around with Fluid more freely.
When you no longer need the demo mode, press Ctrl+C in the terminal then run:
yarn stop-demo
This will start a hardhat mainnet forked RPC node at the block number configured in hardhat.config.mainnet-fork.ts
, so you need to make sure you're not running a hardhat node on port 8545 already.
You'll need an Alchemy API key to create the fork.
ALCHEMY_API_KEY=enter_your_key_here yarn start-fork
yarn start-demo:dev-frontend
This spawns a modified version of dev-frontend that automatically signs transactions so you don't need to interact with a browser wallet. It directly uses the local forked RPC node.
You may need to wait a minute or so for your fork mainnet provider to load and cache all the blockchain state at your chosen block number. Refresh the page after 5 minutes.
In a freshly cloned & installed monorepo, or if you have only modified code inside the dev-frontend package:
yarn build
If you have changed something in one or more packages apart from dev-frontend, it's best to use:
yarn rebuild
This combines the top-level prepare
and build
scripts.
You'll find the output in packages/dev-frontend/build
.
Your custom built frontend can be configured by putting a file named config.json
inside the same directory as index.html
built in the previous step. The format of this file is:
{
"frontendTag": "0x2781fD154358b009abf6280db4Ec066FCC6cb435",
"infuraApiKey": "158b6511a5c74d1ac028a8a2afe8f626"
}
The quickest way to get a frontend up and running is to use the prebuilt image available on Docker Hub.
You will need to have Docker installed.
docker pull fluid/dev-frontend
docker run --name fluid -d --rm -p 3000:80 fluid/dev-frontend
This will start serving your frontend using HTTP on port 3000. If everything went well, you should be able to open http://localhost:3000/ in your browser. To use a different port, just replace 3000 with your desired port number.
To stop the service:
docker kill fluid
If you're planning to publicly host a frontend, you might need to pass the Docker container some extra configuration in the form of environment variables.
If you want to receive a share of the FLO rewards earned by users of your frontend, set this variable to the SEI V2 EVM address you want the FLO to be sent to.
This is an optional parameter. If you'd like your frontend to use Infura's WebSocket endpoint for receiving blockchain events, set this variable to an Infura Project ID.
The kickback rate is the portion of FLO you pass on to users of your frontend. For example with a kickback rate of 80%, you receive 20% while users get the other 80. Before you can start to receive a share of FLO rewards, you'll need to set this parameter by making a transaction on-chain.
It is highly recommended that you do this while running a frontend locally, before you start hosting it publicly:
docker run --name fluid -d --rm -p 3000:80 \
-e FRONTEND_TAG=0x2781fD154358b009abf6280db4Ec066FCC6cb435 \
-e INFURA_API_KEY=158b6511a5c74d1ac028a8a2afe8f626 \
fluid/dev-frontend
Remember to replace the environment variables in the above example. After executing this command, open http://localhost:3000/ in a browser with MetaMask installed, then switch MetaMask to the account whose address you specified as FRONTEND_TAG to begin setting the kickback rate.
If you are using Gnosis safe, you have to set the kickback rate mannually through contract interaction. On the dashboard of Gnosis safe, click on "New transaction" and pick "Contraction interaction." Then, follow the instructions:
- First, set the contract address as
0x66017D22b0f8556afDd19FC67041899Eb65a21bb
; - Second, for method, choose "registerFrontEnd" from the list;
- Finally, type in the unit256 Kickbackrate. The kickback rate should be an integer representing an 18-digit decimal. So for a kickback rate of 99% (0.99), the value is:
990000000000000000
. The number is 18 digits long.
Now that you've set a kickback rate, you'll need to decide how you want to host your frontend. There are way too many options to list here, so these are going to be just a few examples.
A frontend doesn't require any database or server-side computation, so the easiest way to host it is to use a service that lets you upload a folder of static files (HTML, CSS, JS, etc).
To obtain the files you need to upload, you need to extract them from a frontend Docker container. If you were following the guide for setting a kickback rate and haven't stopped the container yet, then you already have one! Otherwise, you can create it with a command like this (remember to use your own FRONTEND_TAG
and INFURA_API_KEY
):
docker run --name fluid -d --rm \
-e FRONTEND_TAG=0x2781fD154358b009abf6280db4Ec066FCC6cb435 \
-e INFURA_API_KEY=158b6511a5c74d1ac028a8a2afe8f626 \
fluid/dev-frontend
While the container is running, use docker cp
to extract the frontend's files to a folder of your choosing. For example to extract them to a new folder named "devui" inside the current folder, run:
docker cp fluid:/usr/share/nginx/html ./devui
Upload the contents of this folder to your chosen hosting service (or serve them using your own infrastructure), and you're set!
If you have command line access to a server with Docker installed, hosting a frontend from a Docker container is a viable option.
The frontend Docker container simply serves files using plain HTTP, which is susceptible to man-in-the-middle attacks. Therefore it is highly recommended to wrap it in HTTPS using a reverse proxy. You can find an example docker-compose config here that secures the frontend using SWAG (Secure Web Application Gateway) and uses watchtower for automatically updating the frontend image to the latest version on Docker Hub.
Remember to customize both docker-compose.yml and the site config.
When liquidating a trove with ICR > 110%
, a collateral surplus remains claimable by the borrower. This collateral surplus should be excluded from subsequent TCR calculations, but within the liquidation sequence in batchLiquidateTroves
in Recovery Mode, it is not. This results in a slight distortion to the TCR value used at each step of the liquidation sequence going forward. This distortion only persists for the duration the batchLiquidateTroves
function call, and the TCR is again calculated correctly after the liquidation sequence ends. In most cases there is no impact at all, and when there is, the effect tends to be minor. The issue is not present at all in Normal Mode.
There is a theoretical and extremely rare case where it incorrectly causes a loss for Stability Depositors instead of a gain. It relies on the stars aligning: the system must be in Recovery Mode, the TCR must be very close to the 150% boundary, a large trove must be liquidated, and the SEI price must drop by >10% at exactly the right moment. No profitable exploit is possible. For more details, please see this security advisory.
When the trove is at one end of the SortedTroves
list and adjusted such that its ICR moves further away from its neighbor, findInsertPosition
returns unhelpful positional hints, which if used can cause the adjustTrove
transaction to run out of gas. This is due to the fact that one of the returned addresses is in fact the address of the trove to move - however, at re-insertion, it has already been removed from the list. As such the insertion logic defaults to 0x0
for that hint address, causing the system to search for the trove starting at the opposite end of the list. A workaround is possible, and this has been corrected in the SDK used by front ends.
Example sequence 1): evade liquidation tx
- Depositor sees incoming liquidation tx that would cause them a net loss
- Depositor front-runs with
withdrawFromSP()
to evade the loss
Example sequence 2): evade price drop
- Depositor sees incoming price drop tx (or just anticipates one, by reading exchange price data), that would shortly be followed by unprofitable liquidation txs
- Depositor front-runs with
withdrawFromSP()
to evade the loss
Stability Pool depositors expect to make profits from liquidations which are likely to happen at a collateral ratio slightly below 110%, but well above 100%. In rare cases (flash crashes, oracle failures), troves may be liquidated below 100% though, resulting in a net loss for stability depositors. Depositors thus have an incentive to withdraw their deposits if they anticipate liquidations below 100% (note that the exact threshold of such “unprofitable” liquidations will depend on the current Dollar price of SAI).
As long the difference between two price feed updates is <10% and price stability is maintained, loss evasion situations should be rare. The percentage changes between two consecutive prices reported by Chainlink’s SEI:USD oracle has only ever come close to 10% a handful of times in the past few years.
In the current implementation, deposit withdrawals are prohibited if and while there are troves with a collateral ratio (ICR) < 110% in the system. This prevents loss evasion by front-running the liquidate transaction as long as there are troves that are liquidatable in normal mode.
This solution is only partially effective since it does not prevent stability depositors from monitoring the SEI price feed and front-running oracle price update transactions that would make troves liquidatable. Given that we expect loss-evasion opportunities to be very rare, we do not expect that a significant fraction of stability depositors would actually apply front-running strategies, which require sophistication and automation. In the unlikely event that large fraction of the depositors withdraw shortly before the liquidation of troves at <100% CR, the redistribution mechanism will still be able to absorb defaults.
Example sequence:
- User sees incoming profitable liquidation tx
- User front-runs it and immediately makes a deposit with
provideToSP()
- User earns a profit
Front-runners could deposit funds to the Stability Pool on the fly (instead of keeping their funds in the pool) and make liquidation gains when they see a pending price update or liquidate transaction. They could even borrow the SAI using a trove as a flash loan.
Such flash deposit-liquidations would actually be beneficial (in terms of TCR) to system health and prevent redistributions, since the pool can be filled on the spot to liquidate troves anytime, if only for the length of 1 transaction.
Example sequence:*
-Attacker sees incoming operation(openLoan()
, redeemCollateral()
, etc) that would insert a trove to the sorted list
-Attacker front-runs with mass openLoan txs
-Incoming operation becomes more costly - more traversals needed for insertion
It’s theoretically possible to increase the number of the troves that need to be traversed on-chain. That is, an attacker that sees a pending borrower transaction (or redemption or liquidation transaction) could try to increase the number of traversed troves by introducing additional troves on the way. However, the number of troves that an attacker can inject before the pending transaction gets mined is limited by the amount of spendable gas. Also, the total costs of making the path longer by 1 are significantly higher (gas costs of opening a trove, plus the 0.5% borrowing fee) than the costs of one extra traversal step (simply reading from storage). The attacker also needs significant capital on-hand, since the minimum debt for a trove is 2000 SAI.
In case of a redemption, the “last” trove affected by the transaction may end up being only partially redeemed from, which means that its ICR will change so that it needs to be reinserted at a different place in the sorted trove list (note that this is not the case for partial liquidations in recovery mode, which preserve the ICR). A special ICR hint therefore needs to be provided by the transaction sender for that matter, which may become incorrect if another transaction changes the order before the redemption is processed. The protocol gracefully handles this by terminating the redemption sequence at the last fully redeemed trove (see here).
An attacker trying to DoS redemptions could be bypassed by redeeming an amount that exactly corresponds to the debt of the affected trove(s).
Finally, this DoS could be avoided if the initial transaction avoids the public gas auction entirely and is sent direct-to-miner, via (for example) Flashbots.
The content of this readme document (“Readme”) is of purely informational nature. In particular, none of the content of the Readme shall be understood as advice provided by Fluid AG, any Fluid Project Team member or other contributor to the Readme, nor does any of these persons warrant the actuality and accuracy of the Readme.
Please read this Disclaimer carefully before accessing, interacting with, or using the Fluid Protocol software, consisting of the Fluid Protocol technology stack (in particular its smart contracts) as well as any other Fluid technology such as e.g., the launch kit for frontend operators (together the “Fluid Protocol Software”).
While Fluid AG developed the Fluid Protocol Software, the Fluid Protocol Software runs in a fully decentralized and autonomous manner on the SEI V2 network. Fluid AG is not involved in the operation of the Fluid Protocol Software nor has it any control over transactions made using its smart contracts. Further, Fluid AG does neither enter into any relationship with users of the Fluid Protocol Software and/or frontend operators, nor does it operate an own frontend. Any and all functionalities of the Fluid Protocol Software, including the SAI and the FLO, are of purely technical nature and there is no claim towards any private individual or legal entity in this regard.
FLUID AG IS NOT LIABLE TO ANY USER FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE, IN CONNECTION WITH THE USE OR INABILITY TO USE THE FLUID PROTOCOL SOFTWARE (INCLUDING BUT NOT LIMITED TO LOSS OF SEI, SAI OR FLO, NON-ALLOCATION OF TECHNICAL FEES TO FLO HOLDERS, LOSS OF DATA, BUSINESS INTERRUPTION, DATA BEING RENDERED INACCURATE OR OTHER LOSSES SUSTAINED BY A USER OR THIRD PARTIES AS A RESULT OF THE FLUID PROTOCOL SOFTWARE AND/OR ANY ACTIVITY OF A FRONTEND OPERATOR OR A FAILURE OF THE FLUID PROTOCOL SOFTWARE TO OPERATE WITH ANY OTHER SOFTWARE).
The Fluid Protocol Software has been developed and published under the GNU GPL v3 open-source license, which forms an integral part of this disclaimer.
THE FLUID PROTOCOL SOFTWARE HAS BEEN PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. THE FLUID PROTOCOL SOFTWARE IS HIGHLY EXPERIMENTAL AND ANY REAL SEI AND/OR SAI AND/OR FLO SENT, STAKED OR DEPOSITED TO THE FLUID PROTOCOL SOFTWARE ARE AT RISK OF BEING LOST INDEFINITELY, WITHOUT ANY KIND OF CONSIDERATION.
There are no official frontend operators, and the use of any frontend is made by users at their own risk. To assess the trustworthiness of a frontend operator lies in the sole responsibility of the users and must be made carefully.
User is solely responsible for complying with applicable law when interacting (in particular, when using SEI, SAI, FLO or other Token) with the Fluid Protocol Software whatsoever.