src/dividend/DividendVault.sol
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import { ReentrancyGuard } from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import { Math } from "@openzeppelin/contracts/utils/math/Math.sol";
/// @title DividendVault
/// @author Dev
/// @notice Holds one arbitrary ERC-20 "quote" token and pays it out to the holders of one launch token, in
/// proportion to what each of them held while they held it.
/// @dev **The whole contract is one accounting decision repeated in four places, so the decision is worth
/// stating before any of the code: a holder is owed a number of *shares*, never a number of tokens.**
///
/// The obvious design credits a holder an amount. Revenue of 100 arrives, the eligible supply is 4, and
/// each token is credited 25. That is correct for exactly as long as the quote token behaves like a
/// ledger of fixed amounts, and the quote here is whatever the launcher picked. If it rebases downward
/// — and every rebasing stablecoin, staked-ETH derivative and elastic-supply token can — the vault's
/// balance falls to 90 while the sum of the credited amounts is still 100. The first three claimers are
/// paid in full out of a pot that no longer covers them and **the last claimer's transfer reverts**.
/// Their money is not late, it is gone, and nothing in the contract can put it back because the credits
/// were denominated in a unit the vault does not control.
///
/// Denominating in shares removes the failure rather than handling it. A share is a claim on a fraction
/// of whatever the vault holds at the moment of payment:
///
/// owed_tokens = owed_shares * quote.balanceOf(vault) / totalShares
///
/// A negative rebase moves the numerator for every holder at once, so all claims shrink by the same
/// ratio and none of them can exceed the balance. **`Σ owed(holder) <= quote.balanceOf(this)` is a
/// consequence of the arithmetic, not a property the code has to defend**, and it is asserted as an
/// invariant in `tests/unit/DividendVault.t.sol` because a future edit could easily reintroduce the
/// amount-denominated version without anybody noticing until a rebase.
///
/// **Income is measured, never announced.** There is no `deposit(uint256 amount)`. The vault compares
/// `quote.balanceOf(this)` against `reserved`, the balance it has already accounted for, and the
/// difference is the income. A caller who says they sent 100 and a fee-on-transfer quote that delivers
/// 97 disagree; the balance does not. The same read is what makes the vault **self-syncing**: revenue
/// that arrives by a plain `transfer` from a fee splitter, a hook, or a person who typed the address in
/// by hand is folded in by the next state-changing call, so there is no keeper whose downtime is
/// silently a loss of funds.
///
/// **The divisor is `eligibleSupply`, never the token's `totalSupply()`.** The pool manager, the hook,
/// the treasury and the burn address hold a large and varying share of any launch token's supply and
/// cannot claim. Dividing by the total supply hands them their proportion of every distribution and
/// then leaves it in the vault forever, which is the single most common way a dividend token strands
/// money. `eligibleSupply` is maintained incrementally from the same balance-change notifications that
/// drive everything else, so an excluded address is not a special case: it is an address whose balance
/// changes are simply not counted.
///
/// **This vault is deployed by its token, and never separately.** `token` is `msg.sender` at
/// construction and immutable, which is only possible because `DividendToken`'s constructor does
/// `new DividendVault(...)` before it mints. The alternative — deploying the vault first and binding it
/// to a token with a one-shot setter — leaves a window in which anybody can bind the vault to a token
/// whose balances they control and post themselves the entire eligible supply. It also makes the single
/// most security-critical field on the contract writable, which a deployment split across a reorg can
/// turn from a theoretical window into a real one.
contract DividendVault is ReentrancyGuard {
using SafeERC20 for IERC20;
/// @notice The quote token must not be the zero address.
error QuoteCannotBeZero();
/// @notice The quote token must not be the launch token this vault pays out on.
/// @dev **Not a tidiness check.** If the quote were the launch token, a payout would move the launch
/// token, which re-enters `onBalanceChange` in the middle of the payout's own accounting. The
/// reentrancy guard cannot help, because `onBalanceChange` has to stay callable during a transfer.
error QuoteCannotBeTheToken();
/// @notice Only the launch token may post balance changes.
error OnlyToken();
/// @notice The caller has nothing to claim.
error NothingOwed();
/// @notice Income was folded into the accumulator.
/// @param income The measured increase in the vault's quote balance since the last sync.
/// @param sharesMinted How many new shares that income bought, at the pre-sync share price.
/// @param accSharesPerToken The accumulator after the fold.
event Synced(uint256 income, uint256 sharesMinted, uint256 accSharesPerToken);
/// @notice A holder was paid, by push or by pull.
/// @param holder Who was paid.
/// @param shares The shares burned to pay them.
/// @param amount The quote tokens sent.
event Paid(address indexed holder, uint256 shares, uint256 amount);
/// @notice A pushed payment reverted and was skipped. The holder keeps every share they had.
/// @param holder Who could not be paid.
/// @param shares The shares they still hold.
event PaymentSkipped(address indexed holder, uint256 shares);
/// @notice An address was excluded at construction and can never earn.
/// @param account The excluded address.
event Exclusion(address indexed account);
/// @notice A holder entered or left the payout rotation.
/// @param holder The holder.
/// @param registered Whether they are now in it.
event Registration(address indexed holder, bool registered);
/// @notice A pass of the payout rotation finished.
/// @param from The cursor position it started at.
/// @param to Where it stopped.
/// @param paidCount How many holders were actually paid.
event Rotated(uint256 from, uint256 to, uint256 paidCount);
/// @notice The canonical burn address, excluded unconditionally.
/// @dev Tokens sent here are gone, and crediting them means crediting nobody. Excluding it is not
/// optional and so it is not left to the caller to remember.
address public constant DEAD = 0x000000000000000000000000000000000000dEaD;
/// @notice The fixed-point scale of `accSharesPerToken`, in share-wei per token-wei.
/// @dev **1e36, not 1e18, and the extra eighteen digits are load-bearing.** The accumulator step is
/// `newShares * SHARE_PRECISION / eligibleSupply`. A six-decimal quote against a launch token with
/// an eighteen-decimal, billion-unit supply gives `1e6 * 1e18 / 1e27`, which truncates to **zero**:
/// every distribution of a whole USDC would round away to nothing and the vault would quietly never
/// pay anybody. At 1e36 the same step is 1e15 and survives. The ceiling is the other side: shares
/// are bounded by the quote's supply, so `newShares * 1e36` stays far below `type(uint256).max` for
/// any quote that a human would price a launch in, and every multiplication that could still be
/// close goes through `Math.mulDiv`, which carries the full 512-bit intermediate.
uint256 public constant SHARE_PRECISION = 1e36;
/// @notice The launch token whose holders this vault pays.
/// @dev `msg.sender` at construction. See the contract docblock for why it cannot be a constructor
/// argument and must not be a setter.
address public immutable token;
/// @notice The ERC-20 this vault pays out.
IERC20 public immutable quote;
/// @notice The quote balance the vault has already turned into shares.
/// @dev **The share pool, not a cached balance.** Anything the vault holds above `reserved` is income
/// that has not been folded in yet; anything below it is a rebase or a payout that has been. Payouts
/// divide by `reserved` rather than by the raw balance so that unfolded income can never be paid out
/// to existing shareholders ahead of the holders it is actually owed to.
uint256 public reserved;
/// @notice Every share currently outstanding. Together they are a claim on all of `reserved`.
uint256 public totalShares;
/// @notice Cumulative shares minted per token-wei of eligible supply, scaled by `SHARE_PRECISION`.
uint256 public accSharesPerToken;
/// @notice The launch-token supply that can earn: total supply minus every excluded balance.
/// @dev Maintained incrementally by `onBalanceChange`. **Never recomputed from `totalSupply()`**, which
/// would be both wrong and unavailable — the vault deliberately never calls back into the token.
uint256 public eligibleSupply;
/// @notice A holder's earning balance: what they hold, or zero if that is under the floor.
/// @dev Derived rather than stored. Holding it in a second mapping alongside `trackedBalanceOf` would
/// mean an extra storage write on **every transfer of the launch token**, to record something the
/// balance already determines — and would introduce the possibility of the two disagreeing.
/// @param holder The holder.
/// @return What they earn on.
function eligibleBalanceOf(address holder) public view returns (uint256) {
uint256 held = trackedBalanceOf[holder];
return held >= minEligibleBalance ? held : 0;
}
/// @notice Shares a holder has accrued and not yet been paid.
/// @dev Settled lazily. The live figure, including accrual since the last settlement and income not yet
/// folded in, is `sharesOf`.
mapping(address holder => uint256 shares) public settledShares;
/// @notice The value of `accSharesPerToken` when a holder was last settled.
mapping(address holder => uint256 checkpoint) public accCheckpointOf;
/// @notice Whether an address is excluded from earning.
mapping(address account => bool excluded) public isExcluded;
address[] private _exclusions;
// ---------------------------------------------------------------------------------------------------- //
// The payout rotation
// ---------------------------------------------------------------------------------------------------- //
/// @notice The smallest launch-token balance that earns dividends at all.
/// @dev **An eligibility rule, not merely a payout convenience.** A holder below this earns nothing:
/// their balance is not counted in `eligibleSupply`, so the income that would have been theirs is
/// divided among the holders above the line instead. There is nothing to claim later, because
/// nothing accrued.
///
/// That is a deliberate product decision and it is the one number here with a real distributional
/// consequence, so it is worth stating plainly. Two reasons for it:
///
/// **Dust cannot be paid for less than it is worth.** A transfer costs about forty thousand gas
/// whatever it carries, so a holder owed a fraction of a cent costs more to pay than they receive,
/// every time, forever.
///
/// **A rotation is a shared resource.** Anybody can create ten thousand addresses and dust each of
/// them; without a floor every one becomes a permanent entry the rotation walks past, and real
/// holders are reached ten thousand times more slowly for it.
///
/// Set from supply in `DividendToken`, so a launch cannot be configured with a bar that makes its
/// own dividends meaningless.
uint256 public immutable minEligibleBalance;
/// @notice A holder's launch-token balance, whether or not it earns.
/// @dev **The vault's own shadow of the token's ledger, and it is a shadow on purpose.** Reading
/// `token.balanceOf` from inside `_update` would make every number here depend on whether the token
/// calls the vault before or after it moves the balance, which is a detail of the token a future
/// edit could flip without touching this file. Tracking the signed deltas instead makes the vault
/// correct under either ordering, and removes an external call from the hot path of every transfer.
///
/// Excluded addresses are never repriced, so they never appear here.
mapping(address holder => uint256 balance) public trackedBalanceOf;
/// @notice Holders large enough to be pushed to, in no particular order.
address[] private _holders;
/// @notice Where each holder sits in `_holders`, plus one. Zero means "not in the rotation".
mapping(address holder => uint256 slotPlusOne) private _holderSlot;
/// @notice How far through `_holders` the rotation has got.
uint256 public cursor;
/// @notice How small a payout has to be before the rotation skips it, as a fraction of the pool.
/// @dev **Relative rather than absolute, so it needs no configuration and cannot rot.** An absolute
/// threshold would have to be expressed in the quote's own decimals, which the vault deliberately
/// never reads, and would be wrong the moment the quote's price moved. A millionth of the pool is
/// dimensionless, scales with the pot, and answers the only question that matters: is sending this
/// worth more than the gas it costs? A holder under it keeps every share and keeps accruing, so the
/// amount grows until it crosses — or they call `claim` and take it whenever they like.
uint256 public constant DUST_FRACTION = 1e6;
/// @param quote_ The ERC-20 to pay out. Arbitrary: it may rebase, take a fee on transfer, or use any
/// number of decimals.
/// @param exclusions_ Addresses that must never earn — the pool manager, the hook, the treasury, and
/// anything else holding supply on behalf of the protocol rather than a person. The vault itself
/// and `DEAD` are added unconditionally. Zero addresses and duplicates are ignored.
/// @param minEligibleBalance_ The smallest balance that earns. Zero lets every holder earn, which is
/// safe only where the token cannot be dusted cheaply.
constructor(address quote_, address[] memory exclusions_, uint256 minEligibleBalance_) {
if (quote_ == address(0)) revert QuoteCannotBeZero();
if (quote_ == msg.sender) revert QuoteCannotBeTheToken();
token = msg.sender;
quote = IERC20(quote_);
minEligibleBalance = minEligibleBalance_;
// The vault holds launch tokens only by accident, and crediting itself would let a stray transfer
// dilute every real holder.
_exclude(address(this));
_exclude(DEAD);
for (uint256 i; i < exclusions_.length; ++i) {
if (exclusions_[i] != address(0)) _exclude(exclusions_[i]);
}
}
/// @notice Posts a launch-token balance change. Called by the token on every `_update`.
/// @dev **The only caller that may ever reach this is the token**, because the entire share ledger is
/// derived from what it says. An open version of this function is a mint function for other
/// people's dividends.
///
/// The order inside is the part to review. `_sync` runs first so the accumulator already contains
/// every wei of income that arrived before this transfer; then each party is settled **at the
/// balance they held before the change**, which is what makes "you earn on what you held while you
/// held it" true rather than approximately true; only then do the balances move. Settling after the
/// move would pay the receiver for a distribution that happened before they owned anything.
///
/// A mint has `from == address(0)` and a burn has `to == address(0)`; neither is a holder, and
/// neither is tracked. An excluded party's leg is skipped entirely, which is the whole of the
/// exclusion mechanism: **an exclusion is not a flag consulted at payout time, it is a balance
/// change that was never counted.**
/// @param from Who lost the tokens, or the zero address on a mint.
/// @param to Who received them, or the zero address on a burn.
/// @param amount How many.
function onBalanceChange(address from, address to, uint256 amount) external {
if (msg.sender != token) revert OnlyToken();
_sync();
if (from != address(0) && !isExcluded[from]) _reprice(from, trackedBalanceOf[from] - amount);
if (to != address(0) && !isExcluded[to]) _reprice(to, trackedBalanceOf[to] + amount);
}
/// @notice Records a holder's new balance and moves them across the eligibility line if they crossed it.
/// @dev **Settle first, at the balance they were earning on.** Everything owed for the period just ended
/// is banked as shares before anything moves, which is what makes "you earn on what you held while
/// you held it" exact rather than approximate — and, for a holder crossing upwards, is what stops
/// them collecting on income that accrued while they were below the line.
///
/// `eligibleSupply` is adjusted by replacing this holder's contribution rather than by the delta,
/// because a crossing moves their **whole** balance in or out, not the amount that was transferred.
/// @param holder The holder.
/// @param balance What they hold now.
function _reprice(address holder, uint256 balance) private {
_settle(holder);
// Read before the write: this is what they were earning on over the period just settled.
uint256 wasEarning = eligibleBalanceOf(holder);
uint256 nowEarning = balance >= minEligibleBalance ? balance : 0;
trackedBalanceOf[holder] = balance;
if (nowEarning != wasEarning) eligibleSupply = eligibleSupply - wasEarning + nowEarning;
if (nowEarning == 0) _unregister(holder);
else _register(holder);
}
/// @notice Pays the next `count` holders in the rotation whatever they are owed.
/// @dev **This is the whole of the automatic distribution, and it is meant to be called by the hook.**
/// Paying every holder is O(holders) and can never fit in one transaction, so the rotation pays a
/// few at a time and comes round again. Each holder is reached once per full pass; how long a pass
/// takes is the holder count divided by `count`, which is why `count` belongs to the caller.
///
/// **It never reverts on anything a holder controls.** A payment that fails is skipped and its
/// shares are restored, exactly as in `distributeTo`. That matters far more here than there: this
/// runs inside a swap, so a holder able to force a revert would be able to stop the pool trading.
/// The caller must *also* bound the gas it forwards — see `FeeHook`, which does both — because a
/// recipient that burns gas rather than reverting cannot be caught from in here.
///
/// **A pass that cannot afford its work reverts, and that is the point.** An earlier version
/// stopped early when gas ran low and recorded how far it got, which reads like graceful
/// degradation and is in fact a hole: `eth_estimateGas` binary-searches for the cheapest limit that
/// succeeds, and a run that pays nobody succeeds. Every wallet therefore converged on the limit
/// that skipped distribution entirely, the swap looked perfectly ordinary, and the tax piled up
/// untouched. Measured: an estimated swap used 206,315 gas and paid nobody; the same swap given
/// room used 383,167 and emptied the vault.
///
/// Reverting removes the cheap path, so an estimate has to cover the work or the swap does not go
/// through at all. Nothing is lost by the revert: the caller sends again with more gas.
///
/// **A pass is not a snapshot and does not have to be fair to the wei.** `_holders` is maintained
/// by swap-and-pop, so a holder leaving mid-pass can move an unvisited holder into a slot already
/// walked, and that holder waits for the next pass. Nothing is lost by waiting: shares keep
/// accruing, and `claim` is always open.
/// @param count How many holders to walk. Every one of them is walked: a pass that cannot afford the
/// work reverts rather than doing less of it. See the note above on why.
/// @return paidCount How many of them were actually paid.
function distributeNext(uint256 count) external nonReentrant returns (uint256 paidCount) {
_sync();
uint256 n = _holders.length;
if (n == 0 || count == 0) return 0;
if (count > n) count = n;
uint256 at = cursor;
if (at >= n) at = 0;
uint256 from = at;
for (uint256 i; i < count; ++i) {
if (at >= n) at = 0;
if (_pay(_holders[at])) {
unchecked {
++paidCount;
}
}
unchecked {
++at;
}
}
if (at >= n) at = 0;
cursor = at;
emit Rotated(from, at, paidCount);
// Fold in anything that arrived mid-pass, and clamp `reserved` back onto the real balance if a
// quote with an unusual transfer debited the vault more than it was asked to send.
_sync();
}
/// @notice Pays one holder if they are owed enough to be worth the gas.
/// @dev Shares are burned before the transfer and restored in full if it does not land, so a holder who
/// cannot receive the quote today loses nothing and is simply reached again next pass.
/// @param holder The holder.
/// @return paid Whether they were paid.
function _pay(address holder) private returns (bool paid) {
_settle(holder);
uint256 shares = settledShares[holder];
uint256 supplyOfShares = totalShares;
if (shares == 0 || supplyOfShares == 0) return false;
uint256 pool = reserved;
uint256 amount = Math.mulDiv(shares, pool, supplyOfShares);
// Below a millionth of the pool the transfer costs more than it moves. The holder keeps the shares.
// Written as a division so that no multiplication can overflow on an absurdly large quote supply;
// when the pool is smaller than `DUST_FRACTION` the floor truncates to zero and nothing is skipped.
if (amount == 0 || amount < pool / DUST_FRACTION) return false;
settledShares[holder] = 0;
totalShares = supplyOfShares - shares;
reserved = pool - amount;
if (_tryPay(holder, amount)) {
emit Paid(holder, shares, amount);
return true;
}
settledShares[holder] = shares;
totalShares = supplyOfShares;
reserved = pool;
emit PaymentSkipped(holder, shares);
return false;
}
/// @notice Puts a holder into the payout rotation, if they are not already in it.
/// @param holder The holder.
function _register(address holder) private {
if (_holderSlot[holder] != 0) return;
_holders.push(holder);
_holderSlot[holder] = _holders.length;
emit Registration(holder, true);
}
/// @notice Takes a holder out of the payout rotation by swapping the last entry into their slot.
/// @param holder The holder.
function _unregister(address holder) private {
uint256 slotPlusOne = _holderSlot[holder];
if (slotPlusOne == 0) return;
uint256 slot = slotPlusOne - 1;
uint256 last = _holders.length - 1;
if (slot != last) {
address moved = _holders[last];
_holders[slot] = moved;
_holderSlot[moved] = slotPlusOne;
}
_holders.pop();
delete _holderSlot[holder];
emit Registration(holder, false);
}
/// @notice How many holders are in the payout rotation.
/// @return The count.
function holderCount() external view returns (uint256) {
return _holders.length;
}
/// @notice The holder at a position in the rotation.
/// @param index The position.
/// @return The holder.
function holderAt(uint256 index) external view returns (address) {
return _holders[index];
}
/// @notice Pays every holder in `holders` whatever they are owed. Anyone may call it.
/// @dev **Permissionless, and best-effort by design.** A distribution bot is a convenience, not a
/// dependency: `claim` covers the case where nobody runs one, and this function being open covers
/// the case where the bot's key is lost. Nothing here can be aimed at a holder to their detriment,
/// since the only thing it can do to them is send them money they already own.
///
/// **A payment that reverts is skipped, and the holder keeps their shares.** The quote may be a
/// token with a blocklist, and the holder may be a contract with a reverting fallback or no
/// fallback at all. Under a naive loop, one such address in a batch of two hundred reverts the whole
/// transaction, and it keeps reverting: the batch can never be paid and the bot has no way to learn
/// which entry is poisoned except by bisecting it. Here the transfer goes through an external
/// self-call so that its revert can be caught, the holder's shares are restored exactly as they
/// were, a `PaymentSkipped` is emitted for the operator to look at, and the loop continues. **The
/// skipped holder loses nothing** — their shares are still outstanding, still earning, and still
/// claimable the moment whatever blocked them stops blocking them.
///
/// Effects land before the transfer and are rolled back in the `catch`, rather than the transfer
/// landing first, so that the ordering is still checks-effects-interactions for anybody auditing it
/// without reasoning about the reentrancy guard.
///
/// The one thing a caller must size is the batch. A recipient that burns gas rather than reverting
/// can consume 63/64 of whatever is forwarded, so a batch large enough to run out of gas fails as a
/// whole. Batches of a few dozen are safe; batches of thousands are not.
/// @param holders The holders to pay. Duplicates are harmless — the second occurrence is owed nothing.
/// @return paidCount How many of them were actually paid.
function distributeTo(address[] calldata holders) external nonReentrant returns (uint256 paidCount) {
_sync();
for (uint256 i; i < holders.length; ++i) {
address holder = holders[i];
_settle(holder);
uint256 shares = settledShares[holder];
uint256 supplyOfShares = totalShares;
if (shares == 0 || supplyOfShares == 0) continue;
uint256 amount = Math.mulDiv(shares, reserved, supplyOfShares);
if (amount == 0) continue;
settledShares[holder] = 0;
totalShares = supplyOfShares - shares;
reserved -= amount;
if (_tryPay(holder, amount)) {
unchecked {
++paidCount;
}
emit Paid(holder, shares, amount);
} else {
settledShares[holder] = shares;
totalShares = supplyOfShares;
reserved += amount;
emit PaymentSkipped(holder, shares);
}
}
// Fold in anything that arrived mid-batch, and clamp `reserved` back onto the real balance if a
// quote with an unusual transfer debited the vault more than it was asked to send.
_sync();
}
/// @notice Claims everything the caller is owed.
/// @dev **The reason the push path is allowed to be best-effort.** Every holder can always pay
/// themselves, so no bot, no operator and no funded relayer sits between a holder and their money.
/// Unlike `distributeTo` this does not swallow a failed transfer: a caller who cannot receive the
/// quote should be told so, not silently no-opped.
/// @return amount The quote tokens sent.
function claim() external nonReentrant returns (uint256 amount) {
_sync();
_settle(msg.sender);
uint256 shares = settledShares[msg.sender];
uint256 supplyOfShares = totalShares;
if (shares == 0 || supplyOfShares == 0) revert NothingOwed();
amount = Math.mulDiv(shares, reserved, supplyOfShares);
if (amount == 0) revert NothingOwed();
settledShares[msg.sender] = 0;
totalShares = supplyOfShares - shares;
reserved -= amount;
quote.safeTransfer(msg.sender, amount);
emit Paid(msg.sender, shares, amount);
_sync();
}
/// @notice What a holder would receive right now.
/// @dev Includes both the accrual since their last settlement and income sitting in the vault that no
/// transaction has folded in yet, so a front end reading this never shows a number that is stale by
/// one distribution. It shares `_preview` with `_sync`, which is deliberate: **a view that computed
/// the pending income by its own copy of the formula would drift from the one that pays out**, and
/// the drift would show up as a claim that pays a different number than the interface promised.
/// @param holder The holder.
/// @return amount The quote tokens they would be sent by `claim` or `distributeTo`.
function owed(address holder) public view returns (uint256 amount) {
(uint256 acc, uint256 supplyOfShares, uint256 pool) = _preview();
if (supplyOfShares == 0) return 0;
return Math.mulDiv(_sharesAt(holder, acc), pool, supplyOfShares);
}
/// @notice A holder's live share balance, including unsettled accrual and unfolded income.
/// @param holder The holder.
/// @return shares Their shares.
function sharesOf(address holder) external view returns (uint256 shares) {
(uint256 acc,,) = _preview();
return _sharesAt(holder, acc);
}
/// @notice Quote tokens sitting in the vault that no call has folded into the accumulator yet.
/// @dev Non-zero between a revenue transfer and the next state-changing call. Also non-zero, and stays
/// that way, whenever `eligibleSupply` is zero — there is nobody to credit, so the income waits
/// rather than being handed to whoever happens to hold shares from an earlier round.
/// @return income The unfolded balance.
function pendingIncome() external view returns (uint256 income) {
(uint256 balance, bool ok) = _readQuoteBalance();
if (!ok || balance <= reserved) return 0;
return balance - reserved;
}
/// @notice What `accSharesPerToken`, `totalShares` and `reserved` would be once pending income is folded
/// in, without folding it in.
/// @dev Exposed because the three stored values are stale by design between a revenue transfer and the
/// next state-changing call, so an indexer — or an invariant that sums `sharesOf` and needs a
/// denominator measured at the same instant — reading them raw would compare two different moments.
/// @return acc The accumulator after the fold.
/// @return shareSupply `totalShares` after the fold.
/// @return pool `reserved` after the fold.
function preview() external view returns (uint256 acc, uint256 shareSupply, uint256 pool) {
return _preview();
}
/// @notice Every address excluded from earning.
/// @return accounts The exclusion list, in the order it was built.
function exclusions() external view returns (address[] memory accounts) {
return _exclusions;
}
/// @notice Folds any income the vault is holding into the accumulator.
/// @dev Called at the top of every state-changing entry point and again at the bottom of the paying
/// ones. Writes exactly what `_preview` computed, so the view and the mutation can never disagree.
function _sync() private {
uint256 poolBefore = reserved;
uint256 sharesBefore = totalShares;
(uint256 acc, uint256 supplyOfShares, uint256 pool) = _preview();
if (acc != accSharesPerToken) {
accSharesPerToken = acc;
totalShares = supplyOfShares;
emit Synced(pool - poolBefore, supplyOfShares - sharesBefore, acc);
}
if (pool != poolBefore) reserved = pool;
}
/// @notice Computes what a sync would produce, without writing anything.
/// @dev The four early returns are the four reasons not to mint shares, and each of them leaves
/// `reserved` alone so that the income is still there to be minted later:
///
/// - the balance read failed, so the vault has no idea what it holds and must not guess;
/// - the balance fell, which is a rebase or a payout: **no shares are minted and none are burned,
/// so every holder's claim moves by the same ratio and the vault cannot go insolvent**;
/// - `eligibleSupply` is zero, so there is nobody to credit. Advancing `reserved` here would hand
/// the income to whoever holds shares from an earlier round, or, if none do, strand it;
/// - the income is too small to move a `SHARE_PRECISION`-scaled accumulator. Leaving `reserved`
/// behind lets dust accumulate until it is worth something instead of being rounded away one
/// wei at a time.
///
/// `newShares` is the income priced at the current share price, `income * totalShares / reserved`,
/// which keeps existing shares worth exactly what they were worth a moment ago. The two degenerate
/// cases — no shares outstanding, or a pool that a rebase took to zero — seed the price at one
/// share per token-wei against the whole balance, which is also what recovers income that arrived
/// while `eligibleSupply` was zero.
///
/// **`totalShares` grows by the full `newShares`, not by `accDelta * eligibleSupply / PRECISION`,
/// and the difference between those two is the whole solvency argument.** The second figure is the
/// smaller one, and crediting it looks strictly better: the truncated remainder would stay in the
/// vault as unshared balance and lift what every outstanding share is worth, rather than sitting
/// there as shares nobody holds. It is also wrong, and the invariant suite finds it in a few dozen
/// calls. Holders settle *lazily*: a holder who does not touch the vault for ten distributions
/// truncates once, over the whole span, while `totalShares` would have truncated ten times, once
/// per distribution. With more distributions than holders the share supply loses more to rounding
/// than the holders do, `Σ sharesOf` climbs past `totalShares`, and the last claim reverts — the
/// exact failure the share denomination exists to remove, reintroduced one wei at a time. Growing
/// by `newShares` puts the rounding error on the side that can absorb it: at most one share-wei
/// per sync is left outstanding against nobody, which lowers every claim by a rounding error and
/// can never overdraw the vault.
/// @return acc The accumulator after the fold.
/// @return supplyOfShares `totalShares` after the fold.
/// @return pool `reserved` after the fold.
function _preview() private view returns (uint256 acc, uint256 supplyOfShares, uint256 pool) {
acc = accSharesPerToken;
supplyOfShares = totalShares;
pool = reserved;
(uint256 balance, bool ok) = _readQuoteBalance();
if (!ok) return (acc, supplyOfShares, pool);
if (balance < pool) return (acc, supplyOfShares, balance);
if (balance == pool) return (acc, supplyOfShares, pool);
uint256 supply = eligibleSupply;
if (supply == 0) return (acc, supplyOfShares, pool);
uint256 newShares =
(supplyOfShares == 0 || pool == 0) ? balance : Math.mulDiv(balance - pool, supplyOfShares, pool);
uint256 accDelta = Math.mulDiv(newShares, SHARE_PRECISION, supply);
if (accDelta == 0) return (acc, supplyOfShares, pool);
return (acc + accDelta, supplyOfShares + newShares, balance);
}
/// @notice Moves a holder's accrual since their last checkpoint into their settled share balance.
/// @dev Excluded addresses reach this with an `eligibleBalanceOf` of zero and accrue nothing, so they
/// need no special case here.
/// @param holder The holder.
function _settle(address holder) private {
uint256 acc = accSharesPerToken;
uint256 checkpoint = accCheckpointOf[holder];
if (acc == checkpoint) return;
uint256 balance = eligibleBalanceOf(holder);
if (balance != 0) settledShares[holder] += Math.mulDiv(balance, acc - checkpoint, SHARE_PRECISION);
accCheckpointOf[holder] = acc;
}
/// @notice A holder's shares against a given accumulator value, without writing.
/// @param holder The holder.
/// @param acc The accumulator to measure against.
/// @return shares Settled plus unsettled shares.
function _sharesAt(address holder, uint256 acc) private view returns (uint256 shares) {
shares = settledShares[holder];
uint256 checkpoint = accCheckpointOf[holder];
uint256 balance = eligibleBalanceOf(holder);
if (balance != 0 && acc > checkpoint) shares += Math.mulDiv(balance, acc - checkpoint, SHARE_PRECISION);
}
/// @notice Attempts a payout and reports whether it landed, without trusting the quote to behave.
/// @dev **Hand-written rather than `try this.payout(...)`, for two reasons that both end in a stuck
/// pool now that this runs inside every swap.**
///
/// A high-level call copies the callee's entire return buffer into memory before anything looks at
/// it. A quote token that returns a megabyte — a returndata bomb — makes that copy cost more gas
/// than the swap has, and under the 63/64 rule the caller dies with it. `catch` does not help: the
/// cost is paid on the way back, before the `catch` is reached. Capping the copy at one word makes
/// this O(1) in whatever the quote decides to return.
///
/// And `abi.decode(ret, (bool))` reverts on its own if the return is shorter than a word or is not
/// a clean bool, which turns a merely malformed token into a reverting one.
///
/// Paid means: the call succeeded, and the token either returned nothing at all (the USDT shape) or
/// returned at least a word whose first word is non-zero. Anything else is treated as unpaid, the
/// caller restores the holder's shares, and the holder is reached again next pass.
///
/// **What this deliberately does not defend against is a quote that burns all the gas it is given
/// rather than reverting.** A stipend here would silently skip every holder the day the quote's
/// transfer legitimately got more expensive. The bound belongs one level up, where `FeeHook` caps
/// what it lends the whole distribution — see `DISTRIBUTION_GAS_BUDGET`.
/// @param to The recipient.
/// @param amount How much.
/// @return ok Whether the transfer succeeded.
function _tryPay(address to, uint256 amount) private returns (bool ok) {
bytes memory request = abi.encodeCall(IERC20.transfer, (to, amount));
address target = address(quote);
assembly ("memory-safe") {
// Copy at most one word of whatever comes back, into scratch space.
let success := call(gas(), target, 0, add(request, 0x20), mload(request), 0x00, 0x20)
let size := returndatasize()
ok := and(success, or(iszero(size), and(iszero(lt(size, 32)), iszero(iszero(mload(0x00))))))
}
}
/// @notice Reads the vault's quote balance, reporting failure instead of propagating it.
/// @dev **A reverting `balanceOf` must not be able to freeze the launch token.** `onBalanceChange` runs
/// inside the token's `_update`, so anything that reverts in here reverts every transfer, every
/// swap and every liquidity operation on the pool, permanently and with no way to switch the vault
/// off. The quote is whatever the launcher chose and may be pausable, upgradeable, or replaced by a
/// proxy pointing at nothing. A `staticcall` decoded by hand covers all of it, including the case
/// where the quote has no code at all and returns success with an empty buffer, which a plain
/// `try`/`catch` would not catch because the failure is in decoding rather than in the call.
/// A failed read simply skips the fold; the income is still there for the next call to find.
/// @return held The vault's quote balance.
/// @return ok Whether the read produced a usable answer.
function _readQuoteBalance() private view returns (uint256 held, bool ok) {
bytes memory request = abi.encodeCall(IERC20.balanceOf, (address(this)));
address target = address(quote);
// Capped at one word for the same reason as `_tryPay`: a quote that returns a megabyte would
// otherwise make reading its own balance cost more gas than the swap this runs inside has left.
// Named `held` rather than `balance` because `balance` is a Yul builtin and cannot be assigned to.
assembly ("memory-safe") {
let success := staticcall(gas(), target, add(request, 0x20), mload(request), 0x00, 0x20)
if and(success, iszero(lt(returndatasize(), 32))) {
held := mload(0x00)
ok := 1
}
}
}
/// @notice Adds an address to the exclusion list, if it is not already on it.
/// @param account The address.
function _exclude(address account) private {
if (isExcluded[account]) return;
isExcluded[account] = true;
_exclusions.push(account);
emit Exclusion(account);
}
}