Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
- Add optimistic governance
- Add optional token trading allowlist controls (`DEFAULT_ADMIN_ROLE`). Enforcement is disabled by default; when
enabled, every token included in a new rebalance, including zero-weight tokens, must be allowlisted.
- Add Folio self-fee (`DEFAULT_ADMIN_ROLE`). On mint, the receiver participates in the resulting exchange-rate
appreciation and recovers a size-dependent portion of the self-fee; see `folioFeeForSelf` in the README.
- Add Folio self-fee (`DEFAULT_ADMIN_ROLE`). Mint self-fees remain in effective supply, keeping mints exchange-rate
neutral, then are handed out at a bounded rate after each daily boundary; see `folioFeeForSelf` in the README.
- Add Folio immutable fee recipients (`DEFAULT_ADMIN_ROLE`)
- Add per-auction custom auction lengths (`AUCTION_LAUNCHER`, within admin-configured max length)
- Add explicit rebalance nonce validation
Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,11 +188,13 @@ Max: 5%

**Fraction of non-DAO fee value directed to Folio holders**

`folioFeeForSelf` applies to the fee-recipient portion of both TVL fees and mint fees. Instead of minting the configured fraction of fee-recipient shares, the Folio omits those shares from its supply. The underlying assets remain in the Folio, increasing the assets represented by each outstanding share.
`folioFeeForSelf` applies to the fee-recipient portion of both TVL fees and mint fees. The configured fraction is ultimately omitted from the supply while the underlying assets remain in the Folio, increasing the assets represented by each outstanding share. TVL self-fees are omitted on an ongoing basis via a reduced TVL fee rate, while mint self-fees are omitted on a daily basis via a brief handout window.

For mint fees, the receiver still receives `shares - totalFeeShares`, including a deduction for the omitted self-fee shares. However, those newly minted receiver shares immediately participate in the resulting exchange-rate increase. The receiver therefore recovers a portion of the self-fee value equal to its newly minted fraction of the post-mint supply, subject to rounding. The effect is small for mints that are small relative to the existing supply and increases with the relative size of the mint. The DAO fee portion, including the minimum DAO fee floor, is minted separately and is not recovered through this effect.
For mint fees, the receiver still receives `shares - totalFeeShares`, including a deduction for the self-fee shares. The self-fee shares initially remain in the effective supply, keeping the mint exchange rate neutral apart from asset-transfer rounding. Starting at each 24-hour boundary, pending mint self-fee shares are virtually burned (handed out to holders) linearly for `FOLIO_FEE_HANDOUT_PERIOD` at a maximum rate of 0.05 basis points (0.0005%) per second. At this rate, capturing the 3 basis point `MIN_MINT_FEE` requires holding for 60 seconds. New self-fees accrued during an open handout window can join its remaining time, and excess pending shares above the maximum daily handout roll over to later days. The handout rate includes TVL fee shares already stored when it is calculated, but not TVL fees accrued in the same poke; this timing difference is normally small but can grow across a long unpoked interval.

Breaking a large mint into smaller sequential mints results in a smaller overall rebate, apart from rounding and fee-floor edge effects, because later tranches do not participate in the appreciation caused by earlier mints. Separately, an account that becomes a holder immediately before another account's mint and redeems afterward can capture a portion of that mint's self-fee.
Under a ten-minute handout period at 0.05 basis points per second, a single handout interval distributes at most 30 basis points (0.30%) per day. This is a linear nominal-share rate rather than a strict compounded exchange-rate cap. If new pending fees are accrued and handed out in smaller intervals throughout the window, each interval's appreciation applies after the previous one; at the one-second extreme, 600 successive 0.05 basis point handouts compound to approximately 30.045 basis points. Repeated pokes without intervening fee accrual or supply changes do not increase the handout. Enforcing an exact 30 basis point cap would require compounding-aware accounting, and the small difference is accepted as a limitation.

The nominal 30 basis point rate is the maximum sustained self-fee handout the Folio can support: average accrual above this capacity creates an indefinitely growing backlog, effectively postponing the excess appreciation forever. This is a known limitation that is acceptable under realistic conditions, equivalent to an approximately 3x exchange-rate multiplier per year solely from mint self-fees. If the daily maximum rate is temporarily exceeded, the handout rate throttles, but does not prevent, the incentive for potential short-term entrants.

#### Fee Floor

Expand Down
142 changes: 105 additions & 37 deletions contracts/Folio.sol
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { ITrustedFillerRegistry, IBaseTrustedFiller } from "@reserve-protocol/tr

import { RebalancingLib } from "@utils/RebalancingLib.sol";
import { FolioLib } from "@utils/FolioLib.sol";
import { AUCTION_WARMUP, AUCTION_LAUNCHER, D18, ERC20_STORAGE_LOCATION, REBALANCE_MANAGER, MAX_MINT_FEE, MAX_FOLIO_FEE, MIN_AUCTION_LENGTH, MAX_AUCTION_LENGTH, RESTRICTED_AUCTION_BUFFER, ONE_DAY } from "@utils/Constants.sol";
import { AUCTION_WARMUP, AUCTION_LAUNCHER, D18, ERC20_STORAGE_LOCATION, FOLIO_FEE_HANDOUT_PERIOD, FOLIO_FEE_HANDOUT_RATE, REBALANCE_MANAGER, MAX_MINT_FEE, MAX_FOLIO_FEE, MIN_AUCTION_LENGTH, MAX_AUCTION_LENGTH, RESTRICTED_AUCTION_BUFFER, ONE_DAY } from "@utils/Constants.sol";
import { Versioned } from "@utils/Versioned.sol";

import { IFolioDAOFeeRegistry } from "@interfaces/IFolioDAOFeeRegistry.sol";
Expand Down Expand Up @@ -83,6 +83,7 @@ import { IFolio } from "@interfaces/IFolio.sol";
* Fees:
* - TVL fee: fee per unit time. Max 10% annually. Causes supply inflation over time, discretely once a day.
* - Mint fee: fee on mint. Max 5%. Does not cause supply inflation.
* - Mint self-fees: remain in effective supply, then are burned at a bounded rate during a brief daily window.
*
* After fees have been applied, the DAO takes a cut based on the configuration of the FolioDAOFeeRegistry including
* a minimum fee floor. The remaining portion above the floor is distributed to the Folio's fee recipients.
Expand Down Expand Up @@ -190,10 +191,13 @@ contract Folio is
// === 6.0.0 ===
bool public tradeAllowlistEnabled;
EnumerableSet.AddressSet private tradeTokenAllowlist;
uint256 public folioFeeForSelf; // D18{1} fraction of fee-recipient shares to burn

FeeRecipient[] public immutableFeeRecipients;

uint256 public folioFeeForSelf; // D18{1} fraction of fee-recipient shares directed to Folio holders
uint256 public folioPendingMintFeeShares; // {share} mint self-fee shares pending handout (burning)
uint256 public lastFolioFeePoke; // {s} last time mint self-fee handout capacity was accounted

/// Any external call to the Folio that relies on accurate share accounting must pre-hook poke
modifier sync() {
_poke();
Expand Down Expand Up @@ -252,6 +256,7 @@ contract Folio is
}

lastPoke = block.timestamp;
lastFolioFeePoke = block.timestamp;

_mint(_creator, _basicDetails.initialShares);
_grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
Expand Down Expand Up @@ -316,9 +321,9 @@ contract Folio is
_setMintFee(_newFee);
}

/// Set the folio fee — fraction of fee-recipient shares that are burned (not minted)
/// Set the folio fee — fraction of fee-recipient shares directed to Folio holders
/// @dev Non-reentrant via distributeFees()
/// @param _newFee D18{1} Fraction of fee-recipient shares to burn
/// @param _newFee D18{1} Fraction of fee-recipient shares directed to Folio holders
function setFolioSelfFee(uint256 _newFee) external onlyRole(DEFAULT_ADMIN_ROLE) {
distributeFees();

Expand Down Expand Up @@ -411,9 +416,20 @@ contract Folio is

/// @dev Contains all pending fee shares
function totalSupply() public view override returns (uint256) {
(uint256 _daoPendingFeeShares, uint256 _feeRecipientsPendingFeeShares, , ) = _getPendingFeeShares();
(
uint256 _daoPendingFeeShares,
uint256 _feeRecipientsPendingFeeShares,
,
uint256 _mintSelfFeeHandout,

return super.totalSupply() + _daoPendingFeeShares + _feeRecipientsPendingFeeShares;
) = _getFeeShares();

return
super.totalSupply() +
_daoPendingFeeShares +
_feeRecipientsPendingFeeShares +
folioPendingMintFeeShares -
_mintSelfFeeHandout;
}

/// @dev Result may be unreliable mid-swap during trusted fill execution, check stateChangeActive()
Expand All @@ -435,7 +451,7 @@ contract Folio is
}

/// @dev Use allowances to set slippage limits for provided assets
/// @dev Minting has 3 share-portions: (i) receiver shares, (ii) DAO fee shares, (iii) fee recipients shares
/// @dev Minting has 4 share-portions: receiver, DAO, fee recipients, and pending Folio self-fee shares
/// @param shares {share} Amount of shares to mint
/// @param minSharesOut {share} Minimum amount of shares the caller must receive after fees
/// @return _assets
Expand All @@ -447,7 +463,6 @@ contract Folio is
) external nonReentrant notDeprecated sync returns (address[] memory _assets, uint256[] memory _amounts) {
// === Calculate fee shares ===

// @dev Semantically view; non-view only because computeMintFees() emits FolioFeePaid
(uint256 sharesOut, uint256 daoFeeShares, uint256 feeRecipientFeeShares) = FolioLib.computeMintFees(
FolioLib.MintFeeParams({
shares: shares,
Expand All @@ -473,9 +488,10 @@ contract Folio is

_mint(receiver, sharesOut);

// defer fee handouts until distributeFees()
// defer DAO and recipient fee handouts until distributeFees()
daoPendingFeeShares += daoFeeShares;
feeRecipientsPendingFeeShares += feeRecipientFeeShares;
folioPendingMintFeeShares += shares - sharesOut - daoFeeShares - feeRecipientFeeShares;
}

/// @param shares {share} Amount of shares to redeem
Expand Down Expand Up @@ -519,7 +535,7 @@ contract Folio is

/// @return {share} Up-to-date sum of DAO and fee recipients pending fee shares
function getPendingFeeShares() public view returns (uint256) {
(uint256 _daoPendingFeeShares, uint256 _feeRecipientsPendingFeeShares, , ) = _getPendingFeeShares();
(uint256 _daoPendingFeeShares, uint256 _feeRecipientsPendingFeeShares, , , ) = _getFeeShares();
return _daoPendingFeeShares + _feeRecipientsPendingFeeShares;
}

Expand Down Expand Up @@ -641,7 +657,7 @@ contract Folio is

/// Start a new rebalance, ending the currently running auction
/// @dev If caller omits old tokens they will be kept in the basket for mint/redeem but skipped in the rebalance
/// @dev Note that weights will be _slightly_ stale after the fee supply inflation on a 24h boundary
/// @dev Weights become stale from TVL fee inflation on each 24h boundary and during the mint self-fee handout window that follows
/// @param rebalanceNonce The expected nonce after this rebalance starts
/// @param tokens The rebalance parameters for each token in the rebalance
/// @param tokens.token MUST be unique; MUST be allowlisted when the trade allowlist is enabled
Expand Down Expand Up @@ -1041,38 +1057,82 @@ contract Folio is
(sellAmount, bidAmount, price) = RebalancingLib.getBid(rebalance, auction, sellToken, buyToken, params);
}

/// Get all pending fee shares and the mint self-fee handout
/// @return _daoPendingFeeShares {share}
/// @return _feeRecipientsPendingFeeShares {share}
/// @return _folioSelfFeeShares {share}
/// @return _tvlSelfFeeShares {share}
/// @return _mintSelfFeeHandout {share}
/// @return _accountedUntil {s}
function _getPendingFeeShares()
function _getFeeShares()
internal
view
returns (
uint256 _daoPendingFeeShares,
uint256 _feeRecipientsPendingFeeShares,
uint256 _folioSelfFeeShares,
uint256 _tvlSelfFeeShares,
uint256 _mintSelfFeeHandout,
uint256 _accountedUntil
)
{
// {s} Always in full days
_accountedUntil = (block.timestamp / ONE_DAY) * ONE_DAY;
if (_accountedUntil <= lastPoke) {
return (daoPendingFeeShares, feeRecipientsPendingFeeShares, 0, lastPoke);
// {share}
uint256 feeSupply = super.totalSupply() + daoPendingFeeShares + feeRecipientsPendingFeeShares;
_daoPendingFeeShares = daoPendingFeeShares;
_feeRecipientsPendingFeeShares = feeRecipientsPendingFeeShares;

// === TVL fees ===

{
// {s} Always in full days
_accountedUntil = (block.timestamp / ONE_DAY) * ONE_DAY;

if (_accountedUntil > lastPoke) {
uint256 tvlFeeElapsed = _accountedUntil - lastPoke; // {s}
(_daoPendingFeeShares, _feeRecipientsPendingFeeShares, _tvlSelfFeeShares) = FolioLib.computeFeeShares(
FolioLib.FeeSharesParams({
currentDaoPending: daoPendingFeeShares,
currentFeeRecipientsPending: feeRecipientsPendingFeeShares,
tvlFee: tvlFee,
folioFeeForSelf: folioFeeForSelf,
supply: feeSupply,
elapsed: tvlFeeElapsed
}),
daoFeeRegistry
);
} else {
_accountedUntil = lastPoke;
}
}

uint256 elapsed = _accountedUntil - lastPoke;
(_daoPendingFeeShares, _feeRecipientsPendingFeeShares, _folioSelfFeeShares) = FolioLib.computeFeeShares(
FolioLib.FeeSharesParams({
currentDaoPending: daoPendingFeeShares,
currentFeeRecipientsPending: feeRecipientsPendingFeeShares,
tvlFee: tvlFee,
folioFeeForSelf: folioFeeForSelf,
supply: super.totalSupply() + daoPendingFeeShares + feeRecipientsPendingFeeShares,
elapsed: elapsed
}),
daoFeeRegistry
);
// === Mint self-fee handout ===

// {s}
uint256 _lastFolioFeePoke = lastFolioFeePoke;

if (folioPendingMintFeeShares != 0 && block.timestamp > _lastFolioFeePoke) {
// {1}
uint256 wholeDaysElapsed = (block.timestamp / ONE_DAY) - (_lastFolioFeePoke / ONE_DAY);

// {s}
uint256 lastElapsed = Math.min(_lastFolioFeePoke % ONE_DAY, FOLIO_FEE_HANDOUT_PERIOD);

// handout if whole days have elapsed OR the last window was not fully handed out
if (wholeDaysElapsed != 0 || lastElapsed < FOLIO_FEE_HANDOUT_PERIOD) {
// {s}
uint256 wholeElapsed = wholeDaysElapsed * FOLIO_FEE_HANDOUT_PERIOD;

// {s}
uint256 currentElapsed = Math.min(block.timestamp % ONE_DAY, FOLIO_FEE_HANDOUT_PERIOD);

// {s}
uint256 elapsed = wholeElapsed + currentElapsed - lastElapsed;

// {share} = {share} * D18{1/s} * {s} / D18
uint256 maxHandout = Math.mulDiv(feeSupply, FOLIO_FEE_HANDOUT_RATE * elapsed, D18);

// {share}
_mintSelfFeeHandout = Math.min(folioPendingMintFeeShares, maxHandout);
}
}
}

/// Set TVL fee by annual percentage. Different from how it is stored!
Expand All @@ -1090,7 +1150,7 @@ contract Folio is
emit MintFeeSet(_newFee);
}

/// Set folio fee — fraction of fee-recipient shares to burn
/// Set folio fee — fraction of fee-recipient shares directed to Folio holders
/// @param _newFee D18{1}
function _setFolioSelfFee(uint256 _newFee) internal {
require(_newFee <= MAX_FOLIO_FEE, Folio__FolioFeeTooHigh());
Expand Down Expand Up @@ -1123,25 +1183,33 @@ contract Folio is
emit NameSet(_newName);
}

/// @dev After: daoPendingFeeShares and feeRecipientsPendingFeeShares are up-to-date
/// @dev After: all pending fee share accounting is up-to-date
function _poke() internal {
_closeTrustedFill(false);

(
uint256 _daoPendingFeeShares,
uint256 _feeRecipientsPendingFeeShares,
uint256 _folioSelfFeeShares,
uint256 _tvlSelfFeeShares,
uint256 _mintSelfFeeHandout,
uint256 _accountedUntil
) = _getPendingFeeShares();
) = _getFeeShares();

if (_accountedUntil > lastPoke) {
daoPendingFeeShares = _daoPendingFeeShares;
feeRecipientsPendingFeeShares = _feeRecipientsPendingFeeShares;
lastPoke = _accountedUntil;
}

if (_folioSelfFeeShares != 0) {
emit FolioFeePaid(address(this), _folioSelfFeeShares);
}
// burn pending mint shares around the 24h handout boundary
if (_mintSelfFeeHandout != 0) {
folioPendingMintFeeShares -= _mintSelfFeeHandout;
}
lastFolioFeePoke = block.timestamp;

if (_tvlSelfFeeShares + _mintSelfFeeHandout != 0) {
// fees paid to self = sum of constant TVL and 24h boundary mint fees
emit FolioFeePaid(address(this), _tvlSelfFeeShares + _mintSelfFeeHandout);
}
}

Expand Down
2 changes: 1 addition & 1 deletion contracts/interfaces/IFolio.sol
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ interface IFolio {
FeeRecipient[] immutableFeeRecipients;
uint256 tvlFee; // D18{1/year} annual fee input; stored on Folio as D18{1/s}
uint256 mintFee; // D18{1}
uint256 folioFeeForSelf; // D18{1} fraction of fee-recipient shares to burn
uint256 folioFeeForSelf; // D18{1} fraction of fee-recipient shares directed to Folio holders
string mandate;
}

Expand Down
2 changes: 2 additions & 0 deletions contracts/utils/Constants.sol
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ uint256 constant MAX_TVL_FEE = 0.1e18; // D18{1/year} 10% annually
uint256 constant MAX_MINT_FEE = 0.05e18; // D18{1} 5%
uint256 constant MAX_FOLIO_FEE = 1e18; // D18{1} 100%
uint256 constant MIN_MINT_FEE = 0.0003e18; // D18{1} 0.03%
uint256 constant FOLIO_FEE_HANDOUT_RATE = 0.000005e18; // D18{1/s} 0.05 bps per second
uint256 constant FOLIO_FEE_HANDOUT_PERIOD = 10 minutes; // {s}
uint256 constant MIN_AUCTION_LENGTH = 120; // {s} 2 min
uint256 constant MAX_AUCTION_LENGTH = 604800; // {s} 1 week
uint256 constant MAX_FEE_RECIPIENTS = 64;
Expand Down
Loading
Loading