Skip to content
Open
Show file tree
Hide file tree
Changes from 7 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 during daily TVL fee accounting.

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 self-fee shares are virtually burned (handed out to holders) linearly for `FOLIO_FEE_HANDOUT_PERIOD` at a maximum rate of half the minimum mint fee per nominal 12-second block. New self-fees accrued during an open handout window can join its remaining time, and excess pending shares roll over to later days. The handout rate uses the current stored nominal supply, excluding pending mint self-fees. Mints and redemptions change the rate immediately; TVL fees change it when they are accrued, so poke timing can change the amount handed out. This difference is normally negligible over one day but can grow during a long-lived rollover.

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.
With the initial four-minute period, at most 30 basis points (0.30%) of appreciation can be handed out per day. This is the maximum sustained self-fee appreciation the Folio supports: average accrual above this capacity creates an indefinitely growing backlog, effectively postponing the excess appreciation forever.

Pending mint self-fee shares are deliberately exempt from TVL fees, like value already directed to Folio holders. A backlog therefore reduces the effective TVL fee on total AUM in proportion to the pending share of effective supply; with an extremely large backlog, the effective TVL fee can approach zero. The handout window is public and predictable, so holders who enter before or during it and remain through later blocks can participate in the appreciation. The bounded stream limits the rate of that opportunity but does not prevent multi-block participation.

#### Fee Floor

Expand Down
83 changes: 70 additions & 13 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_BLOCK_TIME, 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 virtually burned at a bounded rate during a 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,7 +191,9 @@ 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
uint256 public folioFeeForSelf; // D18{1} fraction of fee-recipient shares directed to Folio holders
uint256 public folioPendingFeeShares; // {share} mint self-fee shares pending handout
uint256 public lastFolioFeeHandout; // {s} last time mint self-fee handout capacity was accounted

FeeRecipient[] public immutableFeeRecipients;

Expand Down Expand Up @@ -252,6 +255,7 @@ contract Folio is
}

lastPoke = block.timestamp;
lastFolioFeeHandout = block.timestamp;

_mint(_creator, _basicDetails.initialShares);
_grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
Expand Down Expand Up @@ -316,9 +320,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 @@ -413,7 +417,12 @@ contract Folio is
function totalSupply() public view override returns (uint256) {
(uint256 _daoPendingFeeShares, uint256 _feeRecipientsPendingFeeShares, , ) = _getPendingFeeShares();

return super.totalSupply() + _daoPendingFeeShares + _feeRecipientsPendingFeeShares;
return
super.totalSupply() +
_daoPendingFeeShares +
_feeRecipientsPendingFeeShares +
folioPendingFeeShares -
_getFolioFeeHandout();
}

/// @dev Result may be unreliable mid-swap during trusted fill execution, check stateChangeActive()
Expand All @@ -435,7 +444,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 +456,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 +481,10 @@ contract Folio is

_mint(receiver, sharesOut);

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

/// @param shares {share} Amount of shares to redeem
Expand Down Expand Up @@ -641,7 +650,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 @@ -1068,13 +1077,51 @@ contract Folio is
currentFeeRecipientsPending: feeRecipientsPendingFeeShares,
tvlFee: tvlFee,
folioFeeForSelf: folioFeeForSelf,
// pending mint self-fees are exempt from TVL fees while awaiting handout
supply: super.totalSupply() + daoPendingFeeShares + feeRecipientsPendingFeeShares,
elapsed: elapsed
}),
daoFeeRegistry
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

/// @return _folioFeeHandout {share} Mint self-fee shares available for handout
function _getFolioFeeHandout() internal view returns (uint256) {
uint256 timestamp = block.timestamp;
uint256 lastHandout = lastFolioFeeHandout;

if (folioPendingFeeShares == 0 || timestamp <= lastHandout) {
return 0;
}

uint256 currentDay = timestamp / ONE_DAY;
uint256 lastDay = lastHandout / ONE_DAY;
uint256 lastWindowElapsed = Math.min(lastHandout % ONE_DAY, FOLIO_FEE_HANDOUT_PERIOD);

// return early when today's handout window is already fully accounted
if (currentDay == lastDay && lastWindowElapsed == FOLIO_FEE_HANDOUT_PERIOD) {
return 0;
}

uint256 elapsed;
// timestamp ordering and bounded daily windows make this arithmetic safe
unchecked {
elapsed =
(currentDay - lastDay) *
FOLIO_FEE_HANDOUT_PERIOD +
Math.min(timestamp % ONE_DAY, FOLIO_FEE_HANDOUT_PERIOD) -
lastWindowElapsed;
}

// {share} = {share} * D18{1} * {s} / (D18 * {s})
uint256 maxHandout = Math.mulDiv(
super.totalSupply() + daoPendingFeeShares + feeRecipientsPendingFeeShares,
FOLIO_FEE_HANDOUT_RATE * elapsed,
D18 * FOLIO_FEE_HANDOUT_BLOCK_TIME
);
return Math.min(folioPendingFeeShares, maxHandout);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

/// Set TVL fee by annual percentage. Different from how it is stored!
/// @param _newFeeAnnually D18{1/year}
function _setTVLFee(uint256 _newFeeAnnually) internal {
Expand All @@ -1090,7 +1137,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,10 +1170,12 @@ 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 _folioFeeHandout = _getFolioFeeHandout();

(
uint256 _daoPendingFeeShares,
uint256 _feeRecipientsPendingFeeShares,
Expand All @@ -1138,11 +1187,19 @@ contract Folio is
daoPendingFeeShares = _daoPendingFeeShares;
feeRecipientsPendingFeeShares = _feeRecipientsPendingFeeShares;
lastPoke = _accountedUntil;
}

if (_folioSelfFeeShares != 0) {
emit FolioFeePaid(address(this), _folioSelfFeeShares);
if (_folioFeeHandout != 0) {
// handout is capped at pending shares
unchecked {
folioPendingFeeShares -= _folioFeeHandout;
}
}
lastFolioFeeHandout = block.timestamp;

if (_folioSelfFeeShares + _folioFeeHandout != 0) {
emit FolioFeePaid(address(this), _folioSelfFeeShares + _folioFeeHandout);
}
}

function _addToBasket(address token) internal {
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
3 changes: 3 additions & 0 deletions contracts/utils/Constants.sol
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ 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 = MIN_MINT_FEE / 2; // D18{1} 0.015% per nominal block
uint256 constant FOLIO_FEE_HANDOUT_BLOCK_TIME = 12 seconds; // {s}
uint256 constant FOLIO_FEE_HANDOUT_PERIOD = 4 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
11 changes: 3 additions & 8 deletions contracts/utils/FolioLib.sol
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ library FolioLib {
uint256 currentDaoPending; // {share}
uint256 currentFeeRecipientsPending; // {share}
uint256 tvlFee; // D18{1/s}
uint256 folioFeeForSelf; // D18{1} fraction of fee-recipient shares to burn
uint256 folioFeeForSelf; // D18{1} fraction of fee-recipient shares directed to Folio holders
uint256 supply; // {share}
uint256 elapsed; // {s}
}
Expand Down Expand Up @@ -215,7 +215,6 @@ library FolioLib {
}

/// Compute mint fee shares for DAO and fee recipients
/// @dev Semantically view; non-view only because it emits FolioFeePaid
/// @param params Mint fee parameters
/// @param daoFeeRegistry The DAO fee registry to query fee details from
/// @return sharesOut {share} Shares to mint for the receiver
Expand All @@ -224,7 +223,7 @@ library FolioLib {
function computeMintFees(
MintFeeParams calldata params,
IFolioDAOFeeRegistry daoFeeRegistry
) external returns (uint256 sharesOut, uint256 daoFeeShares, uint256 feeRecipientFeeShares) {
) external view returns (uint256 sharesOut, uint256 daoFeeShares, uint256 feeRecipientFeeShares) {
(, uint256 daoFeeNumerator, uint256 daoFeeDenominator, uint256 daoFeeFloor) = daoFeeRegistry.getFeeDetails(
address(this)
);
Expand All @@ -248,12 +247,8 @@ library FolioLib {
uint256 folioSelfShares = (feeRecipientFeeShares * params.folioFeeForSelf) / D18;
feeRecipientFeeShares -= folioSelfShares;

// {share} minter pays the full fee (including self-fee shares that are burned)
// {share} minter pays the full fee, including pending self-fee shares
sharesOut = params.shares - totalFeeShares;
require(sharesOut != 0 && sharesOut >= params.minSharesOut, IFolio.Folio__InsufficientSharesOut());

if (folioSelfShares != 0) {
emit IFolio.FolioFeePaid(address(this), folioSelfShares);
}
}
}
2 changes: 1 addition & 1 deletion foundry.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ memory_limit = 1073741824 # 1 GB
bytecode_hash = "none"
evm_version = "cancun"
optimizer = true
optimizer_runs = 549
optimizer_runs = 212
solc_version = "0.8.28"
via_ir = false

Expand Down
Loading
Loading