src/blendfi/libraries/BlendfiConstants.sol
// SPDX-License-Identifier: GPL-2.0-or-later
pragma solidity 0.8.36;
/// @title BlendfiConstants
/// @notice Every number the 2026-08-09 feature addition introduces, in one file
/// (`docs/plan-2026-08-09.md` §2.1). No other file under `src/blendfi/` may contain a
/// bare numeric literal for any of these quantities, exactly as
/// `IndexConstants.sol` forbids it under `src/index/`.
///
/// @dev **WHY THIS FILE EXISTS, IN THE OWNER'S WORDS.**
///
/// > *"make it so that it's easily editable in case I do not like something or
/// > wanna change the specific variable, make it from twenty four hours to forty
/// > eight hours or whatever… before the final deployment, if there are any
/// > changes, we'll fix it."*
///
/// So the contract this file signs is narrow and total: **changing any number in
/// this feature is a ONE-LINE EDIT IN THIS FILE plus a recompile.** Nothing else.
/// Not a second literal in a deploy script, not a mirrored copy in the frontend
/// that someone forgets, not a migration. `docs/DIALS.md` is the page the owner
/// reads; `scripts/emit-constants.mjs` is what keeps every off-chain mirror
/// honest against this file.
///
/// **AND "CONFIGURABLE" MEANS CHEAP TO CHANGE BEFORE DEPLOY — NOT CHANGEABLE
/// AFTER.** Every symbol below is `constant`. There is no storage here, no setter,
/// no owner, and no timelock, because adding one would repeal the written law at
/// `IndexFactory.sol` (*"New features ship as a new factory version and leave
/// existing funds untouched; there are no proxies anywhere"*). A dial the owner can
/// turn after launch is a dial an attacker can turn after a key compromise, and the
/// whole reason this product is credible is that nobody — including us — can move a
/// number under a holder who already deposited. Retuning after deploy means
/// deploying a new contract and letting users migrate in the open. That is the cost,
/// it is deliberate, and `docs/DIALS.md` states it in plain words.
///
/// **DELIBERATELY ADDRESS-FREE**, for the same reason `IndexConstants` is: the
/// treasury, the team wallet, the token, the pool manager and the V3 factory are
/// *constructor immutables* on the contracts that need them — testable,
/// chain-portable, and one fewer redeploy reason. A constant address is a constant
/// that is wrong on every chain but one.
///
/// **EVERY DIAL CARRIES ITS OWN HARD BOUND, DECLARED DIRECTLY BENEATH IT.** The
/// bounds are not policy and are not tunable: they are the guard rail that makes a
/// mis-typed dial *undeployable*. `BlendfiConstantsGuard.validate()` checks every one
/// of them and must be called from the constructor of every contract that reads
/// this file — see that file's NatSpec, which is written at the next agent.
///
/// **A ONE-SIDED BOUND IS A CLAIM, NOT AN OVERSIGHT.** Several dials below are
/// bounded in one direction only. Every one of those is a judgement that was made
/// and is written down at the dial: either the missing direction cannot be reached
/// by an edit anyone would plausibly make, or it is already caught by a different
/// rule, or it is a way the system earns less rather than a way it breaks. Where
/// the missing direction turned out to be reachable, silent AND permanent, the
/// bound is there instead — `MIN_TEAM_ALLOCATION_BPS`, `MAX_LP_FEE_TIER`,
/// `MIN_LOCK_TIER_WEIGHT`, `MIN_LOCK_DISCOUNT_BPS`, `MAX_LOCK_TIER_READ_GAS_CAP`.
/// Do not add bounds to the rest for symmetry: a bound that cannot be violated is
/// noise in a file whose whole job is that every line means something, and a bound
/// that forbids a retune the owner might legitimately want is worse than none,
/// because it is discovered at deploy time by someone who cannot change it.
///
/// ⚠ **WHAT IS NOT HERE: THE FUND FEE-TIER TABLE.** `docs/feature-2026-08-09.md` §3
/// specifies a per-fund 1% / 2% / 3% ladder with a per-row split. Those are FEE
/// numbers for the *index product*, and `IndexConstants`'s own fee-block header
/// mandates that *"THIS BLOCK IS THE ONE PLACE ANY FEE NUMBER LIVES"*. Putting them here would
/// create a second home for an existing dial and split the fee story across two
/// files — precisely the failure `emit-constants.mjs` exists to detect. That half of
/// this note is still true, and it is why the table is still not below.
///
/// ⚠ **THE OTHER HALF SAID THE TABLE WAS BLOCKED WORK, AND IT SAID SO UNTIL
/// 2026-08-14 ABOUT A TABLE THAT HAD ALREADY SHIPPED.** It read, verbatim: *"they
/// are **Wave 2** work, and Wave 2 is blocked on the owner's answer to plan defect
/// **D-1** (the ladder breaches `MAX_CREATOR_FEE_SHARE_BPS = 5000`, so the factory
/// carrying it would not deploy)"*. All three of those claims are dead:
///
/// · 🪦 **THE TABLE SHIPPED ON 2026-08-09 AND WAS DELETED ON 2026-08-29**
/// *(ADR-0023, owner brief `docs/prompts/08-creator-fee-rework.md`: "remove the
/// old mechanism of one person, two person, three person")*. `MAX_FEE_TIER`,
/// `FEE_TIER_0/1/2_BPS` and `FEE_TIER_0/1/2_CREATOR_SHARE_BPS` no longer exist
/// in `IndexConstants` or anywhere else. What lives there now is ONE flat
/// `JOIN_FEE_BPS = 100` charged both directions, ONE flat
/// `CREATOR_SHARE_BPS = 6000` governing every stream, and the creator's single
/// per-fund choice — a MAINTENANCE RENT picked from
/// `RENT_MENU_0/1/2/3_BPS_PER_YEAR = 0 / 180 / 600 / 1200`. Still not here,
/// still for the reason above.
///
/// 🔴 **ALWAYS NAME THE SIDE, AND TREAT THE SOLIDITY AS THE ONLY AUTHORITY.**
/// The lesson outlived the ladder that taught it. `docs/feature-2026-08-09.md`
/// §3 states the split from the PLATFORM's end (a "4000" where the constant
/// holds "6000"), and a reader who copies that figure into `CREATOR_SHARE_BPS`
/// writes a number *below* `MAX_CREATOR_FEE_SHARE_BPS` — so the guard passes,
/// the factory deploys, and every creator's share is silently 40% instead of
/// 60%. The bounded direction cannot be typo'd this way; the unbounded one needs
/// the label rather than the check.
/// · **D-1 IS RESOLVED**, by ADR-0020, in favour of raising the cap rather than
/// flattening the ladder.
/// · **THE CAP IS 6000, NOT 5000** (`IndexConstants.MAX_CREATOR_FEE_SHARE_BPS`),
/// and the creator's share is bounded a second time at runtime, at 7600, by
/// `IndexConstants.MAX_RUNTIME_CREATOR_FEE_SHARE_BPS` once a lock discount
/// applies.
/// This same file already said "**6000** today" in the tier-discount block far
/// below, so until 2026-08-14 it contradicted itself across five hundred lines
/// and the wrong half was the one a reader met first.
library BlendfiConstants {
/*//////////////////////////////////////////////////////////////
PLATFORM TOKEN
(feature §2 · checklist A1, A2 · owner-decision 2026-08-09)
🔧 SUPPLY IS NOT A DIAL. It is a promise printed on the product: 1,000,000,000
BLENDFI, 18 decimals, minted once in the constructor, no mint function, no burn
function, no owner, no pause. Every cost table the owner was shown
(feature §4.4 — "$150 at T1, $2,250 at T4 at a $2M cap") divides by this exact
number, so changing it silently invalidates a document the owner has already
made decisions from. It is declared here so it has ONE home, not because it is
expected to move.
The team allocation IS a dial, and it is the one number in this block that a
buyer will look up before buying. It is capped from ABOVE for the obvious
reason and for one less obvious one: the launch plan (§2.4) puts ~90% of supply
into two pools, so an allocation past the cap does not just look bad, it starves
the pools it is subtracted from.
IT IS ALSO FLOORED, AND THAT DIRECTION IS THE UNFIXABLE ONE. Everything else in
this file that "earns less" when it is set too low can be corrected by deploying
a replacement: a zero hook fee is a new hook, a wrong LP tier is a new pool. The
team allocation cannot. Supply is minted ONCE, in the token's constructor, with
no mint function and no owner — so an allocation typed as `0` (or as a digit
short) deploys cleanly, transfers the team nothing, and there is no transaction
on any chain that can ever put it right. It is the one under-set dial in this
library with no remedy at all, which is why it is the one that gets a floor.
//////////////////////////////////////////////////////////////*/
/// @notice Total BLENDFI supply, minted once at deploy and fixed forever.
/// 1,000,000,000 × 10¹⁸.
/// @dev ⚠ NOT `TUNE-BEFORE-DEPLOY` in the ordinary sense — see the block note. The
/// token has **no transfer tax** (feature §2.2): a skim on transfer makes
/// `UniswapV3Pool.swap`'s `balanceBefore + amountIn <= balanceAfter` check fail
/// and **every sell on the V3 pool reverts**. Our cut is taken by the V4 hook
/// and by the 1% LP tier, never by the token.
///
/// NO CEILING, ON PURPOSE. The only over-set failure is exceeding the `uint128`
/// `Position.amount` field in `BlendfiLock` — 3.4e38, eleven orders of magnitude
/// past a one-extra-zero typo on `1e27` — and `BlendfiLock` already refuses that
/// loudly, per position, with `AmountTooLarge`. A bound no plausible edit can
/// reach is noise.
uint256 internal constant BLENDFI_TOTAL_SUPPLY = 1_000_000_000e18;
/// @notice BLENDFI's decimals. 18, like every ERC-20 the routers, the pools and the
/// frontend already assume.
/// @dev Stated as a constant rather than left implicit because `BLENDFI_TOTAL_SUPPLY`
/// above and every `LOCK_TIER*_WEIGHT` below are written in whole tokens ×
/// 10¹⁸; if this ever changed, all five of those literals change with it and
/// a reader needs to see them in the same file to notice.
uint8 internal constant BLENDFI_DECIMALS = 18;
/// @notice The team's share of supply, transferred **directly at deploy, with no
/// vesting contract and no cliff** *(owner-decision 2026-08-09, feature §2)*.
/// 1000 bps == 10%.
/// @dev TUNE-BEFORE-DEPLOY. The remainder (90%) is what seeds the two pools.
///
/// ⚠ The "no vesting" half is an owner decision recorded here so that nobody
/// later reads a 10% allocation and assumes a vesting contract exists to hold
/// it. There is none. The allocation is a plain `transfer` in the deploy
/// script, and the deploy script is the only place it happens.
uint16 internal constant BLENDFI_TEAM_ALLOCATION_BPS = 1000;
/// @notice Ceiling on `BLENDFI_TEAM_ALLOCATION_BPS`. 2000 bps == 20% of supply.
/// @dev Twice the shipped value, which is the widest band any retune could plausibly
/// want, and far below the point where the allocation stops being an allocation
/// and starts being the float. Above this a "fixed supply, 90% in the pool"
/// launch is no longer the product described in feature §2 — the deploy should
/// fail rather than quietly ship a different token.
uint16 internal constant MAX_TEAM_ALLOCATION_BPS = 2000;
/// @notice **Floor** under `BLENDFI_TEAM_ALLOCATION_BPS`. 100 bps == 1% of supply.
/// @dev Deliberately a tenth of the shipped 10% rather than half of it: 1%–20% is a
/// band wide enough for any allocation policy the owner could still arrive at
/// (5%, 3%, 2% all clear it), so this floor forbids no retune anyone would
/// actually want. What it refuses is the *degenerate* setting — `0`, or a value
/// so small the allocation is not an allocation. 1% of supply is 10,000,000
/// BLENDFI, ~$20,000 at the $2M cap feature §4.4 computes from; below that the line
/// cannot be a number someone chose, only a number someone lost.
///
/// ⚠ THIS IS THE ONLY "EARNS LESS" DIRECTION IN THIS FILE THAT GETS A BOUND, and
/// the exception is justified by irreversibility, not by size — see the block
/// note above. `validateHook()`'s NatSpec states the general rule (bounds
/// correspond to ways the system BREAKS, not to ways it earns less) and this is
/// the one dial where earning nothing and being permanently broken are the same
/// event.
uint16 internal constant MIN_TEAM_ALLOCATION_BPS = 100;
/*//////////////////////////////////////////////////////////////
REWARD REGISTRY
(ADR-0025 · the jackpot's eligibility threshold)
🔧 THE JACKPOT IS PAID BY HOLDING, NOT BY LOCKING. `BlendfiToken` keeps an
on-chain list of every wallet whose balance is at or above the threshold below,
and `BlendfiJackpot` pays that list pro rata. This is the reward model The Index
runs live on this same chain (`minShareBalance()` reads exactly 10,000 INDEX at
`0x56910D44…9870`, verified 2026-08-31 — see
`docs/evidence/index-jackpot-research-2026-08-31/VERIFIED.md`), ported with two
corrections: our setters emit events, and ours reconcile the registry instead of
leaving it stale until the holder's next transfer.
⚠ THE THRESHOLD IS SETTABLE, AND THAT IS EXACTLY WHY IT IS BOUNDED HERE. A
threshold the registry admin could raise without limit is a switch that empties
the eligible set, and an empty set sends the whole jackpot to the treasury by
ADR-0025's own empty-epoch rule. The ceiling below is what stops that being a
one-transaction confiscation. It is the only guard the token has against its own
admin, so treat it as a money dial, not a comfort bound.
//////////////////////////////////////////////////////////////*/
/// @notice The jackpot's eligibility bar at deploy: hold this much BLENDFI and the
/// registry carries you. 10,000 tokens — 0.001% of a 1,000,000,000 supply.
/// @dev TUNE-BEFORE-DEPLOY, and settable afterwards by the registry admin within
/// `[MIN_SHARE_BALANCE_FLOOR, MAX_SHARE_BALANCE_CEILING]`.
///
/// The figure is The Index's, deliberately and to the token: their live
/// `minShareBalance()` is `1e22` and their registry holds 3,666 wallets
/// against a 1e27 supply. Matching it is not imitation for its own sake —
/// it is the one number in this design with a public, running precedent at
/// our exact supply and decimals, so it comes with a measured answer to the
/// only question that matters ("how many wallets does a payout loop have to
/// walk?") instead of a guess.
uint256 internal constant BLENDFI_MIN_SHARE_BALANCE = 10_000e18;
/// @notice **Floor** under the settable threshold. One whole BLENDFI.
/// @dev Below one token the registry stops being a holder list and becomes a dust
/// list: every airdrop remnant and every rounding wei enters it, `holderCount`
/// grows without bound, and the jackpot's paginated payout — which is O(holders)
/// in gas and paid by our keeper — becomes unpayable. Zero is the degenerate
/// case of that and is refused by the same bound.
///
/// It is NOT a claim that one token is a sensible bar. It is the point below
/// which the distribution mechanism itself stops working.
uint256 internal constant MIN_SHARE_BALANCE_FLOOR = 1e18;
/// @notice **Ceiling** over the settable threshold. 1,000,000 BLENDFI — 0.1% of supply,
/// and 100× the shipped 10,000.
/// @dev 🔴 THIS IS THE BOUND THAT MAKES THE ADMIN ROLE SAFE TO EXIST, and it is worth
/// being explicit about the attack it refuses.
///
/// The registry admin can raise the bar. Raise it far enough and every wallet
/// falls below it, `totalEligible` goes to zero, and ADR-0025's empty-epoch rule
/// — inherited from AUDIT-MAX AM-10, and correct on its own terms — routes that
/// epoch's whole user pot to the treasury. So an unbounded threshold is not a
/// mis-tuning risk, it is a **redirect-the-revenue-stream key** wearing a
/// parameter's clothes, and it would be reachable in one transaction with no
/// notice and no event anyone would read in time.
///
/// 100× the shipped value is wide enough for any honest retune (10k → 50k →
/// 250k are all reachable if the holder count ever makes the payout loop
/// expensive) and narrow enough that the degenerate setting is unreachable:
/// at 1,000,000 BLENDFI, a wallet holding 0.1% of supply still qualifies, so
/// the eligible set cannot be emptied by this dial at all.
///
/// ⚠ Do not raise this ceiling to "leave room". The room is the attack.
uint256 internal constant MAX_SHARE_BALANCE_CEILING = 1_000_000e18;
/// @notice Ceiling on how many registry entries one paginated call may touch.
/// @dev The three jackpot passes are all caller-bounded (`max` argument), but a caller
/// that passes `type(uint256).max` should meet a named revert rather than an
/// out-of-gas half-way through a state machine.
///
/// **MEASURED, not estimated** (`BlendfiJackpotAudit2::test_L1`, 1,000 wallets):
///
/// | pass | gas / wallet | 2,000-entry page |
/// |----------|-------------:|-----------------:|
/// | snapshot | ~48,800 | ~97.6 M |
/// | tally | ~3,200 | ~6.4 M |
/// | payout | ~39,800 | ~79.6 M |
///
/// ⚠ An earlier version of this comment guessed "~22k, comfortably inside a
/// block". Both halves were wrong: the real cost is more than twice that, and
/// Robinhood 4663's block gas limit is `2**50` — effectively unbounded, so a
/// block is not the constraint at all. **The real constraint is the keeper's
/// ETH**: ~97.6 M gas at the chain's ~0.043 gwei is ~0.0042 ETH for a full
/// 2,000-wallet snapshot page, and a weekly cycle over the reference's live
/// 3,666-wallet registry costs roughly 0.015 ETH all in.
///
/// The payout figure is pessimistic: it is dominated by the 25,000-gas
/// account-creation charge for paying an address that has never held ETH, which
/// is a one-off per wallet and not a steady-state cost.
uint256 internal constant MAX_REGISTRY_PAGE = 2000;
/*//////////////////////////////////////////////////////////////
JACKPOT — THE LOCK BONUS LADDER
(ADR-0025 §"locked coins count" · owner, 2026-08-31)
🔴 THESE FOUR NUMBERS ARE NOT `LOCK_MULTIPLIER_*_BPS`, AND THE WHOLE POINT OF
THIS BLOCK IS THAT THEY ARE A SEPARATE LADDER.
The owner's decision of 2026-08-31 has two halves and the second is the one that
gets lost. **First half:** locked BLENDFI must count toward the jackpot, and count
for more than the same coins sitting in a wallet — because until that day locking
REMOVED a wallet from the roll (the coins move into `BlendfiLock`, so the wallet
balance the registry reads falls to zero), which meant locking could make a user
strictly worse off. **Second half, added the same day after the first draft used
the manager ladder:** the bonus must be SMALL.
The jackpot's whole promise is *"just hold — no lock, no stake, no registration"*,
and that promise is what makes it a different product from the manager tier ladder
rather than a second front-end onto it. At the manager ladder's 4.0× an 8-week
locker would out-weigh an identical plain holder four to one, the roll would be
dominated by lockers within one cycle, and the jackpot would quietly become a lock
product with a holding option. At 1.5× the same locker is nudged, not enthroned.
⚠ THE BENEFITS STACK AND THIS IS THE THIRD ONE, NOT THE MAIN ONE. A locker already
gets the manager tier (`LOCK_TIER*_WEIGHT` → `IndexVault`'s creator split) and
`BlendfiFeeShare`'s weekly tier-weighted drop. This ladder is a nudge on top of
both; sizing it like a headline benefit would triple-count the same commitment.
🔧 EXPRESSED IN BPS OF 1×, against the same `BPS_DENOMINATOR` every other bps
figure in this file uses, so `weight = amount × BONUS_BPS / 10_000` is the one
expression that serves all four slots. Bounded below at 1.0× and above at 2.0× by
`BlendfiConstantsGuard.validateJackpotLockBonus`, and both bounds are load-bearing:
**below 1.0× locking would PENALISE the locker, which is the exact bug this ladder
exists to fix**, and above 2.0× the swamping problem the owner rejected returns.
⚠ WHERE THEY ARE READ: `BlendfiJackpot._lockBonusBpsFor`, and nowhere else.
`BlendfiLock` does not know these numbers exist and must not learn them — it is the
manager ladder's contract and the two economics must be independently retunable.
//////////////////////////////////////////////////////////////*/
/// @notice Jackpot bonus on BLENDFI locked in the `LOCK_DURATION_14D` slot.
/// 11_000 bps == **1.1×** — 10,000 locked for 2 weeks counts as 11,000.
/// @dev TUNE-BEFORE-DEPLOY. Owner, 2026-08-31.
uint16 internal constant JACKPOT_LOCK_BONUS_14D_BPS = 11_000;
/// @notice Jackpot bonus on the `LOCK_DURATION_28D` slot. 12_000 bps == **1.2×**.
/// @dev TUNE-BEFORE-DEPLOY. Owner, 2026-08-31.
uint16 internal constant JACKPOT_LOCK_BONUS_28D_BPS = 12_000;
/// @notice Jackpot bonus on the `LOCK_DURATION_42D` slot. 13_000 bps == **1.3×**.
/// @dev TUNE-BEFORE-DEPLOY. Owner, 2026-08-31.
uint16 internal constant JACKPOT_LOCK_BONUS_42D_BPS = 13_000;
/// @notice Jackpot bonus on the longest slot, `LOCK_DURATION_56D`.
/// 15_000 bps == **1.5×**.
/// @dev TUNE-BEFORE-DEPLOY. Owner, 2026-08-31. The ladder steps 0.1× a slot and
/// then 0.2× at the top, which is the owner's shape rather than a derived
/// one — an 8-week commitment is more than one slot better than a 6-week one.
uint16 internal constant JACKPOT_LOCK_BONUS_56D_BPS = 15_000;
/// @notice **Floor** under every jackpot lock bonus. 10_000 bps == 1.0×.
/// @dev 🔴 NOT A TIDINESS BOUND. A bonus below 1.0× would make locked coins count
/// for LESS than the same coins in a wallet — locking would penalise the
/// locker, which is precisely the defect ADR-0025's 2026-08-31 amendment
/// exists to remove. Numerically equal to `IndexConstants.BPS_DENOMINATOR`,
/// and the guard asserts that identity rather than trusting it, so a retune of
/// the denominator cannot leave a stale floor quietly permitting sub-1×.
uint16 internal constant MIN_JACKPOT_LOCK_BONUS_BPS = 10_000;
/// @notice **Ceiling** over every jackpot lock bonus. 20_000 bps == 2.0×.
/// @dev 🔴 THE ANTI-SWAMPING BOUND. The owner rejected a 4.0× ladder by name: at
/// a large enough bonus the locked cohort out-weighs every plain holder and
/// the jackpot stops being *"just hold"* and becomes a second lock product.
/// 2.0× is a third above the shipped top rung — room for an honest retune —
/// and half the manager ladder's 4.0×, so the two can never be quietly
/// reconciled by raising this one.
///
/// ⚠ Do not raise this to "leave room". The room is the problem it bounds.
uint16 internal constant MAX_JACKPOT_LOCK_BONUS_BPS = 20_000;
/*//////////////////////////////////////////////////////////////
FEE HOOK
(feature §1.3, §2.1 · checklist A10–A12)
🔧 THE ONE FEE IN THIS SYSTEM THAT CANNOT BE DILUTED. Everything else we earn on
the token is an LP fee, split pro-rata with anyone else who supplies liquidity
(feature §1.5.1). The hook's cut is taken from the swap itself, before the pool
math, and goes straight to an IMMUTABLE treasury — so it is charged on every
swap, through every router, no matter who owns the liquidity. An attacker who
owned 99% of the pool would still receive none of it (§2.5).
That is exactly why it is capped hard. A hook fee is a tax the swapper cannot
opt out of and cannot see in the quote; there is no owner to turn it down after
deploy and no proxy to patch, so the only moment it can be wrong is now.
//////////////////////////////////////////////////////////////*/
/// @notice `BlendfiFeeHook`'s cut of every swap on the hooked V4 pool, **each way**.
/// 100 bps == 1% on the buy and 1% on the sell.
/// @dev TUNE-BEFORE-DEPLOY. Deliberately a third of the 300 bps The Index charges
/// on the same chain (`IndexFeeHook.FEE_BPS = 300`, verified source) — our
/// token's utility is the fee discount in `BlendfiLock`, not a reflection yield,
/// so it does not have to buy attention with a 3% tax.
///
/// ⚠ IT STACKS WITH `LP_FEE_TIER`. A trader on the hooked pool pays 1% to
/// liquidity providers AND this 1% to the treasury — two separate, independent
/// fees, which is the whole finding of feature §1.4. Anyone reasoning about
/// "our fee" must add both, and anyone quoting slippage to a user must too.
///
/// NO FLOOR, ON PURPOSE, AND IT IS WORTH SAYING WHY THIS ONE AND NOT
/// `BLENDFI_TEAM_ALLOCATION_BPS`. A zero hook fee is coherent — the pool works, we
/// simply earn nothing but the LP tier — and, decisively, it is **recoverable**:
/// a hook is a separate contract, so shipping the wrong fee costs a redeploy and
/// a pool migration, not the product. A zero team allocation is not recoverable
/// by anything, because supply is minted once. Same shape of typo, opposite
/// remedy, which is the whole reason one is floored and the other is not.
uint16 internal constant HOOK_FEE_BPS = 100;
/// @notice Ceiling on `HOOK_FEE_BPS`. 300 bps == 3% each way.
/// @dev Pinned to the highest rate any comparable live hook on this chain charges
/// (The Index's 300), so the cap is an observed market ceiling rather than an
/// invented one. It is also the same number as `IndexConstants.MAX_JOIN_FEE_BPS`
/// / `MAX_EXIT_FEE_BPS`, which keeps one answer to "how much can this protocol
/// ever take in one hop" instead of two.
uint16 internal constant MAX_HOOK_FEE_BPS = 300;
/*//////////////////////////////////////////////////////////////
POOL SEEDING
(feature §1.4, §1.5.1, §2.7 · checklist A3, A9, A13)
🔧 THESE TWO ARE ASSERTED BY THE DEPLOY SCRIPTS, NOT BY A LIVE CONTRACT. No
contract reads them at runtime — a pool's fee tier is fixed by the Uniswap
factory at `createPool`, and after that it is not ours to change. They live here
anyway, and that is a deliberate exception to "constants live where they are
read": the owner asked for ONE place to find every number in this feature, and a
dial that only appears inside a script is a dial nobody finds. The deploy script
reads them from here and the guard bounds them from here, so a wrong tier fails
at `forge script` rather than at `createPool`, where it would be permanent.
Note the direction: the fee tier is FLOORED first. Checklist A9 is absolute —
*"Never seed any pool below the 1% tier"* — because a 0.3% pool caps LP income
at a third of what the same liquidity earns at 1%, forever, and a pool cannot be
retiered. The mistake that floor catches is the "safe-looking" one: reaching for
the familiar 3000/500 tier because that is what most pairs use.
⚠ IT IS ALSO CAPPED, AND THE CAP EXISTS FOR V4 ALONE. On V3 the tier is not a
free number: `UniswapV3Factory` only accepts a tier its own `feeAmountTickSpacing`
table has enabled, so a nonsense value there REVERTS at `createPool` — loud, and
before anything exists. **V4 has no such table.** `initialize` accepts any static
fee below `LPFeeLibrary.MAX_LP_FEE` (1_000_000 == 100%), so `100_000` — one extra
zero on the line below — creates a real, permanent, 10%-fee pool that nobody will
ever trade against, and the tokens seeded into it are locked in the lock vault
forever. That is the whole cap: it is not there to stop us charging more, it is
there to stop a typo from being a pool.
AND THE TWO ARE CHECKED AS A PAIR, not one at a time. Uniswap assigns every fee
tier at or above 0.05% a tick spacing of exactly `fee / 50` (500→10, 3000→60,
10_000→200), and on V3 the factory DERIVES the spacing from the tier — so a
mismatched pair is not a wrong pool, it is our script disagreeing with a pool that
already exists. On V4 the pair is free and a non-canonical spacing produces a pool
at the right price that no other router expects to find. Tying the spacing to the
tier means the two dials move together or not at all.
//////////////////////////////////////////////////////////////*/
/// @notice The Uniswap fee tier every pool we seed must use. `10_000` == the **1%
/// tier**, on V3 and V4 alike.
/// @dev TUNE-BEFORE-DEPLOY, upward only (see `MIN_LP_FEE_TIER`). Verified identical
/// to The Index's live Index/WETH V3 pool and Index/ETH V4 pool on Robinhood
/// 4663 (feature §1.4, §1.5) — roughly two-thirds of their ~$15,700/day comes
/// from this tier, not from their hook.
uint24 internal constant LP_FEE_TIER = 10_000;
/// @notice **Floor** on `LP_FEE_TIER`. 10_000 == 1%. Checklist A9: never seed below it.
/// @dev A floor rather than a ceiling, and the only floor-shaped bound in this
/// block, because the cheap direction is the harmful one. Raising the tier
/// above 1% only makes our pool less competitive and shows up immediately in
/// volume; lowering it below 1% silently costs us two-thirds of the LP income
/// for the life of the pool, and there is no `setFeeTier` on a Uniswap pool.
uint24 internal constant MIN_LP_FEE_TIER = 10_000;
/// @notice **Ceiling** on `LP_FEE_TIER`. 30_000 == 3%.
/// @dev The same 3% as `MAX_HOOK_FEE_BPS`, on purpose: "the most a trader can be
/// charged in one hop by any dial in this file" should have ONE answer, not two
/// that a reader has to add up and compare. It is also the number that catches
/// the realistic edit mistake — `10_000` grown a digit to `100_000` — which V3
/// would reject loudly and V4 would accept silently and permanently (see the
/// block note).
///
/// Note what the pair of bounds leaves legal: 1% (shipped) through 3%. On V3
/// only 10_000 is actually reachable, because it is the only enabled tier in
/// that band and the factory refuses the rest — so on V3 this cap is inert and
/// the floor does all the work. It earns its place on V4.
uint24 internal constant MAX_LP_FEE_TIER = 30_000;
/// @notice Uniswap's own fee-to-tick-spacing ratio for every tier at or above 0.05%: a
/// tier's canonical spacing is `fee / 50`. 500→10, 3000→60, 10_000→200.
/// @dev ⚠ NOT A DIAL — a fact about Uniswap, recorded here because the guard asserts
/// it and a bare `50` inside the guard would be a magic number with no home. It
/// is exact for every tier the floor above admits, which is what makes the pair
/// check expressible as arithmetic instead of a four-row lookup table. (The
/// 0.01% tier breaks the ratio — 100/1, not 100/2 — and is the one tier this
/// file can never select, because `MIN_LP_FEE_TIER` refuses it.)
uint24 internal constant CANONICAL_TICK_SPACING_DIVISOR = 50;
/// @notice Tick spacing paired with `LP_FEE_TIER`. 200 is Uniswap's canonical spacing
/// for the 1% tier, on V3 (fixed by the factory) and V4 (chosen per pool).
/// @dev Not independently tunable, and the guard now enforces that rather than
/// asking for it: it must equal `LP_FEE_TIER / CANONICAL_TICK_SPACING_DIVISOR`.
/// On V3 the factory *derives* it from the fee tier, so a mismatch here means
/// the deploy script and the chain disagree about a pool that already exists.
/// On V4 it IS a free parameter and a non-canonical value produces a pool with
/// the right price that no other router expects to find. Verified 200 (`0xC8`)
/// on The Index's live V4 `Initialize` event (feature §1.4).
///
/// THAT PAIRING IS ALSO THIS DIAL'S CEILING, and it is why there is no
/// `MAX_LP_TICK_SPACING`. A standalone cap would have to be invented; the
/// pairing is derived, and it is strictly stronger — V4's own ceiling is 32_767,
/// a spacing at which one tick is a 26× price move, so a bound anywhere near it
/// would be a bound in name only. Retune the tier and the spacing follows, or
/// the deploy refuses.
///
/// `int24` because that is Uniswap's own type for it, all the way through
/// `PoolKey` and `createPool`. The guard refuses a non-positive value first:
/// zero or negative makes `initialize` revert, and it would revert *after* the
/// salt mining and the approvals, which is the expensive place to find out.
int24 internal constant LP_TICK_SPACING = 200;
/*//////////////////////////////////////////////////////////////
LP LOCK VAULT SPLIT
(checklist A8 · plan defect D-2 · feature §2.6)
🔧 THIS IS THE NUMBER THAT MAKES THE LAUNCH NOT A RUG. The seeded liquidity is
locked in a vault that holds the LP position forever; only the FEES it accrues
are ever withdrawable, and `collect()` is permissionless so anyone can push them
out to the two hardcoded destinations. Nobody — including us — can pull the
principal back.
⚠ THE SPEC CITED THE WRONG CONTRACT, AND THIS IS THE CORRECTED VALUE (plan
defect D-2). Checklist A8 and feature §13 pin the split at `CREATOR_BPS = 7000 /
TREASURY_BPS = 3000`, read off `MigrationLockVault.sol`. That file is real but it
is not what is deployed on the chain we are targeting: `4663-v3-uni.json` deploys
**`migrationLockVault5050`** (`0x39aa5CB4…6f38`) with `"feeSplit": {"creatorBps":
5000, "treasuryBps": 5000}`. The mechanical properties A8 actually cares about —
immutable treasury, hardcoded `constant` split, permissionless `collect`, no
admin override — are identical in both, so A8's design survives intact; only the
two numbers change, and they are dials here rather than a blind copy of either
file.
The two halves MUST sum to `IndexConstants.BPS_DENOMINATOR`. That is not style:
a split summing to less silently strands the remainder in the vault with no
function that can move it, and a split summing to more makes the second transfer
revert on every `collect()` — the fees accrue and can never be taken out. The
guard asserts the sum, so either mistake makes the vault undeployable.
NEITHER SIDE HAS ITS OWN FLOOR, AND THAT IS DELIBERATE. A 10_000/0 split is
legal here — one destination would receive nothing, forever, from an LP position
nobody can unlock — but it is not *reachable by a mistake*: the sum rule means
every single-line edit that lowers one side already fails the deploy, so the
only path to a degenerate split is editing BOTH lines to agree. That is a
decision, not a typo, and a bound cannot tell the two apart. Adding floors here
would buy nothing the sum rule does not already buy and would forbid a
legitimate 70/30 or 90/10 retune.
//////////////////////////////////////////////////////////////*/
/// @notice The creator side of every fee the locked LP position accrues. 5000 bps ==
/// 50%.
/// @dev TUNE-BEFORE-DEPLOY, **in lockstep with `LP_LOCK_TREASURY_BPS`** — the guard
/// asserts the pair sums to 10_000, so editing one alone makes the deploy
/// revert rather than stranding or double-spending the fees.
uint16 internal constant LP_LOCK_CREATOR_BPS = 5000;
/// @notice The treasury side of the same fees. 5000 bps == 50%.
/// @dev TUNE-BEFORE-DEPLOY, in lockstep with `LP_LOCK_CREATOR_BPS`. The treasury
/// address itself is an **immutable constructor argument**, never a constant —
/// see the address-free note in this library's header.
uint16 internal constant LP_LOCK_TREASURY_BPS = 5000;
/*//////////////////////////////////////////////////////////////
LOCK CONTRACT — DURATION SLOTS
(feature §4.1, §4.3 · checklist C1, C2)
🔧 FOUR OFFERED SLOTS, NOT A FREE-FORM DURATION. A user picks one of these four;
anything else is refused. Discrete slots are what let `tierOf()` stay a pure
lookup on the money path (feature §4.7) instead of an interpolation, and they are
what make the cost tables in §4.3/§4.4 — the tables the owner has already made
decisions from — exact rather than illustrative.
⚠ THREE LADDERS IN THIS PRODUCT ARE ALL CALLED "TIERS" AND NO TWO OF THEM ARE THE
SAME LADDER. Read all three before writing the word anywhere near this file:
· **DURATION slots** — the four below (14/28/42/56 days, each carrying a
multiplier; the `_14D/_28D/_42D/_56D` suffixes state the slot's own length, so
the name cannot disagree with the value — they were `_3M/_6M/_9M/_12M` until
AM-02 on 2026-08-31, three sessions after the durations were retuned from
months to weeks on the owner's word, 2026-08-26, PRODUCTION-READINESS.md §2.2).
They decide how much *weight* a given number of tokens produces.
· **LOCK tiers** — `0..4`, FIVE rungs, further down: four weight thresholds →
fee discounts. They decide what that weight *buys*. A lock tier is
`lockedAmount × durationMultiplierBps` measured against a weight threshold —
**not a balance and not a share of supply**. Holding every token that will ever
exist, unlocked, is tier 0 forever.
· **RENT MENU steps** — `0..3`, four rungs, 0 / 180 / 600 / 1200 bps PER YEAR
(0 / 0.15 / 0.50 / 1.00 % per month), chosen once at `createFund` and frozen into
the clone's bytecode. They are not in this file and never will be: they live in
`IndexConstants` as `MAX_RENT_MENU_INDEX` and `RENT_MENU_0/1/2/3_BPS_PER_YEAR`,
and this library's header spends a paragraph on why. 🪦 This rung was the
three-row FUND FEE ladder (`0..2`, 100/200/300 bps) from 2026-08-09 to 2026-08-29;
the ladder was deleted by ADR-0023 and the per-fund byte now names a rent step.
A user locking 300,000 tokens for 8 weeks sits in the 12M duration slot and lands
in LOCK tier T2 (300,000 × 4.0× = 1,200,000 weight). What RENT MENU step that user's
fund is on is a third, unrelated number. The names below never say "T" for a
duration and never say "M" for a tier, on purpose.
⚠ THIS BLOCK SAID "TWO LADDERS", AND CALLED THE LOCK LADDER "THE FOUR LOCK TIERS
(T1–T4)", UNTIL 2026-08-14. BOTH ERRORS WERE LOAD-BEARING. It never mentioned the
fund FEE tier at all — which this library's own header discusses — so a reader who
had just read the header arrived here and was told there were two. And the lock
ladder has **FIVE** rungs, not four: `BlendfiLock._tierForWeight` returns `0` for any
weight below `LOCK_TIER1_WEIGHT` (`BlendfiLock.sol`), and
`IBlendfiLock.sol`'s `tierOf` doc pins the return as *"`0` (none) through `4` (T4)"*. Framing
the ladder as T1–T4 is exactly what lets tier 0 be forgotten, and tier 0 is not an
edge case: it is the default, the value every matured position falls back to, and the
fail-OPEN answer the money path substitutes when the read fails
(`IndexVault._cappedTierOf`).
//////////////////////////////////////////////////////////////*/
/// @notice Shortest offered lock: 2 weeks, carrying the 1.0× multiplier.
/// @dev TUNE-BEFORE-DEPLOY. `14 days`, not "2 weeks" spelled as a calendar claim —
/// Solidity has no calendar and a lock measured in seconds is the only kind a
/// contract can enforce. Was `90 days` (3 months) until 2026-08-26 — retuned
/// to weeks on the owner's word, PRODUCTION-READINESS.md §2.2. The name says
/// `_14D` because a name that states its own value cannot go stale silently;
/// it read `_3M` over a 14-day slot for five days before AM-02 renamed it.
uint256 internal constant LOCK_DURATION_14D = 14 days;
/// @notice 4-week lock, 2.0× multiplier.
/// @dev TUNE-BEFORE-DEPLOY. Was `180 days` until 2026-08-26 (owner,
/// PRODUCTION-READINESS.md §2.2).
uint256 internal constant LOCK_DURATION_28D = 28 days;
/// @notice 6-week lock, 3.0× multiplier.
/// @dev TUNE-BEFORE-DEPLOY. Was `270 days` until 2026-08-26 (owner,
/// PRODUCTION-READINESS.md §2.2).
uint256 internal constant LOCK_DURATION_42D = 42 days;
/// @notice Longest offered lock: 8 weeks, carrying the 4.0× multiplier — the row
/// every cost table in feature §4.4 is computed from.
/// @dev TUNE-BEFORE-DEPLOY. Was `365 days` (and matched
/// `IndexConstants.SECONDS_PER_YEAR`) until 2026-08-26 — retuned to weeks on
/// the owner's word, PRODUCTION-READINESS.md §2.2; the year identity no longer
/// holds and nothing asserts it.
uint256 internal constant LOCK_DURATION_56D = 56 days;
/// @notice **Floor** under every duration slot. 7 days.
/// @dev Shorter is the cheap direction, so this is the bound that matters. The
/// multiplier ladder prices *commitment*; a slot short enough to be lockable
/// and unlockable inside a market move prices nothing, and it would let a
/// creator take the T4 discount on a fee stream for the length of a
/// transaction. One week is half of today's shortest slot (2 weeks since the
/// 2026-08-26 retune, owner, PRODUCTION-READINESS.md §2.2), so it still leaves
/// a retune room downward while refusing the degenerate setting.
uint256 internal constant MIN_LOCK_DURATION = 7 days;
/// @notice **Ceiling** over every duration slot. 730 days == 2 years.
/// @dev A ceiling is not paranoia here, it is the only protection that exists.
/// `BlendfiLock` has **no early exit and no penalty path** (checklist C6) and no
/// owner, so a fat-fingered `365 days` → `365 * 52 weeks` would take every
/// locker's tokens for longer than they will care about them, with no function
/// anywhere that can give them back. Two years is thirteen times the longest
/// slot we offer (8 weeks since the 2026-08-26 retune) and decades short of
/// the `uint40 unlockAt` cliff that stores it.
uint256 internal constant MAX_LOCK_DURATION = 730 days;
/*//////////////////////////////////////////////////////////////
LOCK CONTRACT — DURATION MULTIPLIERS
(feature §4.1 · checklist C2)
🔧 EXPRESSED IN BPS OF 1×, SO THEY STAY INTEGERS. 1.5× is not representable in
Solidity and `weight = amount * 3 / 2` is a second place for a rounding rule to
live. `weight = amount * MULTIPLIER_BPS / BPS_DENOMINATOR` is one expression that
serves all four slots, rounds one way, and reads the same as every other bps
computation in this codebase.
Monotonic and floored at 1.0×, both asserted. A non-monotonic ladder means a
longer lock produces LESS weight, which inverts the entire product — and it is a
typo away at all times, because the four numbers differ by one digit.
//////////////////////////////////////////////////////////////*/
/// @notice Multiplier for `LOCK_DURATION_14D` (2 weeks). 10_000 bps == **1.0×** — the
/// reference rung, where weight equals the amount locked.
/// @dev TUNE-BEFORE-DEPLOY.
uint16 internal constant LOCK_MULTIPLIER_14D_BPS = 10_000;
/// @notice Multiplier for `LOCK_DURATION_28D` (4 weeks). 20_000 bps == **2.0×**.
/// @dev TUNE-BEFORE-DEPLOY. Was 15_000 (1.5×) until 2026-08-26 (owner,
/// PRODUCTION-READINESS.md §2.2).
uint16 internal constant LOCK_MULTIPLIER_28D_BPS = 20_000;
/// @notice Multiplier for `LOCK_DURATION_42D` (6 weeks). 30_000 bps == **3.0×**.
/// @dev TUNE-BEFORE-DEPLOY. Was 20_000 (2.0×) until 2026-08-26 (owner,
/// PRODUCTION-READINESS.md §2.2).
uint16 internal constant LOCK_MULTIPLIER_42D_BPS = 30_000;
/// @notice Multiplier for `LOCK_DURATION_56D` (8 weeks). 40_000 bps == **4.0×**.
/// @dev TUNE-BEFORE-DEPLOY. Was 30_000 (3.0×) until 2026-08-26 (owner,
/// PRODUCTION-READINESS.md §2.2). The ladder is linear in weeks —
/// multiplier = weeks ÷ 2 — and this rung is what makes the 8-week row of
/// feature §4.3 cost a quarter of the 2-week row: the "longer lock, fewer
/// tokens needed" trade the product is sold on.
uint16 internal constant LOCK_MULTIPLIER_56D_BPS = 40_000;
/// @notice **Floor** under every multiplier. 10_000 bps == 1.0×.
/// @dev Numerically equal to `IndexConstants.BPS_DENOMINATOR`, and the guard asserts
/// that identity rather than trusting it — a bps floor that drifted from the
/// bps denominator would mean "1.0×" quietly stopped meaning 1.0×. Below 1.0×,
/// locking tokens would produce less weight than holding them, which is not a
/// weaker version of the product, it is a different one.
uint16 internal constant MIN_LOCK_MULTIPLIER_BPS = 10_000;
/// @notice **Ceiling** over every multiplier. 50_000 bps == 5.0×.
/// @dev Weight is the only gate on the fee discount, and the multiplier is the only
/// term in it that is not the user's own money — so an over-large multiplier
/// hands out T4 for a rounding error's worth of tokens and the ladder in
/// feature §4.2 stops filtering anything. 5.0× is past the shipped 4.0×
/// and well inside `uint16`, whose own 65_535 ceiling is a second, silent bound
/// a reader should not have to know about.
uint16 internal constant MAX_LOCK_MULTIPLIER_BPS = 50_000;
/*//////////////////////////////////////////////////////////////
LOCK CONTRACT — TIER WEIGHT THRESHOLDS
(feature §4.2 · checklist C3)
🔧 WEIGHT, NOT TOKENS. `weight = amount × multiplier`, so these four numbers are
denominated in the same 18-decimal units as the token but are NOT a token
balance: 100,000 BLENDFI locked for 8 weeks is 400,000 weight and clears T1.
Feature §4.3 is the full amount-by-duration table and it is derived entirely from
these four numbers and the four multipliers above — nothing else feeds it.
⚠ STRICTLY INCREASING, ASSERTED. `tierOf()` walks these from the top down and
returns the first one cleared. A pair out of order does not revert and does not
look wrong in a diff — it just makes a tier permanently unreachable, and the
users who paid for it never learn why. This is the single most typo-prone block
in the file, which is exactly why the ordering is a deploy-time assertion rather
than a comment asking someone to be careful.
//////////////////////////////////////////////////////////////*/
/// @notice Weight needed for **T1**. Worth a 10% discount — off the platform's cut for a
/// fund creator, off the fee itself for whoever is paying it. 300,000 × 10¹⁸.
/// @dev TUNE-BEFORE-DEPLOY. At a $2M market cap this is the owner's reference row:
/// ~$150 to reach it by locking for 8 weeks at 4.0× (feature §4.4; was ~$200
/// on the 12-month/3.0× ladder until the 2026-08-26 retune).
uint256 internal constant LOCK_TIER1_WEIGHT = 300_000e18;
/// @notice Weight needed for **T2**. Worth a 20% discount — off the platform's cut for a
/// fund creator, off the fee itself for whoever is paying it. 900,000 × 10¹⁸.
/// @dev TUNE-BEFORE-DEPLOY.
uint256 internal constant LOCK_TIER2_WEIGHT = 900_000e18;
/// @notice Weight needed for **T3**. Worth a 30% discount — off the platform's cut for a
/// fund creator, off the fee itself for whoever is paying it. 2,250,000 × 10¹⁸.
/// @dev TUNE-BEFORE-DEPLOY.
uint256 internal constant LOCK_TIER3_WEIGHT = 2_250_000e18;
/// @notice Weight needed for **T4**, the top tier. Worth a 40% discount — off the
/// platform's cut for a fund creator, off the fee itself for whoever is paying
/// it. 4,500,000 × 10¹⁸.
/// @dev TUNE-BEFORE-DEPLOY. ~$2,250 at a $2M cap on an 8-week lock at 4.0× — the
/// other end of the owner's reference row (was ~$3,000 on the 12-month/3.0×
/// ladder until the 2026-08-26 retune).
uint256 internal constant LOCK_TIER4_WEIGHT = 4_500_000e18;
/// @notice **Floor** under the bottom threshold. `1e18` — one whole BLENDFI of weight.
/// @dev THIS FLOOR EXISTS FOR ONE SPECIFIC KEYSTROKE: the dropped `e18`. The four
/// thresholds above are the only numbers in this file written as *whole tokens ×
/// 10¹⁸*, and `300_000e18` typed as `300_000` is a legal `uint256`, a legal
/// Solidity literal, a plausible-looking seven-digit number in a diff — and
/// three hundred thousand *wei* of weight, which every dust locker in existence
/// clears. Every creator would silently sit on T1 (or T4), the fee ladder in
/// feature §4.2 would stop filtering anything, and `BlendfiLock` has no owner to
/// fix it.
///
/// A bare `> 0` check — which is what this replaced — does not catch that at
/// all: `300_000` is emphatically greater than zero. One whole token is the
/// smallest bound that does, and it is a *unit* claim rather than a policy one,
/// so it forbids no retune: a threshold below one token cannot be a quantity
/// anyone chose, only a suffix someone lost.
uint256 internal constant MIN_LOCK_TIER_WEIGHT = 1e18;
/// @notice **Ceiling** over the top threshold: total supply.
/// @dev A threshold above the entire supply is a tier that cannot be reached by
/// locking every token that will ever exist at 1.0×, i.e. a tier that exists
/// only in the UI. Cheap to typo (one extra zero on a 7-digit number), invisible
/// in review, and permanent — `BlendfiLock` has no owner to fix it. Pinned to the
/// supply symbol rather than to a copy of its digits so the two cannot drift.
uint256 internal constant MAX_LOCK_TIER_WEIGHT = BLENDFI_TOTAL_SUPPLY;
/*//////////////////////////////////////////////////////////////
LOCK CONTRACT — TIER FEE DISCOUNTS
(feature §4.2 · plan defect D-1)
🔧 ONE LADDER, TWO CONSUMERS, AND IT REDUCES A DIFFERENT NUMBER IN EACH. Both have
been live on chain since 2026-08-12. `IndexVault`'s header block "ONE LOCK LADDER,
TWO PEOPLE, TWO DIFFERENT EFFECTS" states them in full;
this is the short version, and a reader who takes only one of them away will be
wrong about every number in feature §4.2 by one level of indirection:
· **THE CREATOR'S TIER MOVES THE *SPLIT*.** The platform's own cut falls by
`discountBps` *of itself* and the creator picks up the difference —
`creator' = base + (BPS − base) × discountBps / BPS`. T1 on a 3% fund takes the
platform's 1.20% to 1.08%: 10% of 1.20, not 10% of 3.00. What the joiner pays
does not move by one wei. That is `IndexVault._effectiveCreatorShareBps`
(`IndexVault._effectiveCreatorShareBps`), reached from `creatorFeeShareBps()` (`IndexVault.creatorFeeShareBps`), the
mode-B exit (`IndexVault._exit`) and `_creditFees` (`IndexVault._creditFees`).
· **THE PAYER'S TIER MOVES THE *FEE ITSELF*.** The same rows, read against
whoever is actually being charged, taken straight off the fee —
`charged = fee × (BPS − discountBps) / BPS`. A T1 joiner pays 90% of the fee
they would otherwise pay, to the wei; a T4 joiner pays 60%. That is
`IndexVault._afterLockDiscount` (`IndexVault._afterLockDiscount`), read at join against `recipient`
(`IndexVault._join`), at exit against `holder` (`IndexVault._exitFee`) and in the preview against
`msg.sender` (`IndexVault.previewJoin`).
The two compose and neither reads the other, so a locked user in a locked creator's
fund pays a smaller fee of which the creator keeps a larger share.
`IndexVault._lockDiscountBpsOf` (`IndexVault._lockDiscountBpsOf`) is the one place either half reads
this ladder.
⚠ THIS BLOCK READ *"A REDUCTION OF THE PLATFORM'S CUT, IN BPS OF ITSELF. **Not of the
fund's fee**, and not of the creator's share"* UNTIL 2026-08-14, AND THE EMPHASISED
HALF WAS FLATLY FALSE. The second consumer takes it off the fund's fee — the exact
thing that sentence forbade — and it had been doing so for two days while this
paragraph was still the place a reader came to learn what the unit meant. Do not
restore it. The one-sentence version, if a screen needs one, is *"a discount worth
`discountBps`; which number it comes off depends on whether you are the fund's
creator or the person paying"*, and `IBlendfiLock.discountBpsFor`'s NatSpec is the
same correction written at a frontend developer.
⚠ THIS BLOCK WAS HALF OF PLAN DEFECT D-1, AND IT IS LIVE NOW — not "live the moment
Wave 2 lands", which is what this line said until 2026-08-14, in the future tense,
about code that shipped on 2026-08-12. The first consumer above moves value from the
platform's share to the creator's, and the
creator's share is bounded in the factory constructor by
`IndexConstants.MAX_CREATOR_FEE_SHARE_BPS` — **6000** today, enforced by
`IndexFactory._requireRuntimeCreatorCeiling`, which `_validateFeeConstants`
calls from the constructor. With the §3 fee ladder in place the derived runtime
ceiling is `6000 + (4000 × 4000/10_000) = 7600` bps of creator share — which is
why `MAX_LOCK_DISCOUNT_BPS` below must stay 4000 unless that whole derivation is
redone. Raising this one number alone makes the *factory* undeployable, in a file
the editor is not looking at. Read `docs/plan-2026-08-09.md` §1 D-1 before
touching it.
⚠ THAT CROSS-REFERENCE WAS WRONG UNTIL 2026-08-09, IN BOTH HALVES, AND THIS IS THE
ONE FILE WHERE THAT MATTERS MOST. It read "5000 today, `IndexFactory.sol:279-283`":
ADR-0020 had already raised the cap to 6000 and moved the enforcement into a named
function, so a reader who trusted this paragraph would have computed a ceiling of
5000 + 4000×0.4 = 7000 and concluded a discount rise had 600 bps of headroom it does
not have. The warning in the paragraph above is that this block's editor is *not looking at
the other file* — which is precisely why a stale pointer here is worse than a stale
pointer anywhere else, and why the reference is now to a FUNCTION NAME rather than to
a line number that goes out of date on the next unrelated edit.
//////////////////////////////////////////////////////////////*/
/// @notice **T1** discount: 1000 bps (−10%) — off the platform's cut for a fund creator,
/// off the fee itself for whoever is paying it.
/// @dev TUNE-BEFORE-DEPLOY, but see the D-1 warning above.
uint16 internal constant LOCK_TIER1_DISCOUNT_BPS = 1000;
/// @notice **T2** discount: 2000 bps (−20%) — off the platform's cut for a fund creator,
/// off the fee itself for whoever is paying it.
/// @dev TUNE-BEFORE-DEPLOY.
uint16 internal constant LOCK_TIER2_DISCOUNT_BPS = 2000;
/// @notice **T3** discount: 3000 bps (−30%) — off the platform's cut for a fund creator,
/// off the fee itself for whoever is paying it.
/// @dev TUNE-BEFORE-DEPLOY.
uint16 internal constant LOCK_TIER3_DISCOUNT_BPS = 3000;
/// @notice **T4** discount: 4000 bps (−40%) — off the platform's cut for a fund creator,
/// off the fee itself for whoever is paying it. The deepest discount the system
/// can grant.
/// @dev TUNE-BEFORE-DEPLOY. Sits exactly on `MAX_LOCK_DISCOUNT_BPS`, which is
/// deliberate: the cap was derived from this value's effect on the creator
/// share (D-1), so shipping them equal means any increase here trips the guard
/// immediately instead of silently pushing the factory past its own bound.
uint16 internal constant LOCK_TIER4_DISCOUNT_BPS = 4000;
/// @notice **Ceiling** over every tier discount. 4000 bps == a lock is never worth more
/// than 40%, of the platform's cut or of the fee, whichever it is reducing.
/// @dev The number `docs/plan-2026-08-09.md` §1 D-1 recommends introducing alongside
/// raising `MAX_CREATOR_FEE_SHARE_BPS` to 6000, so that the derived worst-case
/// creator share stays provable at 7600 bps rather than unbounded. Without a
/// cap here the lock ladder could hand a creator 100% of every fee the protocol
/// charges, which is not a discount, it is a different business.
///
/// ⚠ IT BOUNDS THE SECOND CONSUMER TOO, AND NOTHING ELSE DOES. On the payer's
/// side `charged = fee × (BPS − discountBps) / BPS`, so a discount of `BPS`
/// would make every fee exactly zero —