diff --git a/CHANGELOG.md b/CHANGELOG.md index 66648ba0..e3149537 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index e41d374f..af147fc6 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/contracts/Folio.sol b/contracts/Folio.sol index 6df1d010..c2a1775d 100644 --- a/contracts/Folio.sol +++ b/contracts/Folio.sol @@ -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"; @@ -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. @@ -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(); @@ -252,6 +256,7 @@ contract Folio is } lastPoke = block.timestamp; + lastFolioFeePoke = block.timestamp; _mint(_creator, _basicDetails.initialShares); _grantRole(DEFAULT_ADMIN_ROLE, msg.sender); @@ -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(); @@ -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() @@ -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 @@ -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, @@ -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 @@ -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; } @@ -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 @@ -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! @@ -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()); @@ -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); } } diff --git a/contracts/interfaces/IFolio.sol b/contracts/interfaces/IFolio.sol index 216d4d5e..14b0086d 100644 --- a/contracts/interfaces/IFolio.sol +++ b/contracts/interfaces/IFolio.sol @@ -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; } diff --git a/contracts/utils/Constants.sol b/contracts/utils/Constants.sol index ba1862f9..571bb70b 100644 --- a/contracts/utils/Constants.sol +++ b/contracts/utils/Constants.sol @@ -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; diff --git a/contracts/utils/FolioLib.sol b/contracts/utils/FolioLib.sol index e5647a25..b6cb474a 100644 --- a/contracts/utils/FolioLib.sol +++ b/contracts/utils/FolioLib.sol @@ -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} } @@ -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 @@ -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) ); @@ -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); - } } } diff --git a/foundry.toml b/foundry.toml index 3a07dd5b..23139c60 100644 --- a/foundry.toml +++ b/foundry.toml @@ -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 diff --git a/test/Folio.t.sol b/test/Folio.t.sol index 6e60566f..c75d74a3 100644 --- a/test/Folio.t.sol +++ b/test/Folio.t.sol @@ -5,7 +5,7 @@ import { IBaseTrustedFiller } from "@reserve-protocol/trusted-fillers/contracts/ import { GPv2OrderLib } from "@reserve-protocol/trusted-fillers/contracts/fillers/cowswap/GPv2OrderLib.sol"; import { IFolio } from "contracts/interfaces/IFolio.sol"; import { Folio } from "contracts/Folio.sol"; -import { AUCTION_WARMUP, D27, MIN_AUCTION_LENGTH, MAX_AUCTION_LENGTH, MAX_MINT_FEE, MAX_TTL, MAX_FEE_RECIPIENTS, MAX_TOKEN_PRICE, MAX_TOKEN_PRICE_RANGE, MAX_TVL_FEE, MAX_LIMIT, MAX_WEIGHT, ONE_DAY, RESTRICTED_AUCTION_BUFFER } from "@utils/Constants.sol"; +import { AUCTION_WARMUP, D18, D27, FOLIO_FEE_HANDOUT_PERIOD, FOLIO_FEE_HANDOUT_RATE, MIN_AUCTION_LENGTH, MAX_AUCTION_LENGTH, MAX_MINT_FEE, MIN_MINT_FEE, MAX_TTL, MAX_FEE_RECIPIENTS, MAX_TOKEN_PRICE, MAX_TOKEN_PRICE_RANGE, MAX_TVL_FEE, MAX_LIMIT, MAX_WEIGHT, ONE_DAY, RESTRICTED_AUCTION_BUFFER } from "@utils/Constants.sol"; import { FolioLib } from "@utils/FolioLib.sol"; import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; import { FolioProxy } from "contracts/folio/FolioProxy.sol"; @@ -23,6 +23,8 @@ contract FolioTest is BaseTest { uint256 internal constant MAX_TVL_FEE_PER_SECOND = 3340960028; // D18{1/s} 10% annually, per second uint256 internal constant AUCTION_LAUNCHER_WINDOW = MAX_TTL / 2; uint256 internal constant AUCTION_LENGTH = 1800; // {s} 30 min + uint256 internal constant FOLIO_PENDING_FEE_SHARES_SLOT = 37; + uint256 internal constant LAST_FOLIO_FEE_POKE_SLOT = 38; IFolio.WeightRange internal SELL = IFolio.WeightRange({ low: 0, spot: 0, high: 0 }); // sell as much as possible IFolio.WeightRange internal BUY = IFolio.WeightRange({ low: MAX_WEIGHT, spot: MAX_WEIGHT, high: MAX_WEIGHT }); // buy as much as possible @@ -639,6 +641,29 @@ contract FolioTest is BaseTest { vm.stopPrank(); } + function _expectedDefaultFeeBalances( + uint256 pendingFeeShares, + uint256 initialDaoShares, + uint256 initialOwnerShares, + uint256 initialFeeReceiverShares + ) + internal + view + returns (uint256 expectedDaoShares, uint256 expectedOwnerShares, uint256 expectedFeeReceiverShares) + { + (, uint256 daoFeeNumerator, uint256 daoFeeDenominator, ) = daoFeeRegistry.getFeeDetails(address(folio)); + uint256 feeRecipientShares = pendingFeeShares - + Math.ceilDiv(pendingFeeShares * daoFeeNumerator, daoFeeDenominator); + uint256 ownerShares = (feeRecipientShares * 0.9e18) / D18; + uint256 feeReceiverShares = (feeRecipientShares * 0.1e18) / D18; + + return ( + initialDaoShares + pendingFeeShares - ownerShares - feeReceiverShares, + initialOwnerShares + ownerShares, + initialFeeReceiverShares + feeReceiverShares + ); + } + function test_daoFee() public { // set dao fee to 0.15% daoFeeRegistry.setTokenFeeNumerator(address(folio), 0.15e18); @@ -827,6 +852,18 @@ contract FolioTest is BaseTest { uint256 initialOwnerShares = folio.balanceOf(owner); uint256 initialDaoShares = folio.balanceOf(dao); + uint256 initialFeeReceiverShares = folio.balanceOf(feeReceiver); + + ( + uint256 expectedDaoShares, + uint256 expectedOwnerShares, + uint256 expectedFeeReceiverShares + ) = _expectedDefaultFeeBalances( + pendingFeeShares, + initialDaoShares, + initialOwnerShares, + initialFeeReceiverShares + ); vm.startPrank(owner); IFolio.FeeRecipient[] memory recipients = new IFolio.FeeRecipient[](3); @@ -840,14 +877,10 @@ contract FolioTest is BaseTest { assertEq(folio.daoPendingFeeShares(), 0, "wrong dao pending fee shares"); assertEq(folio.feeRecipientsPendingFeeShares(), 0, "wrong fee recipients pending fee shares"); - // check receipient balances - (, uint256 daoFeeNumerator, uint256 daoFeeDenominator, ) = daoFeeRegistry.getFeeDetails(address(folio)); - uint256 expectedDaoShares = initialDaoShares + (pendingFeeShares * daoFeeNumerator) / daoFeeDenominator + 1; + // check recipient balances assertEq(folio.balanceOf(address(dao)), expectedDaoShares, "wrong dao shares"); - - uint256 remainingShares = pendingFeeShares - expectedDaoShares; - assertEq(folio.balanceOf(owner), initialOwnerShares + (remainingShares * 0.9e18) / 1e18, "wrong owner shares"); - assertEq(folio.balanceOf(feeReceiver), (remainingShares * 0.1e18) / 1e18, "wrong fee receiver shares"); + assertEq(folio.balanceOf(owner), expectedOwnerShares, "wrong owner shares"); + assertEq(folio.balanceOf(feeReceiver), expectedFeeReceiverShares, "wrong fee receiver shares"); } function test_setTvlFee() public { @@ -1023,6 +1056,18 @@ contract FolioTest is BaseTest { uint256 initialOwnerShares = folio.balanceOf(owner); uint256 initialDaoShares = folio.balanceOf(dao); + uint256 initialFeeReceiverShares = folio.balanceOf(feeReceiver); + + ( + uint256 expectedDaoShares, + uint256 expectedOwnerShares, + uint256 expectedFeeReceiverShares + ) = _expectedDefaultFeeBalances( + pendingFeeShares, + initialDaoShares, + initialOwnerShares, + initialFeeReceiverShares + ); vm.startPrank(owner); uint256 newTVLFee = MAX_TVL_FEE / 1000; @@ -1031,14 +1076,10 @@ contract FolioTest is BaseTest { assertEq(folio.daoPendingFeeShares(), 0, "wrong dao pending fee shares"); assertEq(folio.feeRecipientsPendingFeeShares(), 0, "wrong fee recipients pending fee shares"); - // check receipient balances - (, uint256 daoFeeNumerator, uint256 daoFeeDenominator, ) = daoFeeRegistry.getFeeDetails(address(folio)); - uint256 expectedDaoShares = initialDaoShares + (pendingFeeShares * daoFeeNumerator) / daoFeeDenominator + 1; + // check recipient balances assertEq(folio.balanceOf(address(dao)), expectedDaoShares, "wrong dao shares"); - - uint256 remainingShares = pendingFeeShares - expectedDaoShares; - assertEq(folio.balanceOf(owner), initialOwnerShares + (remainingShares * 0.9e18) / 1e18, "wrong owner shares"); - assertEq(folio.balanceOf(feeReceiver), (remainingShares * 0.1e18) / 1e18, "wrong fee receiver shares"); + assertEq(folio.balanceOf(owner), expectedOwnerShares, "wrong owner shares"); + assertEq(folio.balanceOf(feeReceiver), expectedFeeReceiverShares, "wrong fee receiver shares"); } function test_pendingFeeSharesAtFeeFloor() public { @@ -1430,24 +1471,23 @@ contract FolioTest is BaseTest { uint256 initialDaoShares = folio.balanceOf(dao); uint256 initialFeeReceiverShares = folio.balanceOf(feeReceiver); - (, uint256 daoFeeNumerator, uint256 daoFeeDenominator, ) = daoFeeRegistry.getFeeDetails(address(folio)); - uint256 expectedDaoShares = initialDaoShares + (pendingFeeShares * daoFeeNumerator) / daoFeeDenominator + 1; - uint256 remainingShares = pendingFeeShares - expectedDaoShares; + ( + uint256 expectedDaoShares, + uint256 expectedOwnerShares, + uint256 expectedFeeReceiverShares + ) = _expectedDefaultFeeBalances( + pendingFeeShares, + initialDaoShares, + initialOwnerShares, + initialFeeReceiverShares + ); daoFeeRegistry.setTokenFeeNumerator(address(folio), 0.1e18); - // check receipient balances + // check recipient balances assertEq(folio.balanceOf(address(dao)), expectedDaoShares, "wrong dao shares, 1st change"); - assertEq( - folio.balanceOf(owner), - initialOwnerShares + (remainingShares * 0.9e18) / 1e18, - "wrong owner shares, 1st change" - ); - assertEq( - folio.balanceOf(feeReceiver), - initialFeeReceiverShares + (remainingShares * 0.1e18) / 1e18, - "wrong fee receiver shares, 1st change" - ); + assertEq(folio.balanceOf(owner), expectedOwnerShares, "wrong owner shares, 1st change"); + assertEq(folio.balanceOf(feeReceiver), expectedFeeReceiverShares, "wrong fee receiver shares, 1st change"); // fast forward again, accumulate fees vm.warp(block.timestamp + YEAR_IN_SECONDS / 2); @@ -1457,26 +1497,20 @@ contract FolioTest is BaseTest { initialOwnerShares = folio.balanceOf(owner); initialDaoShares = folio.balanceOf(dao); initialFeeReceiverShares = folio.balanceOf(feeReceiver); - (, daoFeeNumerator, daoFeeDenominator, ) = daoFeeRegistry.getFeeDetails(address(folio)); + (expectedDaoShares, expectedOwnerShares, expectedFeeReceiverShares) = _expectedDefaultFeeBalances( + pendingFeeShares, + initialDaoShares, + initialOwnerShares, + initialFeeReceiverShares + ); // set new fee numerator, should distribute fees daoFeeRegistry.setTokenFeeNumerator(address(folio), 0.05e18); - // check receipient balances - expectedDaoShares = (pendingFeeShares * daoFeeNumerator + daoFeeDenominator - 1) / daoFeeDenominator + 1; - assertEq(folio.balanceOf(address(dao)), initialDaoShares + expectedDaoShares, "wrong dao shares, 2nd change"); - remainingShares = pendingFeeShares - expectedDaoShares; - assertApproxEqAbs( - folio.balanceOf(owner), - initialOwnerShares + (remainingShares * 0.9e18) / 1e18, - 3, - "wrong owner shares, 2nd change" - ); - assertEq( - folio.balanceOf(feeReceiver), - initialFeeReceiverShares + (remainingShares * 0.1e18) / 1e18, - "wrong fee receiver shares, 2nd change" - ); + // check recipient balances + assertEq(folio.balanceOf(address(dao)), expectedDaoShares, "wrong dao shares, 2nd change"); + assertEq(folio.balanceOf(owner), expectedOwnerShares, "wrong owner shares, 2nd change"); + assertEq(folio.balanceOf(feeReceiver), expectedFeeReceiverShares, "wrong fee receiver shares, 2nd change"); } function test_atomicBidWithoutCallback() public { @@ -5089,6 +5123,50 @@ contract FolioTest is BaseTest { // folioFeeForSelf tests // ========================================================= + function _configureMintSelfFeeHandout() internal { + uint256 handoutEnd = (block.timestamp / ONE_DAY) * ONE_DAY + FOLIO_FEE_HANDOUT_PERIOD; + if (block.timestamp < handoutEnd) { + vm.warp(handoutEnd); + } + + _enableMintSelfFeeHandout(); + } + + function _enableMintSelfFeeHandout() internal { + daoFeeRegistry.setDefaultFeeFloor(0); + daoFeeRegistry.setDefaultFeeNumerator(0); + + vm.startPrank(owner); + folio.setTVLFee(0); + folio.setFolioSelfFee(1e18); + folio.setMintFee(MAX_MINT_FEE); + vm.stopPrank(); + } + + function _mintForUser(uint256 shares) internal { + vm.startPrank(user1); + USDC.approve(address(folio), type(uint256).max); + DAI.approve(address(folio), type(uint256).max); + MEME.approve(address(folio), type(uint256).max); + folio.mint(shares, user1, 0); + vm.stopPrank(); + } + + function _nextDay() internal view returns (uint256) { + return ((block.timestamp / ONE_DAY) + 1) * ONE_DAY; + } + + function _maxFolioFeeHandout(uint256 supply, uint256 elapsed) internal pure returns (uint256) { + return Math.mulDiv(supply, FOLIO_FEE_HANDOUT_RATE * elapsed, D18); + } + + function test_mintSelfFeeHandout_parameters() public pure { + assertEq(FOLIO_FEE_HANDOUT_RATE, 0.000005e18, "wrong per-second handout rate"); + assertEq(FOLIO_FEE_HANDOUT_PERIOD, 10 minutes, "wrong handout period"); + assertEq(FOLIO_FEE_HANDOUT_RATE * 60 seconds, MIN_MINT_FEE, "wrong minimum-fee capture time"); + assertEq(FOLIO_FEE_HANDOUT_RATE * FOLIO_FEE_HANDOUT_PERIOD, 0.003e18, "wrong maximum daily handout rate"); + } + function test_setFolioFee() public { vm.startPrank(owner); @@ -5158,7 +5236,7 @@ contract FolioTest is BaseTest { assertTrue(folio.balanceOf(dao) > initialDaoShares, "dao should have received fees"); } - /// @dev With folioFeeForSelf at 50%, half of the fee-recipient shares on mint should be burned + /// @dev With folioFeeForSelf at 50%, half of the fee-recipient shares on mint should await handout function test_mintWithFolioFeeForSelf() public { // set folioFeeForSelf to 50% vm.prank(owner); @@ -5184,7 +5262,7 @@ contract FolioTest is BaseTest { uint256 daoFeeShares = (totalFeeShares * MAX_DAO_FEE + 1e18 - 1) / 1e18; // recipientRaw = totalFeeShares - daoFeeShares uint256 recipientRaw = totalFeeShares - daoFeeShares; - // selfShares (burned) = recipientRaw * 50% + // selfShares (pending handout) = recipientRaw * 50% uint256 selfShares = (recipientRaw * 0.5e18) / 1e18; uint256 expectedFeeRecipientShares = recipientRaw - selfShares; @@ -5192,9 +5270,9 @@ contract FolioTest is BaseTest { uint256 expectedSharesOut = amt - totalFeeShares; assertEq(folio.balanceOf(user1), expectedSharesOut, "wrong user shares out"); - // total supply: genesis + sharesOut + daoFee + feeRecipientFee (self-fee NOT minted) - uint256 expectedTotalSupply = INITIAL_SUPPLY + expectedSharesOut + daoFeeShares + expectedFeeRecipientShares; - assertEq(folio.totalSupply(), expectedTotalSupply, "wrong total supply with folioFeeForSelf"); + // pending self-fees remain in effective supply until handout + assertEq(folio.folioPendingMintFeeShares(), selfShares, "wrong pending folio fee shares"); + assertEq(folio.totalSupply(), INITIAL_SUPPLY + amt, "wrong total supply with folioFeeForSelf"); assertEq(folio.daoPendingFeeShares(), daoFeeShares, "wrong dao pending fee shares"); assertEq( @@ -5204,7 +5282,7 @@ contract FolioTest is BaseTest { ); } - /// @dev With folioFeeForSelf at 100%, ALL fee-recipient shares on mint are burned + /// @dev With folioFeeForSelf at 100%, ALL fee-recipient shares on mint await handout function test_mintWithFolioFeeForSelf_100Percent() public { // set folioFeeForSelf to 100% vm.prank(owner); @@ -5228,16 +5306,329 @@ contract FolioTest is BaseTest { uint256 totalFeeShares = (amt * MAX_MINT_FEE + 1e18 - 1) / 1e18; uint256 daoFeeShares = (totalFeeShares * MAX_DAO_FEE + 1e18 - 1) / 1e18; - // ALL fee-recipient shares are burned (folioFeeForSelf = 100%) + // ALL fee-recipient shares await handout (folioFeeForSelf = 100%) assertEq(folio.feeRecipientsPendingFeeShares(), 0, "fee recipients should get 0 with 100% folioFee"); assertEq(folio.daoPendingFeeShares(), daoFeeShares, "wrong dao pending fee shares"); + assertEq(folio.folioPendingMintFeeShares(), totalFeeShares - daoFeeShares, "wrong pending folio fee shares"); uint256 expectedSharesOut = amt - totalFeeShares; assertEq(folio.balanceOf(user1), expectedSharesOut, "wrong user shares out"); - // total supply = genesis + sharesOut + daoFee (no fee-recipient shares minted) - uint256 expectedTotalSupply = INITIAL_SUPPLY + expectedSharesOut + daoFeeShares; - assertEq(folio.totalSupply(), expectedTotalSupply, "wrong total supply"); + assertEq(folio.totalSupply(), INITIAL_SUPPLY + amt, "wrong total supply"); + } + + function test_mintWithFolioFeeForSelf_isExchangeRateNeutral() public { + _configureMintSelfFeeHandout(); + + (, uint256[] memory amountsBefore) = folio.toAssets(D18, Math.Rounding.Floor); + _mintForUser(INITIAL_SUPPLY); + (, uint256[] memory amountsAfter) = folio.toAssets(D18, Math.Rounding.Floor); + + assertEq(amountsAfter, amountsBefore, "mint changed exchange rate"); + assertEq(folio.totalSupply(), INITIAL_SUPPLY * 2, "mint did not add gross shares"); + assertTrue(folio.folioPendingMintFeeShares() > 0, "missing pending self-fees"); + } + + function test_mintSelfFeeHandout_doesNotStartBeforeDailyBoundary() public { + _configureMintSelfFeeHandout(); + _mintForUser(INITIAL_SUPPLY); + + uint256 pendingSelfFees = folio.folioPendingMintFeeShares(); + uint256 supplyBefore = folio.totalSupply(); + vm.warp(_nextDay() - 1); + + assertEq(folio.totalSupply(), supplyBefore, "self-fees handed out early"); + folio.poke(); + assertEq(folio.folioPendingMintFeeShares(), pendingSelfFees, "poke handed out self-fees early"); + } + + function test_mintSelfFeeHandout_usesInitializationWindow() public { + _enableMintSelfFeeHandout(); + _mintForUser(INITIAL_SUPPLY); + + uint256 pendingSelfFees = folio.folioPendingMintFeeShares(); + uint256 supplyBefore = folio.totalSupply(); + uint256 handoutSupply = supplyBefore - pendingSelfFees; + + vm.warp(block.timestamp + 1); + assertEq( + folio.totalSupply(), + supplyBefore - _maxFolioFeeHandout(handoutSupply, 1), + "handout did not use initialization window" + ); + } + + function test_mintSelfFeeHandout_doesNotWritePendingOutsideWindow() public { + _configureMintSelfFeeHandout(); + _mintForUser(INITIAL_SUPPLY); + vm.warp(block.timestamp + 1); + + vm.record(); + folio.poke(); + (, bytes32[] memory writeSlots) = vm.accesses(address(folio)); + + for (uint256 i; i < writeSlots.length; i++) { + assertNotEq(writeSlots[i], bytes32(FOLIO_PENDING_FEE_SHARES_SLOT), "outside window wrote pending shares"); + } + } + + function test_mintSelfFeeHandout_isLinearAndStopsAfterPeriod() public { + _configureMintSelfFeeHandout(); + _mintForUser(INITIAL_SUPPLY); + + uint256 boundary = _nextDay(); + uint256 pendingSelfFees = folio.folioPendingMintFeeShares(); + uint256 supplyBefore = folio.totalSupply(); + uint256 handoutSupply = supplyBefore - pendingSelfFees; + + vm.warp(boundary); + assertEq(folio.totalSupply(), supplyBefore, "boundary changed supply"); + + vm.warp(boundary + 1); + assertEq(folio.totalSupply(), supplyBefore - _maxFolioFeeHandout(handoutSupply, 1), "wrong first handout"); + + vm.warp(boundary + 2); + assertEq(folio.totalSupply(), supplyBefore - _maxFolioFeeHandout(handoutSupply, 2), "handout not linear"); + + vm.warp(boundary + FOLIO_FEE_HANDOUT_PERIOD); + uint256 supplyAfterPeriod = supplyBefore - _maxFolioFeeHandout(handoutSupply, FOLIO_FEE_HANDOUT_PERIOD); + assertEq(folio.totalSupply(), supplyAfterPeriod, "wrong maximum handout"); + + vm.warp(boundary + ONE_DAY - 1); + assertEq(folio.totalSupply(), supplyAfterPeriod, "handout continued past period"); + + uint256 dailyHandout = _maxFolioFeeHandout(handoutSupply, FOLIO_FEE_HANDOUT_PERIOD); + vm.expectEmit(true, false, false, true, address(folio)); + emit IFolio.FolioFeePaid(address(folio), dailyHandout); + folio.poke(); + assertEq(folio.totalSupply(), supplyAfterPeriod, "poke changed effective supply"); + assertEq(folio.folioPendingMintFeeShares(), pendingSelfFees - dailyHandout, "wrong pending self-fees"); + } + + function test_mintSelfFeeHandout_rollsExcessIntoLaterDays() public { + _configureMintSelfFeeHandout(); + _mintForUser(INITIAL_SUPPLY); + + uint256 boundary = _nextDay(); + uint256 pendingSelfFees = folio.folioPendingMintFeeShares(); + uint256 supplyBefore = folio.totalSupply(); + uint256 handoutSupply = supplyBefore - pendingSelfFees; + uint256 dailyHandout = _maxFolioFeeHandout(handoutSupply, FOLIO_FEE_HANDOUT_PERIOD); + assertTrue(pendingSelfFees > dailyHandout * 3, "insufficient rollover self-fees"); + + vm.warp(boundary + FOLIO_FEE_HANDOUT_PERIOD); + folio.poke(); + assertEq(folio.folioPendingMintFeeShares(), pendingSelfFees - dailyHandout, "wrong first-day rollover"); + + vm.warp(boundary + ONE_DAY + FOLIO_FEE_HANDOUT_PERIOD); + folio.poke(); + assertEq(folio.folioPendingMintFeeShares(), pendingSelfFees - dailyHandout * 2, "wrong second-day rollover"); + + vm.warp(boundary + 3 * ONE_DAY + FOLIO_FEE_HANDOUT_PERIOD); + assertEq(folio.totalSupply(), supplyBefore - dailyHandout * 4, "missed daily windows were not handed out"); + } + + function test_mintSelfFeeHandout_stopsWhenPendingIsEmpty() public { + _configureMintSelfFeeHandout(); + _mintForUser(D18); + + uint256 boundary = _nextDay(); + uint256 pendingSelfFees = folio.folioPendingMintFeeShares(); + uint256 supplyBefore = folio.totalSupply(); + assertTrue( + pendingSelfFees < _maxFolioFeeHandout(supplyBefore - pendingSelfFees, 1), + "self-fees exceed one-second handout" + ); + + vm.warp(boundary + 1); + assertEq(folio.totalSupply(), supplyBefore - pendingSelfFees, "small self-fee not fully handed out"); + folio.poke(); + assertEq(folio.folioPendingMintFeeShares(), 0, "empty handout left pending shares"); + } + + function test_mintSelfFeeHandout_repeatedPokesCannotDoubleHandout() public { + _configureMintSelfFeeHandout(); + _mintForUser(INITIAL_SUPPLY); + + uint256 boundary = _nextDay(); + vm.warp(boundary + 1); + folio.poke(); + uint256 pendingAfterPoke = folio.folioPendingMintFeeShares(); + uint256 supplyAfterPoke = folio.totalSupply(); + + folio.poke(); + assertEq(folio.folioPendingMintFeeShares(), pendingAfterPoke, "same-time poke repeated handout"); + assertEq(folio.totalSupply(), supplyAfterPoke, "same-time poke changed supply"); + } + + function test_mintSelfFeeHandout_doesNotUsePastCapacity() public { + _configureMintSelfFeeHandout(); + vm.warp(block.timestamp + 3 * ONE_DAY); + + uint256 supplyBefore = folio.totalSupply(); + _mintForUser(INITIAL_SUPPLY); + uint256 pendingSelfFees = folio.folioPendingMintFeeShares(); + + assertTrue(pendingSelfFees > 0, "missing pending self-fees"); + assertEq(folio.totalSupply(), supplyBefore + INITIAL_SUPPLY, "past capacity handed out new fees"); + folio.poke(); + assertEq(folio.folioPendingMintFeeShares(), pendingSelfFees, "poke used past capacity"); + } + + function test_mintDuringSelfFeeHandout_isExchangeRateNeutral() public { + _configureMintSelfFeeHandout(); + _mintForUser(INITIAL_SUPPLY); + + uint256 boundary = _nextDay(); + vm.warp(boundary + FOLIO_FEE_HANDOUT_PERIOD / 2); + (, uint256[] memory amountsBefore) = folio.toAssets(D18, Math.Rounding.Floor); + _mintForUser(INITIAL_SUPPLY); + (, uint256[] memory amountsAfter) = folio.toAssets(D18, Math.Rounding.Floor); + + assertEq(amountsAfter, amountsBefore, "mid-handout mint changed exchange rate"); + assertEq(folio.lastFolioFeePoke(), block.timestamp, "mint did not advance handout time"); + + uint256 pendingSelfFees = folio.folioPendingMintFeeShares(); + uint256 supplyBefore = folio.totalSupply(); + uint256 handoutSupply = supplyBefore - pendingSelfFees; + vm.warp(block.timestamp + 1); + assertEq(folio.totalSupply(), supplyBefore - _maxFolioFeeHandout(handoutSupply, 1), "wrong post-mint handout"); + } + + function test_mintSelfFeeHandout_viewAndStateMatchWithTVLFees() public { + vm.startPrank(owner); + folio.setFolioSelfFee(1e18); + folio.setMintFee(MAX_MINT_FEE); + vm.stopPrank(); + _mintForUser(INITIAL_SUPPLY); + + vm.warp(_nextDay() + 1); + uint256 supplyBeforePoke = folio.totalSupply(); + uint256 pendingBeforePoke = folio.getPendingFeeShares(); + + folio.poke(); + + assertEq(folio.totalSupply(), supplyBeforePoke, "poke changed effective supply"); + assertEq(folio.getPendingFeeShares(), pendingBeforePoke, "poke changed pending fee view"); + } + + function test_redeemDuringSelfFeeHandout_usesNewSupply() public { + _configureMintSelfFeeHandout(); + _mintForUser(INITIAL_SUPPLY); + + uint256 pendingBefore = folio.folioPendingMintFeeShares(); + uint256 handoutSupplyBefore = folio.totalSupply() - pendingBefore; + vm.warp(_nextDay() + FOLIO_FEE_HANDOUT_PERIOD / 2); + + uint256 shares = folio.balanceOf(user1) / 2; + (address[] memory basket, ) = folio.toAssets(shares, Math.Rounding.Floor); + vm.prank(user1); + folio.redeem(shares, user1, basket, new uint256[](basket.length)); + + uint256 expectedElapsedHandout = _maxFolioFeeHandout(handoutSupplyBefore, FOLIO_FEE_HANDOUT_PERIOD / 2); + assertEq( + folio.folioPendingMintFeeShares(), + pendingBefore - expectedElapsedHandout, + "redeem did not settle elapsed handout" + ); + + uint256 handoutSupplyAfter = folio.totalSupply() - folio.folioPendingMintFeeShares(); + vm.warp(block.timestamp + 1); + folio.poke(); + assertEq( + folio.folioPendingMintFeeShares(), + pendingBefore - expectedElapsedHandout - _maxFolioFeeHandout(handoutSupplyAfter, 1), + "wrong handout after redeem" + ); + } + + function test_mintSelfFeeHandout_zeroEligibleSupplyPausesUntilRemint() public { + _configureMintSelfFeeHandout(); + vm.prank(owner); + folio.transfer(user1, INITIAL_SUPPLY); + + (address[] memory basket, ) = folio.toAssets(INITIAL_SUPPLY, Math.Rounding.Floor); + vm.prank(user1); + folio.redeem(INITIAL_SUPPLY, address(folio), basket, new uint256[](basket.length)); + + uint256 backlog = INITIAL_SUPPLY / 10; + vm.store(address(folio), bytes32(FOLIO_PENDING_FEE_SHARES_SLOT), bytes32(backlog)); + + assertEq(folio.totalSupply(), backlog, "eligible supply remains"); + + vm.warp(_nextDay() + FOLIO_FEE_HANDOUT_PERIOD); + folio.poke(); + assertEq(folio.folioPendingMintFeeShares(), backlog, "zero base handed out fees"); + + _mintForUser(INITIAL_SUPPLY); + uint256 pendingAfterMint = folio.folioPendingMintFeeShares(); + vm.warp(_nextDay() + 1); + folio.poke(); + assertLt(folio.folioPendingMintFeeShares(), pendingAfterMint, "remint did not restart handout"); + } + + function test_mintSelfFeeHandout_upgradeInitializesWithoutHistoricalCapacity() public { + _mintForUser(INITIAL_SUPPLY); + _configureMintSelfFeeHandout(); + + assertEq(folio.folioPendingMintFeeShares(), 0, "upgrade started with pending fees"); + vm.store(address(folio), bytes32(LAST_FOLIO_FEE_POKE_SLOT), bytes32(0)); + vm.warp(block.timestamp + 3 * ONE_DAY); + + _mintForUser(INITIAL_SUPPLY); + uint256 pendingSelfFees = folio.folioPendingMintFeeShares(); + assertGt(pendingSelfFees, 0, "mint did not create pending fees"); + assertEq(folio.lastFolioFeePoke(), block.timestamp, "mint did not initialize timestamp"); + + folio.poke(); + assertEq(folio.folioPendingMintFeeShares(), pendingSelfFees, "upgrade used historical capacity"); + } + + function test_pendingMintSelfFeesAreExemptFromTVLFees() public { + uint256 handoutEnd = (block.timestamp / ONE_DAY) * ONE_DAY + FOLIO_FEE_HANDOUT_PERIOD; + if (block.timestamp < handoutEnd) { + vm.warp(handoutEnd); + } + daoFeeRegistry.setDefaultFeeFloor(0); + daoFeeRegistry.setDefaultFeeNumerator(0); + vm.startPrank(owner); + folio.setFolioSelfFee(0.5e18); + folio.setMintFee(MAX_MINT_FEE); + folio.setTVLFee(MAX_TVL_FEE); + vm.stopPrank(); + + uint256 snapshot = vm.snapshotState(); + _mintForUser(INITIAL_SUPPLY); + uint256 eligibleMintShares = folio.totalSupply() - folio.folioPendingMintFeeShares() - INITIAL_SUPPLY; + uint256 pendingBeforeTVL = folio.getPendingFeeShares(); + uint256 boundary = _nextDay(); + vm.warp(boundary); + uint256 pendingWithBacklog = folio.getPendingFeeShares() - pendingBeforeTVL; + + vm.revertToState(snapshot); + vm.prank(owner); + folio.setMintFee(0); + _mintForUser(eligibleMintShares); + pendingBeforeTVL = folio.getPendingFeeShares(); + vm.warp(boundary); + + assertEq(folio.getPendingFeeShares() - pendingBeforeTVL, pendingWithBacklog, "backlog accrued TVL fees"); + } + + function test_distributeFeesAndFeeSetter_doNotBypassSelfFeeHandout() public { + _configureMintSelfFeeHandout(); + _mintForUser(INITIAL_SUPPLY); + + uint256 pendingSelfFees = folio.folioPendingMintFeeShares(); + uint256 supplyBefore = folio.totalSupply(); + folio.distributeFees(); + assertEq(folio.folioPendingMintFeeShares(), pendingSelfFees, "distribution bypassed handout"); + assertEq(folio.totalSupply(), supplyBefore, "distribution changed effective supply"); + + vm.prank(owner); + folio.setFolioSelfFee(0); + assertEq(folio.folioPendingMintFeeShares(), pendingSelfFees, "fee setter bypassed handout"); + assertEq(folio.totalSupply(), supplyBefore, "fee setter changed effective supply"); } /// @dev With folioFeeForSelf at 0%, behavior is unchanged from before @@ -5300,6 +5691,7 @@ contract FolioTest is BaseTest { vm.roll(block.number + 1000000); uint256 accountedUntil = (block.timestamp / ONE_DAY) * ONE_DAY; + uint256 lastTVLFeePoke = folio.lastPoke(); (, , uint256 expectedSelfFeeShares) = FolioLib.computeFeeShares( FolioLib.FeeSharesParams({ currentDaoPending: folio.daoPendingFeeShares(), @@ -5307,7 +5699,7 @@ contract FolioTest is BaseTest { tvlFee: folio.tvlFee(), folioFeeForSelf: folio.folioFeeForSelf(), supply: supplyBefore, - elapsed: accountedUntil - folio.lastPoke() + elapsed: accountedUntil - lastTVLFeePoke }), daoFeeRegistry );