The set of user's rewarding contracts include:
In this document we are going to focuse in the structure of FeeSharing contract and it's relation with the Staking contract, and the Bitocracy platform.
The main philosophy behind the participation of fee rewards, provided that a user voluntarily put under stake some of his/her SOV tokens, is to reward the commitment in the long term of the assets that represents the Sovryn project as a whole. This philosophy is the very soul of the Sovryn's Bitocracy.
That's why the architecture of the Staking contract is mainly based on time and on a tick-tock degradation of rights to participate as time progress, with the idea of incentive the staker to renew their commitment in the long term extending (ideally each two weeks) the stkaing period. This staking period grants to the user a participation weight that is not linear, but slightly exponential as the term of the commitment is longer. However to avoid any numerical and logistic management issues with the contract, there is a limit in the staking period (can not be infinite, of course).
This feature is crucial in the design of the Staking contract and in consequence, of the FeeSharing contract as well, to manage the participation of each staker. It is done by the means of a complex set of mappings related to a time dependant concept known as "checkpoint".
Such complex structure is needed in the moment that the concept of time dependant participation is applied: the change on the weight of rights of SOV stakers can not be triggered by any transaction initiated by any user - which would led to a simpler solution - but by the pass of time, and in order to keep track of time-based changes of staker's participations, we need a map storage relating users, assets and certain chunks of time - two weeks, for the Staking contract indeed - delimited by the so called "lock dates". This name is deceiving, because while this moments of time define points in which the SOV assets can be locked or staked, they are also the moments of time in which these commitments can be... "unlocked".
The mathematical formulation for the weights in the participation are based on the Compound protocol.
Given that all parameters in solidity are in fact positive integers, and divisions are round down integer numbers, it is relevant to have it clear all about the ceiling and floor functions.
Fee-Sharing contract has a set of dependencies required for it to work. In this section we give a quick review on these dependencies.
The FeeSharing contract is very simple. It only includes three groups of methods:
These are the methods that fund the FeeSharing contract with rewards coming from different sources: borrowing and lending pools, margin trading, trading in whitelisted AMM pools, and the Zero protocol.
withdrawFees(address\[ ])
: This function can be executed by anyone and requires a list of reward tokens as input. The function internally calls the Protocol's method withdrawFees
which is part of the ProtocolSettings
module. By the moment of this writing, the valid addresses to be part of the input array are:
Once IProtocol.withdrawFees
is executed, FeeSharing do the conversion of the WRBTC tokens received, into iWRBTC tokens (next to be depreciated) and the internal method _addCheckpoint
is executed for the iWRBTC token (next, only for WRBTC token) adding a register of the timestamp and the amount collected of the reward token. This allow the contract to identify which of the SOV stakers will participate of some part of this deposit. If a staker has a stake prior to the asset's checkpoint.timestamp, will be allowed to take a part of these funds as reward, and will not otherwise.
Lastly the function broadcasts the event feeSharing.FeeWithdrawn
. However, the Protocol emitted a correspondent event: Protocol.WithdrawFees
, and also returns as parameter, the amount of WRBTC token sent to the FeeSharing contract.
withdrawFeesAMM(address\[ ])
: As the former, this function can be executed by anyone, and also requires a list of converter addresses. The valid converters to be part of this array can be retrieved from the method getWhitelistedConverterList
, which can vary with time. However, we must mention that only Bancor-v1 converters are whitelisted (MOC/BTC, SOV/BTC, ETH/BTC, BNB/BTC, XUSD/BTC, FISH/BTC, RIF/BTC and MYNT/BTC converters are whitelisted in the FeeSharing contract). To verify if an address provided as converter is a valid one, the FeeSharing contract internally calls the method _validateWhitelistedConverter
.
Then, the function will internally call each converter's method withdrawFees
, which has been only implemented in v1 converters.
Once IConverterAMM.withdrawFees
is executed, FeeSharing do the conversion of the WRBTC tokens received into iWRBTC tokens (next to be depreciated) and the internal method _addCheckpoint
is executed for the iWRBTC token (next, only for WRBTC token) adding a register of the timestamp and the amount collected of the reward token. This allow the contract to identify which of the SOV stakers will participate of some part of this deposit. If a staker has a stake prior to the asset's checkpoint.timestamp, will be allowed to take a part of these funds as reward, and will not otherwise.
Lastly the function broadcasts the event FeeSharing.FeeAMMWithdrawn
. However, the converter has emitted a correspondent event: AMM_v1.WithdrawFees
, and also returns as parameter, the amount of WRBTC token sent to the FeeSharing contract.
transferTokens(address \_token, uint96 \_amount)
: This function allows to anyone to supply funds as reward to SOV stakers. This function is specially used to do special promotions as for the MYNT and ZERO protocols. Next there will be supplies from Perpetual protocol. However, this method is open to any reward program that Sovryn community may implement in the future.
This function requires that the msg.sender
previously had executed the proper ERC20.approve
function to allow the FeeSharing contract to take the aimed funds. Once the funds arrive to the FeeSharing contract, the internal method _addCheckpoint
is executed, and for the same purposes mentioned above. Lastly the FeeSharing.TokensTransferred
event is emitted.
About _addCheckpoint(address _token, uint96 _amount)
internal method:
This method will make use of 4 of the 5 mappings of the storage: lastFeeWithdrawalTime(reward_token_address)
, unprocessedAmount(reward_token_address)
, numTokenCheckpoints(reward_token_address)
and tokenCheckpoints(reward_token_address, checkpointNumber)
.
In the first place the lastFeeWithdrawalTime
which is a UNIX-epoch timestamp is updated with the current block.timestamp
.
If less than one day has passed since the last update of this value for the reward token, the amount withdrawn to FeeSharing contract is accumulated in the register of the map unprocessedAmount
.
Otherwise, the unprocessedAmount
value is emptied and the value stored is passed to the register of tokenCheckpoints
, by calling another internal method: _writeTokenCheckpoint(address _token, uint96 _numTokens)
.
This method will update the values of numTokenCheckpoints(reward_token_address)
, which is a counter number and tokenCheckpoints(reward_token_address, checkpointNumber)
which is a struct with a set of four values: the current block.number
, current block.timestamp
, the amount of funds supplied in this checkpoint and the total amount of voluntary weighted stakes, which is retrieved from the Staking contract by the internal method FeeSharing._getVoluntaryWeightedStake
, this method call the Staking contract querying the values of Staking.getPriorTotalVotingPower(current_block, current_timestamp)
and subtracting from it the Staking.getPriorVestingWeightedStake(current_block, current_timestamp)
.
Lastly the event CheckpointAdded
is emitted.
These are the methods that allow stakers to claim their rewards.
getAccumulatedFees(user_address, reward_token_address)
: Is the public getter method that allow any user to know how much can be collected from the FeeSharing contract. So far the valid addresses for the reward_token_address are:
0xa9dcdc63eabb8a2b6f39d7ff9429d88340044a7a
; but soon it will only be the WRBTC: 0x542fda317318ebf1d3deaf76e0b632741a7e677d
;0xefc78fc7d48b64958315949279ba181c2114abbd
0x2e6b1d146064613e8f521eb3c6e65070af964ebb
0xdb107fa69e33f05180a4c2ce9c2e7cb481645c2d
In this document, the math formula of this method is of the special relevance. This method call internally _getAccumulatedFees
which will be the focus of our attention in this document.
withdraw(address \_loanPoolToken, uint32 \_maxCheckpoints, address \_receiver)
:
This method will evaluate how much fees the msg.sender
own accumulated in the FeeSharing contract. It is an intricate function which execute many calls in order to calculate and deliver the correspondent reward.
Below it is a brief description on how this method works:
1°. Let say that the parameters of the function are: withdraw(, , ), where
_loanPool Token address,
_maxCheckpoints which is a number MAX_CHECKPOINTS
_receiver address.
2°. require()
3°. Let IProtocol.wrbtcToken()
4°. require()
5°. Let IProtocol.underlyingToLoanPool
()
6°. require()
7°. Let msg.sender
8°. if( == )
9°. Let to be tha amount to be withdrawn.
10°. And let the counter to be the next checkpoint (unit256
) value of the mapping:
processedCheckpoints[user_address][token_address]
;
which is indeed the number of checkpoints that a specific user () has processed for an specific token ().
11°. Then: (, ) _getAccumulatedFees
(, , ) ;
this function, "" will be described better below.
12°. require()
13°. Store in the number
Finally
14°. if ( == )
ILoanToken
().burnToBTC
(, , false
)
else IERC20
().transfer
(,)
In this last step can be seen that the contract sends the ERC20 tokens to the msg.sender
regardless the specification of _receiver
address. This bug has been fixed in the repository. However, by the moment of this writing, the fix awaits for the deployment of a new FeeSharingLogic contract.
These are methods that can only be executed by the contract's owner which is so far, the exchequer.
removeWhitelistedConverterAddress(converterAddress)
: As its name indicates, it delists converters from the contract's whitelist. It emits the event UnwhitelistedConverter
.addWhitelistedConverterAddress(converterAddress)
: Which whitelists a new converter to the contract. It emits the event WhitelistedConverter
.withdrawWRBTC(address receiver, uint256 wrbtcAmount)
: As far as the main reward token has been the iWRBTC token, when some WRBTC remain in the FeeSharing by mistake, the owner can withdraw them from the contract by the means of this method. This method leave no trace except from the event of Transfer
from the WRBTC token contract. However if we are about to migrate to a new paradigm in which the main reward token is going to be the WRBTC token, this function should be disabled in this new logic.In this section we describe the origin of fees that are distributed by the FeeSharing contract.
The protocol splits three kind of fees to be delivered to the FeeSharing contract to be distributed: fees from trading activity, fees from borrowing activity and from lending. In this section each kind of fees and its origins are explained.
In these descriptions it will be important for the devs to have the Sovryn Protocol ABI handy. It can be generated from our repository following the indications of our API documentation, or directly from our front-end repository's documentation here.
The lending fees are the portion destined to stakeholders, deviated from the fees collected by the protocol from any borrowing operation, whether it was created with the intention to margin trade or to take a simple borrow, taked from the funds lended by the LoanToken contract. This cut represent the 10% of the fees taken from any borrowing or trade activity, and the other 90% is destined to reward the liquidity providers (lenders). This percentage is stored in the parameter SovrynProtocol.lendingFeePercent
, which is actually 10^19, but is divided by a constant of 10^20. About how the total fee is calculated, it is based on the market conditions, and the exact formula is beyond the scope of this document.
These fees are registered in the Sovryn Protocol mapping called lendingFeeTokensHeld
. This storage is updated each time the method _payLendingFee
is called which belongs to the code of a contract called FeesHelper
which is inherited directly by both, the contract InterestUser
and SwapsUser
, which, in turn, are inherited by the following contracts:
_settleInterest
is called, which in turn calls _payInterest
and then _payInterestTransfer
which calls _payLendingFee
.rollover
occurs, which in turn calls the internal method _rollover
and then payLendingFee
.closeWithSwap
), the method _settleInterest
will be called. See above._payLendingFee
is involved._initializeIniterest
is executed, involving _payLendingFee
.If an opening position is a simple borrow, or what is called "torque-loan" then after splitting the interest fees between liquidity providers (lenders) and SOV stakeholders (by executing _initializeInterest
and then _payInterest
then _payInterestTransfer
and finally _payLendingFee
) the FeesHelper._payBorrowingFee
method is executed , based in the outcome of FeesHelper._getBorrowingFee
, which is collected from the collateral supplied by the borrower. This fee is determined by the parameter SovrynProtocol.borrowingFeePercent
which is the 0.09% (or the 0.0009) of the total amount of collateral supplied by the borrower. Then _payBorrowingFee
add all this funds (the 0.09% of the collateral supplied by the borrower) to the storage of borrowingFeeTokensHeld
mapping.
Just to clarify: the lending fee is taking from the money which is lent by the protocol to the user (borrower), and the borrowing fee is taking from the money the user supply as collateral.
If an opening position is a margin trade (is not a torque-loan), then after splitting the interest fees between liquidity providers (lenders) and SOV stakeholders (by executing _initializeInterest
and then _payInterest
then _payInterestTransfer
and finally _payLendingFee
) the SwapsUser._loanSwap
method is executed, setting as false
the flag for SwapsExternal
. This method _loanSwap
takes the money borrowed to the user who aims to open a margin position, and convert it into collateral tokens, and after that the protocol will take a "trading fee" from the total amount of collateral obtained from this swap.
Then the sequence is: SwapsUser._loanSwap
which calls SwapsUser._swapsCall
which then takes the total amount of money borrowed (in terms of lending tokens) and swaps it into collateral tokens and then takes a percentage: the SovrynProtocol.tradingFeePercent
which is the 0.15% of the borrowed amount converted into collateral, and reserves it to the staking holders by registering this amount in the storage of tradingFeeTokensHeld
mapping.
In this case each AMM v1 converter (v2 are excluded) retain only one kind of fees for FeeSharing, and those only come from swaps:
When a user execute a swap with an AMM converter v1, by exchanging an amount of an asset 1: into an asset 2: , for an output amount of by calling AMMv1.doConvert
, from a fee of 0.3% is subtracted. Both, the output amount and the fee are calculated by the function AMMv1.targetAmountAndFee
.
To keep records of how much of the total reserve of an asset is available for the liquidity providers and how much is available for the FeeSharing contract (which address is retrieved by querying AMMv1.getFeesControllerFromSwapSettings
) to be withdrawn, the storage mappings AMMv1.reserves
[] and AMMv1.protocolFeeTokensHeld
[] are updated. The reserves
storage keeps record of the funds available for liquidity providers, and the protocolFeeTokensHeld
storage keeps record of the funds available for the FeeSharing contract to be withdrawn.
The 0.3% of fees collected by the converter are split into 0.25% for liquidity providers and a 0.05% for the FeeSharing contract, as can be verified by querying the parameter AMMv1.conversionFee
(and divide by the constant AMMv1.CONVERSION_FEE_RESOLUTION
which is 10^6, to obtain the fee of 0.3%) in any of our converters and querying the function protocolFee
from the SwapSettings
contract, since that is the contract called by the internal method AMMv1.getProtocolFeeFromSwapSettings
in order to get the cut value for the FeeSharing in the converter contract code.
When? | Source of Money? | Name of The Fee | Fees % |
---|---|---|---|
Any Trade Operation | From Funds Lent to the User | Lending Fee | 10% of any owed interests by that moment |
Opening a Borrowing | From Funds Supplied as Collateral | Borrowing Fee | 0.09% of Collateral Supplied |
Margin Trade Operation | From Funds Converted into Collateral Tokens | Trading Fee | 0.15% of Swapped Funds |
Any AMM V1 Swap | The Output of The Swap Transaction | AMM Fee | 1/6 of the 0.3% of Output of the Swap |
The FeeSharing contract is deeply dependent on the Staking contract. Therefore, the Staking's storage is decisive for the FeeSharing procedures, especially in the process of withdraw and getting accumulated rewards.
Staking inherits WeightedStaking, which inherits Checkpoints. These last two contracts include mainly useful functions to help in the Staking's calculus, operations and logs, but they do not declare any storage. Checkpoints inherits StakingStorage and SafeMath96 - which is only a library -. In StakingStorage is declared all the contract's storage. However, let's remember that the real storage is held by the Proxy contract.
The most relevant elements of the StakingStorage to be analyzed here will be the mappings, which are going to be the focus of our interest for the FeeSharing contract. There are 14 mappings in the Staking contract. And from those 14 mapping only 6 are relevant to FeeSharing contract:
Let's say that:
the user's address,
the date in UNIX epoch format, by when the SOV staked by a user will be unlocked,
the total number of checkpoints that a given user has for a given staking position.
the total number of checkpoints that the whole Staking contract has for a given staking unlock date.
the total number of checkpoints that the whole Staking contract has for a given staking unlock date, originated exclusively from vesting contracts (no EOAs).
The two first storage mappings let the FeeSharing algorithm to know how much are the user's shares in the Staking contract, in order to know the cut of the cake they can claim. The numUserStakingCheckpoints
mapping let it know the updated counter number for the checkpoint parameter, and userStakingCheckpoints
allows to query the actually useful data, based on that counter.
Then numTotalStakingCheckpoints
and numVestingCheckpoints
mappings has similar functions of that of numUserStakingCheckpoints
: querying the total amount of checkpoints for both, the total staking positions of the Staking contract and the total staking positions originated on vestings.
Then, with those numbers, we can query the total stakes in given dates for the whole Staking contract through totalStakingCheckpoints
and the total stakes coming from vestings at such dates through vestingCheckpoints
. The difference between these two figures gives to FeeSharing the total amount of voluntary stakings for certain unlock (or lock) - dates. And this number is used to weight the user's cut from the cake.
But to complete the calculations, FeeSharing contract needs it own storage mappings. FeeSharingProxyStorage is the contract that declares all the FeeSharing's storage. Again, let's be aware that all the real storage is set in the proxy contract, not in the logics.
So, let's say that:
the ERC20 token address of a given asset "" held by the FeeSharing contract, ready to be distributed,
the total number of checkpoints for each asset "" held by FeeSharing contract,
the number of payments a user has received from tha FeeSharing contract for a specific asset.
So, the FeeSharing storage has five mappings from which the following three are the most relevant:
FeeSharing.numTokenCheckpoints [] = - a counter -. It tells how many checkpoints a token as asset held by the FeeSharing contract, has in the FeeSharing contract. It is important to be clear about that this "checkpoints" registered in the FeeSharing contract has a different structure than those held by the Staking contract.
FeeSharing.tokenCheckpoints [] [] = is the feeSharing's Checkpoint Struct. This struct is made of a different set of parameters which we can define the following way: , where:
FeeSharing.processedCheckpoints [] [] = - a counter -. it tells how many valid withdrawals a specific user has verified for a specific token asset "tk". Several "withdrawals" can be executed in a single transaction, so this is a counter to manage the valid payments a user can still claim from FeeSharing.
The numTokenCheckpoints
allows FeeSharing to know the updated counter number for the asset's checkpoint parameter, and tokenCheckpoints
allows to query the actually useful data for that asset, based on that counter. Then, the updated checkpoint of a given asset "" will give the FeeSharing contract the needed information about how much voluntary staking was in the Staking contract by the moment such checkpoint was created, which moment in time such register happened, and the amount of funds of that asset that were held by FeeSharing when that checkpoint were registered. With all that info, and the data FeeSharing can retrieve from Staking, the claimable share for a given user in a given moment can be estimated.
Lastly processedCheckpoints
let the contract to know how many of these asset's checkpoints were "spent" by a given user, in order to prevent paying the same dividend twice.
A last word can be said about the other two mappings on FeeSharing:
FEE_WITHDRAWAL_INTERVAL
which lasts 1 day. If anyone tries to deposit funds of certain asset to FeeSharing more than once in less than one day, no new checkpoint will be registered; however, the mapping unprocessedAmount
will allow the contract keep the track of how much tokens will be recognized by when the next valid checkpoint will be created. This storage is a useful feature, however the usefulness of the last one is arguable.FeeSharing._addCheckpoint
we see that if the deposit occurs within a FEE_WITHDRAWAL_INTERVAL
, no updates occur neither in lastFeeWithdrawalTime
nor in tokenCheckpoints
. So, this mapping is an unnecessary spend on storage for the contract, and we can replace it seamlessly with the value of FeeSharing.tokenCheckpoints.timestamp
.This section is dedicated to describe in detail the internal method _getAccumulatedFees
(, ,) as mentioned above.
To better understand many of the math statements in this description, we are going to use a couple of useful tools. First of all, let's define an "mandatory inequality": an expression that assign one or another value depending on the validity of an inequality condition. Let's say we have a parameter A, a value B, and a variable which we want to define as A as long as A is less or equal than B, but if such condition is broken, then the value of will be equal to B; then we can expressed it the following way:
The other tool will be a lemma used to define a floor fraction in terms of the modulus operator:
Where we can read as , and both and are positive integers.
Lastly, let's handle large function's names with shortened terms, so let's define _getAccumulatedFees
as .
This way (, ,) (), an array of two numbers, where is the amount of tokens a user can withdraw, and is the counter corresponding to the total number of valid claims the user have been done to certain asset after executing the withdrawal.
1°. The algorithm checks if is or not a vesting contract. In such case, it throws immediately () = ().
2°. A start count value is defined: [] [] (according the definition of "" as the processedCheckpoints
mapping)
3°. An ending count value is defined:
4°. If ( ) then require( [] ) ; according the definition of the mapping numTokenCheckpoints
() above.
5°. Then we assign to the ending count value the output of the internal method _getEndOfrange
( ), which can be summarized according to the following expression:
block.number
In the former expression:
and,
where MAX_CHECKPOINTS
(= 100)
And what the expression means is that if for the chosen , the value block.number
then we must try with a lower value.
6°. If ( ), i.e.: the 4th step condition is false, then we check:
The condition is specified to make queries to the contract instead of get the value to be claimed in a withdraw transaction.
7°. In order to estimate the amount of tokens the user can claim for the asset he method now starts a for loop in which the index goes from to which end up being a summation of terms. The first expression of this summation can assume the form of:
In the former formula the terms:
parameter
are the FeeSharing checkpoint parameters for the asset in the counter number , as described in the FeeSharing storage section, explained in the point 8.
Also, we see a new function: representing the public method Staking.getPriorWeightedStake
. We can simplify all this gibberish notation by replacing:
parameter
parameter
So, we will allow us to say that:
and so on ...
8°. Mechanics of : The first step of this method is to take the value and convert it into the nearest and previous "unlock date", through the method: Staking.timestampToLockDate
[] , which according the code, it is:
Where:
Staking.TWO_WEEKS
= 1209600 (seconds, i.e. a time window of 2 weeks), and
Staking.kickoffTS
= 1613125695 which is a date, not a time window.
gives the time at which the Staking-Proxy contract was deployed. It is the timestamp of the block 3100263
.
Due to (2), the expression (6) can be simplified as:
9°. From this point the function turns intricate, but after some simplifications, we end up verifying that the next step is to perform a summation through a for loop where its index will go from 0 to 78. So if we call the output of as , then:
In which "" is another public method: Staking.weightedStakeByDate
. Also we can simplify the term by "". But before deepening into the weightedStakeByDate
method, we can clarify beforehand that mathematically, the parameter is only used in one of the methods internally called (Staking._getPrioirUserstakeByDate
) to check if block.number
, and if the condition fails (and the expected behavior is to succeed), the whole process revert. So, we can assume that (mathematically) the function , and therefore, are independent from .
10°. The value of will meet the equality:
=
Where:
the internal methodStaking._getUserstakeByDate
the public method Staking.computeWeightByDate
11°. The value of is pretty simple:
So, is independent of the index , and it is only a function of the index . In the equation :
Staking.WEIGHT_FACTOR
10
Staking.MAX_VOTING_WEIGHT
9
Staking.MAX_DURATION_POW_2
12°. The procedures of the function are pretty intricate, but essentially what it does is to evaluate how much stake a user has in a given staking position to be unlocked in the date for the most recent valid Staking checkpoint. So, for the purposes of this analysis we can replace with . It is important to understand that if for the date , the user has no staking positions, then the value of for that user, in that date will be zero. So values are a collection of mostly zeros and few values of funds staked at the date.
13°. So, finally the output of will be the array of parameters , where was defined in the 5° step, and A is defined by the following equation: