Read the machine.
Every contract and script below, with its SHA-256 and its review status. Converging, not final: anything marked under review may still change before launch #1 — the pinned build hash for the reproducible-build ritual lands separately, before the first $AMUSE transaction.
src/Converter.solv0.6 · review signed offe2fcbe83de02fde43870c0f032ab95ee1a7d26d44afc88e75465c89a0a921500
The $AMUSE buy-pressure flywheel: permissionless WETH→$AMUSE→split. Immutable once deployed.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {IERC20} from "./vendor/IERC20.sol";
import {ReentrancyGuard} from "./vendor/ReentrancyGuard.sol";
import {FullMath} from "./vendor/FullMath.sol";
import {
Currency,
PoolKey,
SwapParams,
BalanceDelta,
BalanceDeltaLibrary,
IHooks,
IPoolManager,
IUnlockCallback,
PoolIdLibrary
} from "./vendor/V4.sol";
/// @notice Minimal interface of the Clanker v4 FeeLocker (ClankerFeeLocker v4.0.0,
/// Base 0xF3622742b1E446D92e45E22923Ef11C2fcD55D68 — NOT the LP locker).
/// claim() is all-or-nothing per (feeOwner, token) and reverts on zero (NoFeesToClaim).
interface IFeeLocker {
function availableFees(address feeOwner, address token) external view returns (uint256);
function claim(address feeOwner, address token) external;
}
/// @notice Minimal interface of a Clanker v4 LP locker.
/// collectRewards pulls accrued pool fees into the FeeLocker for `token`'s
/// reward recipients. Callable by anyone; skipped during the MEV window,
/// so quiet pools need this nudge before anything is claimable.
interface ILpLocker {
function collectRewards(address token) external;
}
/// @notice Constructor parameters for Converter (packed to avoid stack-too-deep).
struct DeployParams {
address weth;
/// @dev Clanker FeeLocker (Base v4.0.0: 0xF3622742b1E446D92e45E22923Ef11C2fcD55D68).
address feeLocker;
/// @dev Clanker LP locker that holds the child pools' positions (Base
/// ClankerLpLockerFeeConversion v1.1: 0xffA37784D619F228D8B379d287a4D7282e500762).
/// Pinned so poke() never calls a caller-chosen address from inside the
/// fund-moving path. Other lockers can be nudged by anyone directly.
address lpLocker;
address amuse;
address amuseSplit;
address poolManager;
address currency0;
address currency1;
uint24 fee;
int24 tickSpacing;
address hooks;
uint256 maxPokeWeth;
/// @dev Dust floor: if min(balance, maxPokeWeth) < minPokeWeth, poke() is a
/// no-op that does not consume the window. Stops an attacker burning the
/// interval with 1-wei donations; to burn a window they must donate at
/// least this much, which buys $AMUSE for the split.
uint256 minPokeWeth;
uint256 pokeInterval;
uint256 bountyBps;
/// @dev minOut = spot quote x (10_000 - slippageBps) / 10_000. Must cover the
/// pool's LP fee (+ Clanker's 20% protocol share of it) plus the price impact
/// of a cap-sized buy, or every poke reverts until liquidity deepens.
uint256 slippageBps;
}
/// @title Converter
/// @notice Ownerless, non-upgradeable WETH -> $AMUSE -> $AMUSE-split converter.
/// Every child launch's 1% WETH fee stream accrues to this contract in the
/// Clanker FeeLocker; poke() claims it and market-buys $AMUSE through the
/// pinned $AMUSE/WETH Uniswap v4 pool, forwarding the proceeds to the $AMUSE
/// liquid split. Between pokes the contract holds only WETH it cannot do
/// anything with except the scheduled swap and the caller's WETH bounty.
contract Converter is IUnlockCallback, ReentrancyGuard {
using BalanceDeltaLibrary for BalanceDelta;
using PoolIdLibrary for PoolKey;
// --- immutables ---
IERC20 public immutable WETH;
IFeeLocker public immutable FEE_LOCKER;
ILpLocker public immutable LP_LOCKER;
IERC20 public immutable AMUSE;
address public immutable AMUSE_SPLIT;
IPoolManager public immutable POOL_MANAGER;
Currency public immutable CURRENCY0;
Currency public immutable CURRENCY1;
uint24 public immutable FEE;
int24 public immutable TICK_SPACING;
IHooks public immutable HOOKS;
bool public immutable ZERO_FOR_ONE; // true when WETH == currency0
uint256 public immutable MAX_POKE_WETH;
uint256 public immutable MIN_POKE_WETH;
uint256 public immutable POKE_INTERVAL;
uint256 public immutable BOUNTY_BPS;
/// @notice minOut = spot quote x (10_000 - SLIPPAGE_BPS) / 10_000. Set at
/// construction from the live-pool fee sweep (test/ConverterLive.t.sol).
uint256 public immutable SLIPPAGE_BPS;
// --- state ---
uint256 public lastPoke;
// --- constants ---
uint160 private constant MIN_SQRT_PRICE = 4295128739;
uint160 private constant MAX_SQRT_PRICE = 1461446703485210103287273052203988822378723970342;
/// @dev PoolManager.pools mapping slot (v4-core StateLibrary.POOLS_SLOT).
bytes32 private constant POOLS_SLOT = bytes32(uint256(6));
/// @notice `poke` called before `lastPoke + POKE_INTERVAL`. Checked first so a
/// keeper that is early spends only the check's gas, not a full collect+claim.
error TooSoon();
event Poke(
address indexed caller,
uint256 wethIn,
uint256 amuseOut,
uint256 bounty,
uint256 wethBacklog // WETH left behind for later windows
);
/// @notice Emitted by `sweep` when a nonzero balance is forwarded.
event Sweep(address indexed token, uint256 amount);
constructor(DeployParams memory p) {
require(p.weth != address(0) && p.amuse != address(0) && p.weth != p.amuse, "Converter: bad tokens");
require(p.currency0 < p.currency1, "Converter: unordered key");
require(
(p.currency0 == p.weth || p.currency1 == p.weth)
&& (p.currency0 == p.amuse || p.currency1 == p.amuse),
"Converter: key must be WETH/AMUSE"
);
require(p.maxPokeWeth > 0 && p.pokeInterval > 0, "Converter: bad params");
// the swap delta is int128: a cap above int128.max could never settle
// and would brick every poke at deploy time. Fail fast instead.
require(p.maxPokeWeth <= uint256(uint128(type(int128).max)), "Converter: cap too large");
require(p.minPokeWeth > 0 && p.minPokeWeth <= p.maxPokeWeth, "Converter: bad min poke");
require(p.bountyBps <= 10_000, "Converter: bad bounty");
require(p.slippageBps > 0 && p.slippageBps < 10_000, "Converter: bad slippage");
require(
p.feeLocker != address(0) && p.lpLocker != address(0) && p.amuseSplit != address(0)
&& p.poolManager != address(0),
"Converter: zero address"
);
WETH = IERC20(p.weth);
FEE_LOCKER = IFeeLocker(p.feeLocker);
LP_LOCKER = ILpLocker(p.lpLocker);
AMUSE = IERC20(p.amuse);
AMUSE_SPLIT = p.amuseSplit;
POOL_MANAGER = IPoolManager(p.poolManager);
CURRENCY0 = Currency.wrap(p.currency0);
CURRENCY1 = Currency.wrap(p.currency1);
FEE = p.fee;
TICK_SPACING = p.tickSpacing;
HOOKS = IHooks(p.hooks);
ZERO_FOR_ONE = (p.currency0 == p.weth);
MAX_POKE_WETH = p.maxPokeWeth;
MIN_POKE_WETH = p.minPokeWeth;
POKE_INTERVAL = p.pokeInterval;
BOUNTY_BPS = p.bountyBps;
SLIPPAGE_BPS = p.slippageBps;
// the pinned pool must already exist: a PoolKey typo would strand every
// WETH this contract ever receives (the swap is the only exit).
require(_sqrtPriceX96() != 0, "Converter: pool not initialized");
}
/// @notice Permissionless poke: collect -> claim -> capped swap -> pay out.
/// @param tokens Child token addresses to nudge fee collection for on the
/// pinned LP_LOCKER (batched by the caller; failures are swallowed).
function poke(address[] calldata tokens) external nonReentrant {
// 0. window check first: an early poke must not burn a keeper's gas on
// the collect loop and claim before reverting. Reverts never advance
// lastPoke (state is rolled back); no-op returns below leave it alone too.
if (block.timestamp < lastPoke + POKE_INTERVAL) revert TooSoon();
// 1. nudge fee collection for each child pool (quiet pools skip collection
// during the MEV window). One bad entry must not brick the poke.
for (uint256 i = 0; i < tokens.length; ++i) {
try LP_LOCKER.collectRewards(tokens[i]) {} catch {}
}
// 2. claim the converter's full WETH accrual (all-or-nothing; reverts on
// zero, so check first -> clean no-op path below).
if (FEE_LOCKER.availableFees(address(this), address(WETH)) > 0) {
FEE_LOCKER.claim(address(this), address(WETH));
}
// 2b. forward any $AMUSE already sitting here (direct sends, never the
// swap path) BEFORE the dust no-op below, so stray $AMUSE never waits
// on the WETH balance to reach the floor.
uint256 stray = AMUSE.balanceOf(address(this));
if (stray > 0) {
require(AMUSE.transfer(AMUSE_SPLIT, stray), "Converter: split transfer failed");
}
// 3. all accounting on balance held. Zero balance -> clean no-op that
// does not touch lastPoke.
uint256 bal = WETH.balanceOf(address(this));
// dust floor: below MIN_POKE_WETH the poke is a clean no-op that leaves
// lastPoke untouched (covers the zero-balance case too). Claimed WETH
// simply waits in the contract for a window with a real balance.
if ((bal > MAX_POKE_WETH ? MAX_POKE_WETH : bal) < MIN_POKE_WETH) return;
// 4. size the swap and reserve the caller's WETH bounty from the same
// balance: swap + bounty <= balance. The bounty is on the AMOUNT
// SWAPPED (never on the balance held — bounty hunters can't bleed
// the backlog).
(uint256 swapAmt, uint256 bounty) = _sizeSwap(bal);
if (swapAmt == 0) return; // dust below the reserve floor; interval untouched
lastPoke = block.timestamp;
// 5. capped exact-input swap through the pinned pool.
uint256 minOut = (_quote(swapAmt) * (10_000 - SLIPPAGE_BPS)) / 10_000;
// a zero quote would mean the slot0 read returned nothing (wrong pool /
// layout) and would silently disable the slippage bound — refuse instead.
require(minOut > 0, "Converter: no quote");
uint256 amuseOut = _swap(swapAmt, minOut);
// 6. pay the bounty in WETH, then forward ALL $AMUSE to the split.
// (event first: all values are known, and a later revert rolls the log
// back with everything else.)
emit Poke(msg.sender, swapAmt, amuseOut, bounty, bal - swapAmt - bounty);
if (bounty > 0) {
require(WETH.transfer(msg.sender, bounty), "Converter: bounty failed");
}
uint256 rest = AMUSE.balanceOf(address(this));
if (rest > 0) {
require(AMUSE.transfer(AMUSE_SPLIT, rest), "Converter: split transfer failed");
}
}
/// @notice Permissionless recovery sweep for ERC-20s that are not WETH:
/// misdirected child tokens (e.g. a child's 1% slot set to Both/Clanker
/// instead of Paired), airdrops, stray $AMUSE. Forwards the ENTIRE balance
/// to the immutable $AMUSE split — the only exit. WETH is refused: it is
/// the working asset and leaves only through poke(). ERC-20 only: the
/// contract has no receive(), so an ETH path would be dead code.
/// @dev Shares poke()'s reentrancy guard so a sweep can never interleave
/// with an active swap. Low-level call supports bool-returning and
/// no-return tokens; a reverting/false token fails only its own sweep.
function sweep(address token) external nonReentrant {
require(token != address(WETH), "Converter: no WETH sweep");
uint256 bal = IERC20(token).balanceOf(address(this));
if (bal == 0) return;
(bool ok, bytes memory data) =
token.call(abi.encodeWithSelector(IERC20.transfer.selector, AMUSE_SPLIT, bal));
require(ok && (data.length == 0 || abi.decode(data, (bool))), "Converter: sweep failed");
emit Sweep(token, bal);
}
/// @notice Uniswap v4 unlock callback. Only the PoolManager may call.
function unlockCallback(bytes calldata data) external returns (bytes memory) {
require(msg.sender == address(POOL_MANAGER), "Converter: not PM");
(uint256 amountIn, uint256 minOut) = abi.decode(data, (uint256, uint256));
BalanceDelta delta = POOL_MANAGER.swap(
_poolKey(),
SwapParams({
zeroForOne: ZERO_FOR_ONE,
// forge-lint: disable-next-line(unsafe-typecast) -- amountIn <= MAX_POKE_WETH « int256.max; the 0.8.x cast reverts on overflow instead of truncating
amountSpecified: -int256(amountIn),
sqrtPriceLimitX96: ZERO_FOR_ONE ? MIN_SQRT_PRICE + 1 : MAX_SQRT_PRICE - 1
}),
""
);
uint256 amountOut = _settleAndTake(delta);
require(amountOut >= minOut, "Converter: slippage");
return abi.encode(amountOut);
}
/// @notice Current pool spot price, for keepers/verifiers.
function sqrtPriceX96() external view returns (uint160) {
return _sqrtPriceX96();
}
// --- internals ---
/// @dev Sizes the swap against the balance held and reserves the WETH
/// bounty from the same balance: swap + bounty <= balance. Normally
/// swap = min(balance, cap); when the balance barely covers the cap the
/// swap is shaved so the reserved bounty still fits. (Rounding: with
/// swap = floor(bal*10000/(10000+bps)) we have
/// swap + floor(swap*bps/10000) <= bal.)
function _sizeSwap(uint256 bal) internal view returns (uint256 swapAmt, uint256 bounty) {
swapAmt = bal > MAX_POKE_WETH ? MAX_POKE_WETH : bal;
bounty = (swapAmt * BOUNTY_BPS) / 10_000;
if (swapAmt + bounty > bal) {
swapAmt = (bal * 10_000) / (10_000 + BOUNTY_BPS);
bounty = (swapAmt * BOUNTY_BPS) / 10_000;
}
}
function _poolKey() internal view returns (PoolKey memory) {
return PoolKey({
currency0: CURRENCY0,
currency1: CURRENCY1,
fee: FEE,
tickSpacing: TICK_SPACING,
hooks: HOOKS
});
}
/// @dev Reads pools[poolId].slot0.sqrtPriceX96 via extsload (StateLibrary layout).
function _sqrtPriceX96() internal view returns (uint160) {
bytes32 stateSlot = keccak256(abi.encode(_poolKey().toId(), POOLS_SLOT));
bytes32 data = POOL_MANAGER.extsload(stateSlot);
return uint160(uint256(data)); // sqrtPriceX96 occupies the low 160 bits
}
/// @dev Spot quote for an exact WETH input at the top-of-call price.
/// Two-step mulDiv avoids ever squaring sqrtPriceX96 (uint320 overflow).
function _quote(uint256 amountIn) internal view returns (uint256) {
uint256 sqrtP = uint256(_sqrtPriceX96());
uint256 q;
if (ZERO_FOR_ONE) {
// selling currency0 (WETH): out = in * price, price = sqrtP^2 / 2^192
q = FullMath.mulDiv(amountIn, sqrtP, 1 << 96);
q = FullMath.mulDiv(q, sqrtP, 1 << 96);
} else {
// selling currency1 (WETH): out = in / price
q = FullMath.mulDiv(amountIn, 1 << 96, sqrtP);
q = FullMath.mulDiv(q, 1 << 96, sqrtP);
}
return q;
}
function _swap(uint256 amountIn, uint256 minOut) internal returns (uint256) {
bytes memory ret = POOL_MANAGER.unlock(abi.encode(amountIn, minOut));
return abi.decode(ret, (uint256));
}
function _settleAndTake(BalanceDelta delta) internal returns (uint256 amountOut) {
if (ZERO_FOR_ONE) {
int128 d0 = delta.amount0();
int128 d1 = delta.amount1();
require(d0 < 0 && d1 > 0, "Converter: bad delta");
// forge-lint: disable-next-line(unsafe-typecast) -- d0 < 0 checked above, so -d0 is a positive int128 and the widening casts cannot truncate
_settlePay(CURRENCY0, uint256(uint128(-d0)));
// forge-lint: disable-next-line(unsafe-typecast) -- d1 > 0 checked above, so the widening casts cannot truncate
amountOut = uint256(uint128(d1));
POOL_MANAGER.take(CURRENCY1, address(this), amountOut);
} else {
int128 d0 = delta.amount0();
int128 d1 = delta.amount1();
require(d1 < 0 && d0 > 0, "Converter: bad delta");
// forge-lint: disable-next-line(unsafe-typecast) -- d1 < 0 checked above, so -d1 is a positive int128 and the widening casts cannot truncate
_settlePay(CURRENCY1, uint256(uint128(-d1)));
// forge-lint: disable-next-line(unsafe-typecast) -- d0 > 0 checked above, so the widening casts cannot truncate
amountOut = uint256(uint128(d0));
POOL_MANAGER.take(CURRENCY0, address(this), amountOut);
}
}
/// @dev Pays `amount` of `c` into the PoolManager. `sync` MUST precede the
/// transfer: PoolManager._settle reads the synced currency from transient
/// storage and, if none is synced, treats the settlement as native ETH with
/// `paid = msg.value` (0), leaving the ERC-20 delta open so `unlock` reverts
/// with CurrencyNotSettled (v4-core PoolManager.sol:349-365, :279-288, :112).
function _settlePay(Currency c, uint256 amount) internal {
POOL_MANAGER.sync(c);
require(IERC20(Currency.unwrap(c)).transfer(address(POOL_MANAGER), amount), "Converter: pay failed");
POOL_MANAGER.settle();
}
}
src/AmuseSink.solv0.6 · review signed offfe7dedc857e09617b86b3d05b24d6b9aae69de245273f5576fa10cce15b1718c
Receives converter output and forwards it to the $AMUSE liquid split. No custody.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {IERC20} from "./vendor/IERC20.sol";
/// @title AmuseSink
/// @notice Ownerless, non-upgradeable holder of unallocated child-split ERC-1155 units.
/// A 0xSplits v1 LiquidSplit clone cannot receive ERC-1155 (no onERC1155Received),
/// so unallocated units of every child split are assigned here instead. The sink
/// claims their fee share via the normal SplitMain flow and anyone can sweep the
/// proceeds to the $AMUSE liquid split. One deployment serves the whole system.
contract AmuseSink {
/// @notice The $AMUSE liquid split contract. The only address funds can ever leave to.
address public immutable AMUSE_SPLIT;
constructor(address amuseSplit) {
require(amuseSplit != address(0), "AmuseSink: zero split");
AMUSE_SPLIT = amuseSplit;
}
function onERC1155Received(address, address, uint256, uint256, bytes calldata)
external
pure
returns (bytes4)
{
return this.onERC1155Received.selector;
}
function onERC1155BatchReceived(address, address, uint256[] calldata, uint256[] calldata, bytes calldata)
external
pure
returns (bytes4)
{
return this.onERC1155BatchReceived.selector;
}
receive() external payable {}
/// @notice ERC-165. Cheap compatibility insurance for marketplaces and
/// indexers that probe before interacting.
function supportsInterface(bytes4 interfaceId) external pure returns (bool) {
return interfaceId == 0x01ffc9a7 // ERC165 itself
|| interfaceId == 0x4e2312e0; // ERC1155TokenReceiver
}
/// @notice Permissionless. Forwards the ENTIRE balance of `token`
/// (address(0) = ETH) to the $AMUSE split. The only exit.
/// @dev Zero balance is a no-op (no call made). ERC-20 transfers are made
/// with a low-level call so both bool-returning and no-return tokens work;
/// a returned `false` or a revert fails the sweep. The ETH path relies on
/// the $AMUSE LiquidSplit clone's `receive()` (fork-confirmed: the clone
/// runtime accepts plain ETH and emits ReceiveETH).
function sweep(address token) external {
if (token == address(0)) {
uint256 bal = address(this).balance;
if (bal == 0) return;
(bool ok,) = AMUSE_SPLIT.call{value: bal}("");
require(ok, "AmuseSink: ETH sweep failed");
} else {
uint256 bal = IERC20(token).balanceOf(address(this));
if (bal == 0) return;
(bool ok, bytes memory data) =
token.call(abi.encodeWithSelector(IERC20.transfer.selector, AMUSE_SPLIT, bal));
require(ok && (data.length == 0 || abi.decode(data, (bool))), "AmuseSink: sweep failed");
}
}
}
script/DeployConverter.s.soldeploy script · pushed for review38b49d0a493953a9351dd9dd4feee42b132b0eee3b8321ed2b0cd40d9b39fbcf
The exact launch script: verifies the Clanker token, the split, and the pool, then deploys Converter + AmuseSink. Aborts on anything off-shape.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Script.sol";
import {Converter, DeployParams} from "../src/Converter.sol";
import {AmuseSink} from "../src/AmuseSink.sol";
import {Currency, PoolKey, PoolId, IPoolManager, IHooks} from "../src/vendor/V4.sol";
/// @notice Minimal interfaces for the deploy-time assertions.
interface IClankerFactory {
function enabledLockers(address locker, address hook) external view returns (bool);
/// @dev Returns (token, hook, locker) for tokens deployed by this factory,
/// all zeros for anything else. Verified empirically on the pinned fork.
function deploymentInfoForToken(address token)
external
view
returns (address, address, address);
}
interface IFeeLockerFlags {
function allowedDepositors(address depositor) external view returns (bool);
}
interface ILiquidSplit {
function owner() external view returns (address);
function splitMain() external view returns (address);
function payoutSplit() external view returns (address);
}
interface ILiquidSplitFactory {
function ls1155CloneImpl() external view returns (address);
}
interface ISplitMainAuth {
function getController(address _split) external view returns (address);
}
/// @title Deploy the v0.6 Converter + AmuseSink on Base
/// @notice PRIVATE. Do not publish.
/// @dev Prerequisites (all manual, all human-approved before this runs):
/// 1. The root $AMUSE LiquidSplit is deployed (1,000 units, owner = 0x…dEaD).
/// 2. $AMUSE is launched on Clanker (StaticFeeV2 1%/1%, rewards in Both to the
/// split; tokenAdmin + reward admins = 0x…dEaD).
/// 3. The deployer wallet holds enough Base ETH for two contract deployments.
///
/// Env vars (only two — everything else is pinned or read onchain):
/// AMUSE - the canonical $AMUSE token address (verify the address, never the ticker)
/// AMUSE_SPLIT - the root $AMUSE LiquidSplit *clone* address (never the underlying SplitMain split)
///
/// The PoolKey is NOT human input. It is read from the pinned LP locker's
/// `tokenRewards($AMUSE)` record, cross-checked against the Clanker factory's
/// `deploymentInfoForToken($AMUSE)`, and the pool's initialization is verified
/// before any broadcast. A human-supplied pool key was the previous script's
/// weakest link (its POOL_FEE example was even wrong for Clanker v4 pools,
/// whose PoolKey fee is the dynamic-fee flag 0x800000).
///
/// Economic parameters are constants, not env vars. They are the v0.6 reviewed
/// values; changing them means editing this script, which means a new review.
///
/// Usage:
/// forge script script/DeployConverter.s.sol --rpc-url $BASE_RPC \
/// --broadcast --verify # --verify needs ETHERSCAN_API_KEY (use the BaseScan key)
///
/// Dry run first (no broadcast): drop --broadcast to simulate and inspect the receipt.
///
/// Safety properties:
/// - Every onchain assertion runs BEFORE any deployment. A wrong address, a
/// disabled locker, a mismatched pool key, or an uninitialized pool aborts
/// the script with no state change and no gas spent beyond the simulation.
/// - The Converter constructor independently re-verifies: ordered key,
/// WETH/$AMUSE pair, param bounds, int128 cap, and pool initialization.
/// This script's checks are the human-readable layer on top; the
/// constructor is the last line of defense.
/// - Nothing in this script moves funds, sets allowances, or calls claim/
/// collect. It deploys two ownerless contracts and stops.
/// - The two deployments are separate broadcast transactions, each logged
/// the moment it lands ("step 1/2", "step 2/2"). If the second fails, the
/// first address is already in the log — re-run only the AmuseSink
/// deployment with the same AMUSE_SPLIT.
contract DeployConverter is Script {
// Pinned Base addresses — mainnet values, immutable across deployments.
address internal constant WETH = 0x4200000000000000000000000000000000000006;
address internal constant POOL_MANAGER = 0x498581fF718922c3f8e6A244956aF099B2652b2b;
address internal constant CLANKER_FACTORY = 0xE85A59c628F7d27878ACeB4bf3b35733630083a9; // Clanker v4.0.0
address internal constant FEE_LOCKER = 0xF3622742b1E446D92e45E22923Ef11C2fcD55D68; // ClankerFeeLocker v4.0.0
address internal constant LP_LOCKER = 0xffA37784D619F228D8B379d287a4D7282e500762; // ClankerLpLockerFeeConversion v1.1
address internal constant HOOK_STATIC_V2 = 0xb429d62f8f3bFFb98CdB9569533eA23bF0Ba28CC; // the reviewed hook
address internal constant DEAD = 0x000000000000000000000000000000000000dEaD;
address internal constant LIQUID_SPLIT_FACTORY = 0xdEcd8B99b7F763e16141450DAa5EA414B7994831;
address internal constant SPLIT_MAIN = 0x2ed6c4B5dA6378c7897AC67Ba9e43102Feb694EE;
// Solady clones-with-immutable-args proxy shape for the factory's clones:
// 136 bytes of runtime code, with the implementation address as the PUSH20
// immediate at code offset 65. Pinned by the deployed factory's LibClone
// version, so this shape is immutable for every clone it will ever make.
// Clanker v4 pools carry the dynamic-fee flag in PoolKey.fee even when the
// hook is StaticFeeV2 (the fee lives in the hook). Verified on the fork:
// a real StaticFeeV2 1%/1% deployment initializes the pool at 0x800000.
uint24 internal constant DYNAMIC_FEE_FLAG = 0x800000;
// Runtime codehash of a LiquidSplit clone is NOT pinned, deliberately.
// The factory bakes distributorFee AND block.timestamp into each clone's
// runtime code (clone(abi.encodePacked(_distributorFee, block.timestamp))),
// so every clone has a unique codehash and a codehash pin can never match
// a real split on mainnet. (It only "worked" on forks, where
// block.timestamp is frozen.) authenticateAmuseSplit authenticates
// structurally instead — see its NatSpec.
uint256 internal constant LIQUID_SPLIT_CODE_LENGTH = 136;
uint256 internal constant LIQUID_SPLIT_IMPL_OFFSET = 65;
// PoolManager.pools mapping slot (v4-core StateLibrary.POOLS_SLOT).
bytes32 internal constant POOLS_SLOT = bytes32(uint256(6));
// v0.6 reviewed economic parameters. Constants on purpose: an override
// requires editing this file, which requires a new review.
uint256 internal constant MAX_POKE_WETH = 0.05 ether;
uint256 internal constant MIN_POKE_WETH = 0.002 ether;
uint256 internal constant POKE_INTERVAL = 4 hours;
uint256 internal constant BOUNTY_BPS = 50;
uint256 internal constant SLIPPAGE_BPS = 400;
// The reviewed pool shape is StaticFeeV2 1%/1% at tickSpacing 200. The
// spacing is read from the locker like the rest of the key, then pinned
// here: a pool with any other spacing is not the reviewed configuration,
// and deploying against it must abort rather than silently accept it.
// (A different reviewed spacing means editing this file — same rule as
// the economic constants above.)
int24 internal constant TICK_SPACING_REVIEWED = 200;
/// @notice Runs the full pre-flight check chain, then deploys.
/// @dev Returns the deployed addresses so fork tests can assert the exact
/// launch receipt. `forge script` ignores return values.
function run() external returns (Converter converter, AmuseSink sink) {
// ---- 0. Wrong chain, wrong script. (A fork or replica where the
// pinned contracts happen to exist must never receive a deployment.)
require(block.chainid == 8453, "DEPLOY ABORT: not Base mainnet");
address amuse = vm.envAddress("AMUSE");
address amuseSplit = vm.envAddress("AMUSE_SPLIT");
require(amuse != address(0), "AMUSE unset");
require(amuseSplit != address(0), "AMUSE_SPLIT unset");
require(amuse != WETH, "AMUSE is WETH?");
require(amuse != amuseSplit, "DEPLOY ABORT: AMUSE == AMUSE_SPLIT");
// ---- 1. Authenticate AMUSE_SPLIT (see authenticateAmuseSplit).
// Every future $AMUSE transfer and sink sweep goes here, immutably.
authenticateAmuseSplit(amuseSplit);
// ---- 2. Authenticate AMUSE: it must be a token actually deployed by
// the pinned Clanker factory, with the reviewed hook and locker.
// A copied address + matching pool receipt for some *other* valid
// Clanker token would otherwise sail through every other check and
// leave the immutable Converter buying the wrong token forever.
(address depToken, address depHook, address depLocker) =
IClankerFactory(CLANKER_FACTORY).deploymentInfoForToken(amuse);
require(depToken == amuse, "DEPLOY ABORT: AMUSE was not deployed by the pinned Clanker factory");
require(depHook == HOOK_STATIC_V2, "DEPLOY ABORT: AMUSE was not deployed with the reviewed hook");
require(depLocker == LP_LOCKER, "DEPLOY ABORT: AMUSE was not deployed with the pinned LP locker");
// ---- 3. Read the PoolKey from the locker, don't take it from a human.
PoolKey memory key = _lockerPoolKey(amuse);
(address c0, address c1) = amuse < WETH ? (amuse, WETH) : (WETH, amuse);
require(Currency.unwrap(key.currency0) == c0, "DEPLOY ABORT: locker poolKey currency0 mismatch");
require(Currency.unwrap(key.currency1) == c1, "DEPLOY ABORT: locker poolKey currency1 mismatch");
require(key.fee == DYNAMIC_FEE_FLAG, "DEPLOY ABORT: locker poolKey fee is not the Clanker v4 flag");
require(address(key.hooks) == depHook, "DEPLOY ABORT: locker poolKey hooks mismatch");
require(
key.tickSpacing == TICK_SPACING_REVIEWED,
"DEPLOY ABORT: locker poolKey tickSpacing is not the reviewed 200"
);
// ---- 4. The two flags that can silently change under us.
require(
IClankerFactory(CLANKER_FACTORY).enabledLockers(LP_LOCKER, depHook),
"DEPLOY ABORT: Clanker.enabledLockers(LP_LOCKER, hooks) is false"
);
require(
IFeeLockerFlags(FEE_LOCKER).allowedDepositors(LP_LOCKER),
"DEPLOY ABORT: FeeLocker.allowedDepositors(LP_LOCKER) is false"
);
// ---- 5. Pool initialization, checked BEFORE broadcast (the Converter
// constructor re-checks via the same extsload read; this keeps a bad
// key from costing broadcast gas).
PoolId poolId = PoolId.wrap(keccak256(abi.encode(key)));
bytes32 stateSlot = keccak256(abi.encode(poolId, POOLS_SLOT));
uint160 sqrtPriceX96 = uint160(uint256(IPoolManager(POOL_MANAGER).extsload(stateSlot)));
require(sqrtPriceX96 != 0, "DEPLOY ABORT: pool not initialized");
// ---- 6. Deploy.
DeployParams memory p = DeployParams({
weth: WETH,
feeLocker: FEE_LOCKER,
lpLocker: LP_LOCKER,
amuse: amuse,
amuseSplit: amuseSplit,
poolManager: POOL_MANAGER,
currency0: c0,
currency1: c1,
fee: key.fee,
tickSpacing: key.tickSpacing,
hooks: depHook,
maxPokeWeth: MAX_POKE_WETH,
minPokeWeth: MIN_POKE_WETH,
pokeInterval: POKE_INTERVAL,
bountyBps: BOUNTY_BPS,
slippageBps: SLIPPAGE_BPS
});
vm.startBroadcast();
converter = new Converter(p);
// Partial-deployment visibility: if the AmuseSink deployment below
// fails, this line is already in the log, so the Converter address is
// recoverable without re-running anything. The NatSpec recovery note
// above ("re-run only the AmuseSink deployment") depends on it.
console.log("step 1/2 Converter deployed:", address(converter));
sink = new AmuseSink(amuseSplit);
console.log("step 2/2 AmuseSink deployed:", address(sink));
vm.stopBroadcast();
_printReceipt(converter, sink, p);
}
/// @notice Authenticate AMUSE_SPLIT: a LiquidSplit clone from the pinned
/// factory, wired to the pinned SplitMain, with dead ownership.
/// @dev The factory's clones are clones-with-immutable-args, so a codehash
/// pin can never match a real split on mainnet. Authenticate structurally:
/// 1. 136-byte runtime (the Solady CWIA proxy shape) with the factory's
/// pinned implementation as the PUSH20 target at code offset 65.
/// A genuine clone proxy of the real impl IS a real LiquidSplit.
/// 2. splitMain() is the pinned SplitMain (closes the "hand-rolled proxy
/// with different immutable args" shape).
/// 3. SplitMain.getController(payoutSplit()) is the clone itself (the
/// clone controls its underlying split; closes the "pasted the
/// underlying SplitMain split / a stranger's split" failure modes).
/// 4. owner() is the dead wallet.
/// Checks 1-3 hold for every factory clone regardless of distributorFee or
/// creation timestamp, and fail for an EOA, the underlying SplitMain split,
/// or any other contract. They catch accidental misuse (typo'd / wrong /
/// stale addresses). They do not defend against a deployer deliberately
/// crafting a contract to mimic a clone — the deployer is trusted, and the
/// split address is verified out-of-band before this runs (see header).
/// Public so the fork suite can exercise the abort chain directly.
function authenticateAmuseSplit(address amuseSplit) public view {
bytes memory rt = amuseSplit.code;
require(
rt.length == LIQUID_SPLIT_CODE_LENGTH,
"DEPLOY ABORT: AMUSE_SPLIT is not a 136-byte LiquidSplit clone proxy"
);
address impl;
assembly {
// 32 = length word, then the PUSH20 immediate at code offset 65.
impl := shr(96, mload(add(rt, add(32, LIQUID_SPLIT_IMPL_OFFSET))))
}
require(
impl == ILiquidSplitFactory(LIQUID_SPLIT_FACTORY).ls1155CloneImpl(),
"DEPLOY ABORT: AMUSE_SPLIT implementation is not the pinned LiquidSplit impl"
);
require(
ILiquidSplit(amuseSplit).splitMain() == SPLIT_MAIN,
"DEPLOY ABORT: AMUSE_SPLIT splitMain is not the pinned SplitMain"
);
require(
ISplitMainAuth(SPLIT_MAIN).getController(ILiquidSplit(amuseSplit).payoutSplit())
== amuseSplit,
"DEPLOY ABORT: AMUSE_SPLIT is not the controller of its payout split"
);
require(
ILiquidSplit(amuseSplit).owner() == DEAD,
"DEPLOY ABORT: AMUSE_SPLIT owner is not the dead wallet"
);
}
/// @dev Reads the canonical PoolKey for `token` from the pinned LP locker's
/// `tokenRewards` record. Struct word layout (bytes), verified empirically
/// on the pinned fork against a real Clanker v4 deployment:
/// 0: tuple offset | 32: token | 64: poolKey.currency0 |
/// 96: poolKey.currency1 | 128: poolKey.fee |
/// 160: poolKey.tickSpacing | 192: poolKey.hooks | …
/// The value assertions in run() (currencies, fee flag, hooks) make a
/// layout misread fail loudly instead of deploying against a wrong key.
function _lockerPoolKey(address token) internal view returns (PoolKey memory key) {
(bool ok, bytes memory ret) = LP_LOCKER.staticcall(
abi.encodeWithSelector(bytes4(keccak256("tokenRewards(address)")), token)
);
require(ok && ret.length >= 224, "DEPLOY ABORT: tokenRewards(token) failed");
address currency0;
address currency1;
uint24 fee;
int24 tickSpacing;
address hooks;
assembly {
currency0 := mload(add(ret, 96)) // byte 64
currency1 := mload(add(ret, 128)) // byte 96
fee := mload(add(ret, 160)) // byte 128
tickSpacing := mload(add(ret, 192)) // byte 160
hooks := mload(add(ret, 224)) // byte 192
}
key = PoolKey({
currency0: Currency.wrap(currency0),
currency1: Currency.wrap(currency1),
fee: fee,
tickSpacing: tickSpacing,
hooks: IHooks(hooks)
});
// Sanity: the record must be for the token we asked about.
address recordedToken;
assembly {
recordedToken := mload(add(ret, 64)) // byte 32
}
require(recordedToken == token, "DEPLOY ABORT: locker has no rewards record for AMUSE");
}
function _printReceipt(Converter converter, AmuseSink sink, DeployParams memory p) internal view {
console.log("=== Muse Launch Lite v0.6 deployment receipt ===");
console.log("chain id: ", block.chainid);
console.log("deployer: ", msg.sender);
console.log("Converter: ", address(converter));
console.log("AmuseSink: ", address(sink));
console.log("$AMUSE: ", p.amuse);
console.log("$AMUSE split: ", p.amuseSplit);
console.log("pool currency0: ", p.currency0);
console.log("pool currency1: ", p.currency1);
console.log("pool fee: ", p.fee);
console.log("pool tickSpacing: ", uint256(int256(p.tickSpacing)));
console.log("pool hooks: ", p.hooks);
console.log("MAX_POKE_WETH: ", p.maxPokeWeth);
console.log("MIN_POKE_WETH: ", p.minPokeWeth);
console.log("POKE_INTERVAL: ", p.pokeInterval);
console.log("BOUNTY_BPS: ", p.bountyBps);
console.log("SLIPPAGE_BPS: ", p.slippageBps);
console.log("split codehash ok: true");
console.log("split owner = dead: true");
console.log("factory token/hook: true");
console.log("locker enabled: true");
console.log("depositor allowed: true");
console.log("pool sqrtPriceX96: ", converter.sqrtPriceX96());
console.log("sink ERC-165: ", sink.supportsInterface(0x01ffc9a7));
console.log("sink ERC-1155 recv: ", sink.supportsInterface(0x4e2312e0));
console.log("=== verify on BaseScan, then record above ===");
}
}
src/LauncherDistributorV2.soldistributor v2 · implementation under review6a89039428c33118e032179617a9befc938780a7bbe35c27d2c0db61f59c6f3b
Ownership distributor: bonding-curve drip, global claim budget, weekly pots, treasury sweep. Spec v0.4 reviewed; code awaiting re-review — may change.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {IERC20} from "./vendor/IERC20.sol";
import {IERC1155, IERC1155Receiver} from "./vendor/IERC1155.sol";
import {ILiquidSplit, ISplitMain} from "./vendor/ISplits.sol";
import {ReentrancyGuard} from "./vendor/ReentrancyGuard.sol";
import {FullMath} from "./vendor/FullMath.sol";
import {MerkleProof} from "./vendor/MerkleProof.sol";
import {BudgetIndexCore} from "./BudgetIndexCore.sol";
import {LauncherSpotV2, SpotParams} from "./LauncherSpotV2.sol";
/// @notice Constructor parameters for LauncherDistributorV2 (packed to avoid stack-too-deep).
struct DistributorV2Params {
address protocolSplit;
uint256 splitTokenId;
address weth;
address amuse;
address poolManager;
address currency0;
address currency1;
uint24 fee;
int24 tickSpacing;
address hooks;
/// @dev Sweep guards shared by every spot (see LauncherSpot): per-sweep
/// WETH cap, dust floor, minimum interval between sweeps, and the
/// on-chain slippage tolerance in bps.
uint256 maxSweepWeth;
uint256 minSweepWeth;
uint256 sweepInterval;
uint256 slippageBps;
/// @dev Merkle root over (leafIndex, recipient, amount) leaves summing to
/// 100 - deployerGenesis. Root correctness is the deployer's
/// responsibility; the tree is published offchain for verification.
bytes32 genesisRoot;
/// @dev The deployer's pre-allocated genesis units at split creation
/// (spec §2): 1 <= g <= 10, counting toward the deployer's genesis cap.
uint256 deployerGenesis;
}
/// @title LauncherDistributorV2
/// @notice Holds the protocol liquid-split NFT vault (1,000 units: 100
/// genesis / 800 drip / 100 permanent endowment) and distributes it to
/// launcher operators. Implements DISTRIBUTOR_V2_SPEC.md v0.4:
/// - Drip earning is a bonding curve in WETH: earned = 800*revenue/(revenue+K),
/// K = 100 WETH (floor-ish early rewards, diminishing returns for size).
/// - Pacing is the shared BudgetIndexCore engine: a true Synthetix-style
/// index over remaining entitlement. Every payout is capped at remaining
/// entitlement; aggregate payouts can never exceed the budget.
/// - The distributor's SplitMain fee share is swept permissionlessly into a
/// multi-token treasury over the immutable set {WETH, $AMUSE} (50 bps
/// keeper cut). 1% of the treasury reserve becomes a weekly pot, claimable
/// pro-rata by closed-week revenue share.
/// - Genesis units are claimed permissionlessly against an immutable merkle
/// root. The 100-unit endowment is never distributed.
/// @dev No admin, no epochs, no governance. No upgrade path. Every state
/// transition is permissionless. Same trust model as v1: the only external
/// trust is the pinned $AMUSE/WETH pool and SplitMain.
contract LauncherDistributorV2 is BudgetIndexCore, IERC1155Receiver, ReentrancyGuard {
// --- constants ---
/// @notice Genesis allocation, in integer units (deployerGenesis of them
/// pre-allocated to the deployer at split creation; the merkle tree
/// covers the remaining 100 - deployerGenesis).
uint256 public constant GENESIS_UNITS = 100;
/// @notice Permanent endowment, in integer units. Never distributed; its
/// SplitMain fee share funds the treasury indefinitely.
uint256 public constant ENDOWMENT = 100;
/// @notice Bonding-curve half-point, in WETH wei. Confirmed by the owner
/// 2026-09-21. K sets the dust floor (~0.125 WETH for the first unit) and
/// is the Sybil price of the vault — see the spec §3 Sybil note, stated
/// honestly: it prices ~100 WETH of gross self-funded flow for ~94-98%
/// of the drip if honest launchers are small. The binding Sybil limiter
/// is the 12-month budget plus honest competition for the same budget.
uint256 public constant K = 100 ether;
/// @notice Weekly-pot week length, deploy-anchored.
uint256 public constant WEEK = 7 days;
/// @notice Weekly-pot claim window: a closed week pays for 12 weeks after
/// it closes, then its unclaimed remainder is reclaimed to the reserve.
uint256 public constant CLAIM_WINDOW_WEEKS = 12;
/// @notice Keeper cut on sweepTreasury, in bps. Confirmed by the owner
/// 2026-09-21.
uint256 public constant KEEPER_BPS = 50;
/// @notice Upper bound on weeks _settlePots funds per call (~2 years of
/// missed weeks); any remainder settles on later permissionless calls,
/// still oldest-first.
uint256 private constant MAX_SETTLE_WEEKS = 104;
// --- immutables ---
IERC1155 public immutable PROTOCOL_SPLIT;
uint256 public immutable SPLIT_TOKEN_ID;
ISplitMain public immutable SPLIT_MAIN;
/// @notice The liquid clone's underlying SplitMain split (kept from v1's
/// wiring; v2's treasury path goes through SPLIT_MAIN directly).
address public immutable PAYOUT_SPLIT;
address public immutable WETH;
address public immutable AMUSE;
address public immutable POOL_MANAGER;
address public immutable CURRENCY0;
address public immutable CURRENCY1;
uint24 public immutable FEE;
int24 public immutable TICK_SPACING;
address public immutable HOOKS;
uint256 public immutable MAX_SWEEP_WETH;
uint256 public immutable MIN_SWEEP_WETH;
uint256 public immutable SWEEP_INTERVAL;
uint256 public immutable SLIPPAGE_BPS;
/// @notice Immutable merkle root for genesis claims (§5.2).
bytes32 public immutable GENESIS_ROOT;
/// @notice The deployer's pre-allocated genesis units (spec §2 §9):
/// 1 <= g <= 10, counting toward the deployer's genesis cap.
uint256 public immutable DEPLOYER_GENESIS;
// --- state: drip earning ---
/// @dev Launcher -> their fee spot. One spot per launcher, forever.
mapping(address launcher => address spot) public spots;
/// @dev Cumulative pre-swap WETH the launcher's spot has reported (wei).
mapping(address launcher => uint256 revenue) public revenueOf;
// (earnedOf / claimedOf / totalClaimed live in BudgetIndexCore.)
// --- state: genesis (§5.2) ---
/// @dev Claimed-leaf bitmap: bit (leafIndex & 0xff) of word (leafIndex >> 8).
mapping(uint256 word => uint256 bits) private _genesisClaimedBitmap;
/// @dev Sum of claimed genesis amounts. Always <= 100 - DEPLOYER_GENESIS.
uint256 public genesisClaimedTotal;
// --- state: weekly pot (§7) ---
/// @dev Per-launcher per-week attributed WETH. Written only to the current
/// week, so a closed week's totals are frozen by construction.
mapping(address launcher => mapping(uint256 week => uint256 weth)) public weekRevenue;
/// @dev Per-week total attributed WETH. Immutable once the week ends.
mapping(uint256 week => uint256 weth) public weekTotal;
/// @dev One claim per launcher per week, enforced.
mapping(address launcher => mapping(uint256 week => bool claimed)) public weekClaimed;
/// @dev True once _settlePots has processed the week (even a zero-revenue
/// week, whose pot is simply 0).
mapping(uint256 week => bool funded) public potFunded;
/// @dev Funded pot per week per token, in token wei.
mapping(uint256 week => mapping(address token => uint256 amount)) public pot;
/// @dev Paid-out portion of each pot (dust from flooring stays until reclaim).
mapping(uint256 week => mapping(address token => uint256 amount)) public potPaid;
/// @dev Reserve as computed when the week was settled (recomputed per
/// week, oldest-first).
mapping(uint256 week => mapping(address token => uint256 reserve)) public reserveSnapshot;
/// @dev Highest week _settlePots has funded. type(uint256).max means no
/// week has been settled yet (week 0 is then next).
uint256 public lastPotSettled;
/// @dev Sum of funded-but-unpaid pots per token. Grows on pot funding;
/// shrinks on pot payout AND on reclaim — it tracks the live earmark, so
/// reserve(t) = balanceOf(t) - outstandingPots[t] is the true free
/// balance and the treasury converges to ~100x weekly inflow (§7).
mapping(address token => uint256 amount) public outstandingPots;
event Registered(address indexed launcher, address indexed spot);
event Revenue(address indexed launcher, uint256 wethIn, uint256 cumulative);
event Claimed(address indexed launcher, address indexed to, uint256 nfts);
event GenesisClaimed(uint256 indexed leafIndex, address indexed recipient, uint256 amount);
event SweptTreasury(address indexed keeper, address indexed token, uint256 withdrawn, uint256 keeperCut);
event WeekClaimed(address indexed launcher, uint256 indexed week, uint256 wethPaid, uint256 amusePaid);
event WeekReclaimed(uint256 indexed week, uint256 wethReturned, uint256 amuseReturned);
constructor(DistributorV2Params memory p) BudgetIndexCore(block.timestamp) {
require(
p.protocolSplit != address(0) && p.weth != address(0) && p.amuse != address(0)
&& p.poolManager != address(0),
"Distributor: zero address"
);
require(p.currency0 < p.currency1, "Distributor: unordered key");
require(
(p.currency0 == p.weth || p.currency1 == p.weth)
&& (p.currency0 == p.amuse || p.currency1 == p.amuse),
"Distributor: key must be WETH/AMUSE"
);
require(p.deployerGenesis >= 1 && p.deployerGenesis <= 10, "Distributor: bad deployerGenesis");
require(p.genesisRoot != bytes32(0), "Distributor: zero genesis root");
// the vault NFTs belong to this liquid clone: read the split wiring
// from it so the treasury path can never target a different split.
address splitMain = ILiquidSplit(p.protocolSplit).splitMain();
address payoutSplit = ILiquidSplit(p.protocolSplit).payoutSplit();
require(splitMain != address(0) && payoutSplit != address(0), "Distributor: bad split");
PROTOCOL_SPLIT = IERC1155(p.protocolSplit);
SPLIT_TOKEN_ID = p.splitTokenId;
SPLIT_MAIN = ISplitMain(splitMain);
PAYOUT_SPLIT = payoutSplit;
WETH = p.weth;
AMUSE = p.amuse;
POOL_MANAGER = p.poolManager;
CURRENCY0 = p.currency0;
CURRENCY1 = p.currency1;
FEE = p.fee;
TICK_SPACING = p.tickSpacing;
HOOKS = p.hooks;
// sweep-guard values are validated by the spot at register() time;
// a misconfigured distributor fails there, not silently.
MAX_SWEEP_WETH = p.maxSweepWeth;
MIN_SWEEP_WETH = p.minSweepWeth;
SWEEP_INTERVAL = p.sweepInterval;
SLIPPAGE_BPS = p.slippageBps;
GENESIS_ROOT = p.genesisRoot;
DEPLOYER_GENESIS = p.deployerGenesis;
// no week settled yet: the first _settlePots starts at week 0.
lastPotSettled = type(uint256).max;
}
// --- registration ---
/// @notice Deploy the caller's fee spot (a LauncherSpotV2, which reports
/// pre-swap WETH as revenue). One spot per launcher; the caller pays
/// deployment gas, which rate-limits registration spam. Child launches
/// created through this launcher route their 1% WETH fee stream to the
/// returned spot address.
function register() external returns (address spot) {
require(spots[msg.sender] == address(0), "Distributor: already registered");
spot = address(
new LauncherSpotV2(
SpotParams({
weth: WETH,
amuse: AMUSE,
protocolSplit: address(PROTOCOL_SPLIT),
distributor: address(this),
launcher: msg.sender,
poolManager: POOL_MANAGER,
currency0: CURRENCY0,
currency1: CURRENCY1,
fee: FEE,
tickSpacing: TICK_SPACING,
hooks: HOOKS,
maxSweepWeth: MAX_SWEEP_WETH,
minSweepWeth: MIN_SWEEP_WETH,
sweepInterval: SWEEP_INTERVAL,
slippageBps: SLIPPAGE_BPS
})
)
);
spots[msg.sender] = spot;
emit Registered(msg.sender, spot);
}
// --- drip earning (§3) ---
/// @notice Record pre-swap WETH as the launcher's revenue. Callable only
/// by that launcher's own spot. Settles pots, advances the budget, and
/// settles the launcher's budget index BEFORE the new earned takes
/// effect — new earned never gets retroactive index. A launcher's first
/// noteRevenue initializes indexPaid to the current budgetIndex.
function noteRevenue(address launcher, uint256 wethIn) external {
require(msg.sender == spots[launcher], "Distributor: not the spot");
require(wethIn > 0, "Distributor: zero revenue");
_settlePots();
_advanceBudget();
_settleBudget(launcher);
revenueOf[launcher] += wethIn;
// the curve is monotone in revenue, so earned only moves forward.
_raiseEarned(launcher, _earnedFor(revenueOf[launcher]));
// the per-week snapshot: all writes go to the current week, so a
// closed week's totals are frozen by construction (§7).
uint256 wk = currentWeek();
weekRevenue[launcher][wk] += wethIn;
weekTotal[wk] += wethIn;
emit Revenue(launcher, wethIn, revenueOf[launcher]);
}
/// @notice Bonding-curve entitlement for cumulative WETH revenue (wei):
/// floor(800 * revenue / (revenue + K)). One mulDiv; integer units, floor
/// division favors the vault. Zero revenue earns zero.
function _earnedFor(uint256 revenueWei) internal pure returns (uint256) {
return FullMath.mulDiv(DRIP_UNITS, revenueWei, revenueWei + K);
}
// --- drip claims (§5.1) ---
/// @notice Claim newly earned protocol NFTs to `to`. Pays
/// min(budgeted entitlement, drip vault availability): if the budget or
/// vault is empty, pay is 0 and the entitlement is PRESERVED (v1 behavior
/// kept) — this never reverts on exhaustion. Accounting only ever moves
/// claimed forward, so this cannot underflow or double-pay.
/// @dev `to` is separate from msg.sender so a launcher that is a contract
/// (Safe, agent) without an ERC-1155 receiver can still claim to an EOA.
/// @return pay The units actually transferred (0 on exhaustion).
function claim(address to) external returns (uint256 pay) {
require(to != address(0), "Distributor: zero recipient");
_advanceBudget();
_settleBudget(msg.sender);
pay = _payableUnits(msg.sender, accruedScaledOf[msg.sender]);
// finding-1 cap lives in _payableUnits; the vault cap here: drip
// claims can never touch the endowment or genesis reserve.
uint256 avail = dripAvail();
if (pay > avail) {
pay = avail;
}
if (pay > 0) {
_recordClaim(msg.sender, pay);
PROTOCOL_SPLIT.safeTransferFrom(address(this), to, SPLIT_TOKEN_ID, pay, "");
}
emit Claimed(msg.sender, to, pay);
}
/// @notice Vault units the launcher can claim right now: the floored,
/// entitlement-capped index budget attributed to them, capped at the live
/// drip vault balance. Mirrors claim()'s payout math exactly (view-only),
/// so claimable == claim for back-to-back calls.
function claimable(address launcher) public view override returns (uint256) {
uint256 budgeted = _claimableUnits(launcher);
uint256 avail = dripAvail();
return budgeted > avail ? avail : budgeted;
}
/// @notice Drip-claimable vault balance: the distributor's unit balance
/// minus the 100-unit endowment (never distributed) minus the unclaimed
/// genesis reserve. Drip claims can never touch either.
function dripAvail() public view returns (uint256) {
uint256 bal = PROTOCOL_SPLIT.balanceOf(address(this), SPLIT_TOKEN_ID);
// genesisClaimedTotal <= 100 - DEPLOYER_GENESIS always (§5.2), so no
// underflow.
uint256 reserved = ENDOWMENT + (GENESIS_UNITS - DEPLOYER_GENESIS - genesisClaimedTotal);
return bal > reserved ? bal - reserved : 0;
}
// --- genesis claims (§5.2) ---
/// @notice Claim genesis units against the immutable merkle root.
/// Permissionless, no deadline, no admin. Leaves are
/// keccak256(abi.encode(leafIndex, recipient, amount)); the tree uses
/// sorted-pair hashing (see vendor/MerkleProof). Each leaf claims at most
/// once; total claims can never exceed 100 - DEPLOYER_GENESIS.
/// Unclaimed genesis units simply remain, reserved out of drip reach.
function claimGenesis(
uint256 leafIndex,
address recipient,
uint256 amount,
bytes32[] calldata proof
) external {
require(recipient != address(0), "Distributor: zero recipient");
require(!_isGenesisClaimed(leafIndex), "Distributor: already claimed");
require(
genesisClaimedTotal + amount <= GENESIS_UNITS - DEPLOYER_GENESIS,
"Distributor: exceeds genesis"
);
bytes32 leaf = keccak256(abi.encode(leafIndex, recipient, amount));
require(MerkleProof.verify(proof, GENESIS_ROOT, leaf), "Distributor: bad proof");
_setGenesisClaimed(leafIndex);
genesisClaimedTotal += amount;
PROTOCOL_SPLIT.safeTransferFrom(address(this), recipient, SPLIT_TOKEN_ID, amount, "");
emit GenesisClaimed(leafIndex, recipient, amount);
}
function _isGenesisClaimed(uint256 leafIndex) internal view returns (bool) {
return ((_genesisClaimedBitmap[leafIndex >> 8] >> (leafIndex & 0xff)) & 1) == 1;
}
function _setGenesisClaimed(uint256 leafIndex) internal {
_genesisClaimedBitmap[leafIndex >> 8] |= 1 << (leafIndex & 0xff);
}
// --- treasury (§6) ---
/// @notice Permissionless sweep of the distributor's SplitMain credit into
/// the treasury reserve, for one token of the immutable set {WETH,
/// $AMUSE}. Any other token reverts — the token set is immutable. Stray
/// tokens sent directly have no exit, by design.
/// @dev Takes no holder list (unlike v1's recycle): the sweep pulls only
/// the distributor's own credit. But the credit only exists after someone
/// calls distributeFunds on the protocol split with a holder list that
/// INCLUDES the distributor — that duty sits with the cron (spec §6); it
/// did not disappear. nonReentrant: the treasury holds accounting state,
/// so the v1 "no state, no guard" reasoning no longer applies.
function sweepTreasury(address token) external nonReentrant {
require(token == WETH || token == AMUSE, "Distributor: bad token");
// Settle pots BEFORE the withdrawal so the new reserve cannot inflate
// pots for weeks that already closed — pots reflect the pre-sweep
// reserve.
_settlePots();
// positive-credit pre-check (kept from v1's SplitMain review): zero
// credit is a clean no-op.
uint256 credit = SPLIT_MAIN.getERC20Balance(address(this), token);
if (credit == 0) {
return;
}
uint256 balBefore = IERC20(token).balanceOf(address(this));
address[] memory tokens = new address[](1);
tokens[0] = token;
SPLIT_MAIN.withdraw(address(this), 0, tokens);
uint256 withdrawn = IERC20(token).balanceOf(address(this)) - balBefore;
// keeper fee: exactly 50 bps of the withdrawn amount, to the caller.
uint256 keeperCut = (withdrawn * KEEPER_BPS) / 10_000;
if (keeperCut > 0) {
_safeTransfer(token, msg.sender, keeperCut);
}
// the remainder stays: it IS the reserve (derived accounting, §6).
emit SweptTreasury(msg.sender, token, withdrawn, keeperCut);
}
/// @notice Treasury reserve of `token`: balance minus the live pot
/// earmark, exactly, after every state-changing call (invariant 7).
/// Direct donations of WETH/$AMUSE are automatically included — they
/// enlarge future pots (documented, accepted).
function reserve(address token) public view returns (uint256) {
return IERC20(token).balanceOf(address(this)) - outstandingPots[token];
}
// --- weekly pot (§7) ---
/// @notice Current week number, deploy-anchored. Week 0 starts at deploy.
function currentWeek() public view returns (uint256) {
return (block.timestamp - DEPLOYED_AT) / WEEK;
}
/// @notice Permissionless pot settlement. _settlePots also runs at the top
/// of noteRevenue, claimWeek, and sweepTreasury; this standalone entry
/// point covers the pathological case (>104 missed weeks), where an
/// entry-point settlement hits the per-call bound and a later call
/// finishes the job — still oldest-first.
function settlePots() external {
_settlePots();
}
/// @notice Fund every closed, not-yet-settled week, oldest-first, at most
/// 104 per call. Each week's pot is 1% of the reserve net of
/// previously-settled pots (recomputed per week) — the fund-week-5-before-
/// week-4 game is gone no matter which entry point triggers settlement.
/// @dev Residual timing note, stated plainly (spec §7): a sweep landing
/// between a week's close and its settlement inflates that week's pot;
/// the settlement call is permissionless, so anyone can settle promptly.
/// What the loop removes is ORDER gaming, not all timing dependence.
function _settlePots() internal {
uint256 cw = currentWeek();
// lastPotSettled == type(uint256).max (constructor) means no week has
// been settled yet: start at week 0.
uint256 w = lastPotSettled == type(uint256).max ? 0 : lastPotSettled + 1;
uint256 n = 0;
address[2] memory tokens = [WETH, AMUSE];
while (w < cw && n < MAX_SETTLE_WEEKS) {
if (weekTotal[w] > 0) {
for (uint256 i = 0; i < 2; ++i) {
address t = tokens[i];
uint256 r = IERC20(t).balanceOf(address(this)) - outstandingPots[t];
uint256 p = r / 100;
pot[w][t] = p;
outstandingPots[t] += p;
reserveSnapshot[w][t] = r;
}
}
potFunded[w] = true;
lastPotSettled = w;
unchecked {
++w;
++n;
}
}
}
/// @notice Claim the launcher's share of closed week `w`'s pot, pro-rata
/// of their closed-week revenue share: pot[w][t] * weekRevenue[l][w] /
/// weekTotal[w], per token. Permissionless and nonReentrant.
/// @dev Pays the EARNER, never msg.sender — no theft vector via
/// front-running someone's claim. One claim per launcher per week; only
/// closed weeks; only within the 12-week window.
function claimWeek(address launcher, uint256 w) external nonReentrant {
uint256 cw = currentWeek();
require(w < cw, "Distributor: week not closed");
require(!weekClaimed[launcher][w], "Distributor: already claimed");
require(cw <= w + CLAIM_WINDOW_WEEKS, "Distributor: window elapsed");
_settlePots();
// guaranteed by the _settlePots() pre-call, except in the >104-missed-
// weeks pathological case, where it reverts and a later permissionless
// settlePots() finishes the job.
require(potFunded[w], "Distributor: week not settled");
weekClaimed[launcher][w] = true;
uint256 wr = weekRevenue[launcher][w];
uint256 wt = weekTotal[w];
uint256 wethPaid;
uint256 amusePaid;
if (wr > 0 && wt > 0) {
address[2] memory tokens = [WETH, AMUSE];
for (uint256 i = 0; i < 2; ++i) {
address t = tokens[i];
uint256 pay = (pot[w][t] * wr) / wt;
if (pay > 0) {
// release the earmark: the pot slice is now paid, not outstanding.
outstandingPots[t] -= pay;
potPaid[w][t] += pay;
_safeTransfer(t, launcher, pay);
if (t == WETH) {
wethPaid = pay;
} else {
amusePaid = pay;
}
}
}
}
emit WeekClaimed(launcher, w, wethPaid, amusePaid);
}
/// @notice Return a long-unclaimed week's remainder to the reserve.
/// Callable once the 12-week claim window has fully elapsed
/// (currentWeek > w + 12). Permissionless, nonReentrant. The unclaimed
/// funds enlarge future pots.
function reclaimWeek(uint256 w) external nonReentrant {
require(currentWeek() > w + CLAIM_WINDOW_WEEKS, "Distributor: window open");
uint256 wethBack;
uint256 amuseBack;
address[2] memory tokens = [WETH, AMUSE];
for (uint256 i = 0; i < 2; ++i) {
address t = tokens[i];
// potPaid[w][t] <= pot[w][t] always (each pay <= its pro-rata
// slice), so this cannot underflow; outstandingPots[t] covers
// every funded week's remainder, so neither can that.
uint256 unclaimed = pot[w][t] - potPaid[w][t];
if (unclaimed > 0) {
pot[w][t] = 0;
outstandingPots[t] -= unclaimed;
if (t == WETH) {
wethBack = unclaimed;
} else {
amuseBack = unclaimed;
}
}
}
emit WeekReclaimed(w, wethBack, amuseBack);
}
// --- ERC-1155 receiver (accept vault funding) ---
/// @notice Accept vault funding via safeTransferFrom.
function onERC1155Received(address, address, uint256, uint256, bytes calldata)
external
pure
returns (bytes4)
{
return IERC1155Receiver.onERC1155Received.selector;
}
/// @notice Accept vault funding via safeBatchTransferFrom.
function onERC1155BatchReceived(address, address, uint256[] calldata, uint256[] calldata, bytes calldata)
external
pure
returns (bytes4)
{
return IERC1155Receiver.onERC1155BatchReceived.selector;
}
// --- internals ---
/// @dev Low-level ERC20 transfer tolerating non-standard return values
/// (absent or boolean), as v1's recycle did.
function _safeTransfer(address token, address to, uint256 amount) internal {
(bool ok, bytes memory data) =
token.call(abi.encodeWithSelector(IERC20.transfer.selector, to, amount));
require(ok && (data.length == 0 || abi.decode(data, (bool))), "Distributor: transfer failed");
}
}
src/LauncherSpotV2.soldistributor v2 · implementation under review7b3d938f8d7779a68a2d6fbbd3aa76b5e4b61e91293fe3aa292161917e8b4232
Per-launcher spot contract reporting pre-swap WETH revenue. Awaiting re-review — may change.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {LauncherSpot, SpotParams} from "./LauncherSpot.sol";
/// @title LauncherSpotV2
/// @notice The v2 fee spot: identical to LauncherSpot except the revenue
/// handed to the distributor is the PRE-SWAP WETH amount (the child's 1% fee
/// stream, measured directly), not the $AMUSE swap output. All sweep guards
/// are inherited unchanged: per-sweep cap, dust floor, rate limit, and the
/// on-chain slippage bound derived from the top-of-call quote.
/// @dev Deployed by LauncherDistributorV2.register(), one per launcher. The
/// inherited sweep() reports through _reportRevenue, which is overridden here.
contract LauncherSpotV2 is LauncherSpot {
constructor(SpotParams memory p) LauncherSpot(p) {}
/// @dev In v1 the revenue reported was the $AMUSE output of the swap. In
/// v2 the report is the pre-swap WETH amount — the honest measure of the
/// child's 1% fee flow — so the bonding curve prices actual fee volume
/// rather than a price-dependent swap output.
function _reportRevenue(uint256 wethIn, uint256 /* amuseOut */) internal override {
DISTRIBUTOR.noteRevenue(LAUNCHER, wethIn);
}
}
src/BudgetIndexCore.soldistributor v2 · implementation under review6d49005eac619a1b5755abcc48f09d82dafcc6a683d3e5c2d63c50cfccc0ebf3
Synthetix-style index over remaining claim entitlement. Awaiting re-review — may change.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/// @title BudgetIndexCore
/// @notice The production §4/§5.1 budget-index engine. A true Synthetix-style
/// index over REMAINING ENTITLEMENT (earned - claimed): only new budget
/// increments are distributed, pro-rata to the weights present at the time.
/// Accrual is kept at 1e18 scale and floored only in the payout calculation —
/// never at settle — so payouts never depend on settle frequency (spec §4,
/// the v0.4 fix). Every payout is capped at remaining entitlement, so
/// aggregate payouts can never exceed the budget.
/// @dev Shared by LauncherDistributorV2 (the production path) and the
/// differential-test harness (test/BudgetIndexHarness). Index math lives
/// ONLY here: any change must be made in this file, never in a caller.
/// Ordering discipline (spec §4): callers advance the budget and settle the
/// launcher BEFORE the launcher's weight (earned) changes — new earned never
/// earns retroactive index. A launcher's weight changes only in their own
/// transactions, each of which settles first, so the weight applied to every
/// elapsed budget increment is the weight actually in force for it, and the
/// lazy index is exact, not approximate.
abstract contract BudgetIndexCore {
/// @notice Drip vault size, in integer units.
uint256 public constant DRIP_UNITS = 800;
/// @notice The global budget reaches DRIP_UNITS after this long, then
/// stays flat: a ~12-month hard floor on full distribution, regardless of
/// volume, launcher count, or Sybil splitting.
uint256 public constant BUDGET_DAYS = 365 days;
/// @notice Budget-index fixed-point scale.
uint256 internal constant INDEX_SCALE = 1e18;
/// @notice Deployment timestamp: anchors the budget clock.
uint256 public immutable DEPLOYED_AT;
/// @dev Lifetime entitlement per launcher, in integer units. Monotone
/// non-decreasing. (Production: the floored bonding-curve value of the
/// launcher's cumulative WETH revenue. Harness: set directly.)
mapping(address launcher => uint256 earned) public earnedOf;
/// @dev Cumulative units claimed per launcher. Advances only by units
/// actually paid, so earned >= claimed always.
mapping(address launcher => uint256 claimed) public claimedOf;
/// @dev Sum of claimedOf. Always <= budgetDistributed (invariant 3).
uint256 public totalClaimed;
/// @dev Sum over launchers of (earnedOf - claimedOf): the index divisor.
/// A launcher who has claimed everything stops accruing; new budget flows
/// to those still owed.
uint256 public totalUnclaimed;
/// @dev Cumulative budget per unit of weight, 1e18 scale.
uint256 public budgetIndex;
/// @dev The budget() value already folded into the index. Always advances
/// (even when totalUnclaimed == 0 — budget growth while nobody is owed
/// anything belongs to nobody: skipped, not banked).
uint256 public budgetDistributed;
/// @dev Total index-budget attributed to the launcher, 1e18 scale.
/// Floored to whole units ONLY in the payout calculation, never at settle.
mapping(address launcher => uint256 accruedScaled) public accruedScaledOf;
/// @dev The budgetIndex value the launcher was last settled to.
mapping(address launcher => uint256 index) public indexPaid;
constructor(uint256 deployedAt) {
DEPLOYED_AT = deployedAt;
}
/// @notice The global claim budget: 800 units over 365 days from deploy,
/// then flat at 800 forever. A rate limit on claims, not a pool that fills.
function budget() public view returns (uint256) {
uint256 elapsed = block.timestamp - DEPLOYED_AT;
uint256 b = (DRIP_UNITS * elapsed) / BUDGET_DAYS;
return b > DRIP_UNITS ? DRIP_UNITS : b;
}
/// @notice Fold newly elapsed budget into the index, pro-rata to the
/// weights present. Each time-increment is folded exactly once. If nobody
/// is owed anything, the growth is skipped, not banked: budgetDistributed
/// still advances, so that budget belongs to nobody.
function _advanceBudget() internal {
// budget() is monotone in time and budgetDistributed tracks it, so
// this cannot underflow.
uint256 d = budget() - budgetDistributed;
if (d > 0 && totalUnclaimed > 0) {
budgetIndex += (d * INDEX_SCALE) / totalUnclaimed;
}
budgetDistributed = budget();
}
/// @notice Attribute the elapsed index to the launcher at their current
/// weight (remaining entitlement). NO flooring here (v0.4): accrual stays
/// at 1e18 scale and is divided only in the payout calculation, so a
/// small-weight launcher settling daily accrues exactly what they accrue
/// settling once.
function _settleBudget(address l) internal {
// earned >= claimed always (claimed advances only by paid units),
// and budgetIndex only moves forward, so neither line underflows.
uint256 w = earnedOf[l] - claimedOf[l];
accruedScaledOf[l] += w * (budgetIndex - indexPaid[l]);
indexPaid[l] = budgetIndex;
}
/// @notice Monotonically raise a launcher's entitlement. Callers must
/// settle the launcher FIRST (see the ordering discipline above).
function _raiseEarned(address l, uint256 newEarned) internal {
require(newEarned >= earnedOf[l], "BudgetIndex: earned decreases");
totalUnclaimed += newEarned - earnedOf[l];
earnedOf[l] = newEarned;
}
/// @notice Advance the launcher's claim accounting by `pay` units. `pay`
/// must not exceed _payableUnits(l, accruedScaledOf[l]).
function _recordClaim(address l, uint256 pay) internal {
claimedOf[l] += pay;
totalClaimed += pay;
// pay <= earnedOf[l] - claimedOf[l] (guaranteed by _payableUnits), so
// this cannot underflow.
totalUnclaimed -= pay;
}
/// @notice Floored, entitlement-capped payout units from scaled accrual:
/// the ONLY division in the whole index (spec §4, v0.4). The finding-1
/// cap — budgeted <= earnedOf — is enforced here, on every claim path.
function _payableUnits(address l, uint256 accruedScaled) internal view returns (uint256) {
uint256 budgeted = accruedScaled / INDEX_SCALE;
if (budgeted > earnedOf[l]) {
budgeted = earnedOf[l];
}
return budgeted > claimedOf[l] ? budgeted - claimedOf[l] : 0;
}
/// @notice View twin of the claim payout math (no vault cap): pending
/// index advance, settle, floor, and entitlement cap — without writes.
function _claimableUnits(address l) internal view returns (uint256) {
uint256 bi = budgetIndex;
uint256 d = budget() - budgetDistributed;
if (d > 0 && totalUnclaimed > 0) {
bi += (d * INDEX_SCALE) / totalUnclaimed;
}
uint256 w = earnedOf[l] - claimedOf[l];
uint256 accrued = accruedScaledOf[l] + w * (bi - indexPaid[l]);
return _payableUnits(l, accrued);
}
/// @notice Units the launcher can claim right now (no vault cap; the
/// production distributor overrides this to also cap at the live vault).
function claimable(address l) public view virtual returns (uint256) {
return _claimableUnits(l);
}
}
docs/DISTRIBUTOR_V2_SPEC.mdspec v0.4 · reviewed, converging491375a13bf998d06528f43573bcb7adda416170c2f465db8b34690d0302f02b
The full distributor-v2 design the code is written against.
# Distributor v2 — Specification (DRAFT)
**Status:** draft for review. No code written against this spec. Nothing here is
deployed, merged, or funded. v0.3 reworked the budget into a true index and
closed the review's six findings; v0.4 applies the review's one remaining fix
(no per-settle flooring, §4). **The review gate is satisfied — implementation
may begin.**
**Source branch:** `distributor-v2` (branched from `distributor-v1` tip `1fb5023`,
which is the signed-off v1 at `d8ca66c` plus its review packet).
**v0.6 remains closed.** This spec changes no file under the v0.6 tag.
**Version history:**
- v0.1 — 2026-09-21, first full draft (bonding-curve drip + global budget +
treasury + weekly pot).
- v0.2 — 2026-09-21, redesigns per review: weekly pot pays **closed weeks
only** (per-launcher week snapshot, frozen denominator, one claim per
week); global budget allocated **pro-rata by entitlement** (index, not
first-come); treasury is a **multi-token pot over immutable {WETH, $AMUSE}**
with defined exits; all review-flagged numbers corrected; **K = 100 WETH
proposed** with the Sybil framing stated honestly.
(K = 100 WETH and the 50 bps keeper cut were confirmed by the owner later
the same day; values unchanged, status only — no version bump.)
- v0.3 — 2026-09-21, per review: the §4 budget is now a **true
Synthetix-style index** — the v0.2 formula had two arithmetic holes
(over-payment beyond entitlement at low volume; aggregate claims exceeding
the budget). Claims are capped at remaining entitlement; only new budget
increments are distributed, pro-rata to the weights present at the time.
Pot funding settled oldest-first via `_settlePots()` (§7); protocol split
declared **separate** from the $AMUSE fee split with ≥2-holder construction
(§2); Sybil item 2 corrected to the honest 1:1 flow cost (§3);
holder-list requirement restated as cron duty (§6). **K = 100 held pending
re-confirmation on the corrected Sybil price** (§13.1).
- v0.4 — 2026-09-21, per review (the only change before code): `_settleBudget`
no longer floors to whole units on every settle — accrual is kept at 1e18
scale (`accruedScaledOf`) and divided only in the payout calculation, so
payouts don't depend on settle frequency (order-independence, invariant 11).
**K = 100 WETH confirmed** (decision by Aether per the review's delegation;
rationale in §13.1; the owner may override).
---
## 1. Goals
1. **Emission is a function of time AND WETH.** Low routed volume → the vault
lasts longer. Very high volume → distribution still takes at least ~12 months.
2. **Floor-like early rewards, diminishing returns for size.** Dust-level
revenue earns a handful of units; whales pay more per marginal unit. Zero
revenue still earns zero.
3. **Perpetual incentives.** The program never fully ends: an endowment plus a
weekly fee pot keep paying producers indefinitely.
4. **No admin, no epochs, no governance.** Every state transition is
permissionless. Same trust model as v1.
5. **Every token the treasury can hold has a defined exit.** No stranded
balances except deliberately unrecoverable stray tokens (§6).
## 2. Allocation — 1,000 units, fixed at construction
| Bucket | Units | Fate |
|---|---|---|
| Genesis (early builders) | 100 | Claimed permissionlessly against an immutable merkle root set at construction (§5). Recipients/amounts TBD (owner decision §13). |
| Drip vault | 800 | Distributed via bonding curve + pro-rata budget index (§3–§4). |
| Permanent endowment | 100 | Held by the distributor forever. Never distributed. Its SplitMain fee share funds the treasury indefinitely. |
### The protocol split is not the $AMUSE fee split
Two distinct 0xSplits Liquid Splits exist. They must not be confused:
- **$AMUSE fee split** (launch-lite §1.1): receives the $AMUSE token's own
Clanker fees. Keeps its own allocation — founder, Pact sale, builder
vaults — including the Pact buyers' permanent fee rights. **Untouched by
this spec.**
- **Protocol split** (this spec): receives the $AMUSE bought from child-launch
1% revenue via the spots. 1,000 units: 100 genesis / 800 drip /
100 endowment, as tabled above.
Spots forward swept $AMUSE to the **protocol split**. If the signed-off
Converter coexists with spots (open question, §8), its bought $AMUSE also
goes to the protocol split — launch-lite §2's "to the $AMUSE liquid split"
predates the distributor and is superseded on this point only. (The
AmuseSink is unaffected: it handles unallocated *child* units and strays,
not the 1% WETH flow.)
Construction: SplitMain's `validSplit` requires at least two accounts, so a
split held entirely by the distributor cannot even be created — let alone
distribute. The protocol split is therefore created with **two holders**:
the distributor holding `1000 − g` units and the deployer holding `g` units,
where `1 ≤ g ≤ 10` and `g` counts toward the deployer's genesis cap
(standing direction §13.3). The remaining `100 − g` genesis units are claimed
via the merkle tree (§5.2). This also seeds the second holder the split
needs to ever distribute at all.
The distributor holds `1000 − g` units at construction: 800 drip +
100 endowment + `(100 − g)` genesis reserve. Genesis units leave only via
`claimGenesis` (§5.2); drip claims can never touch them (`dripAvail`, §5.1).
Accounting reservations (§10) guarantee claims — drip or genesis — can never
reduce the distributor's balance below the 100-unit endowment.
## 3. Drip earning — bonding curve
Per launcher, over **cumulative** attributed WETH (lifetime, never resets):
```
earned(launcher) = floor( DRIP_UNITS * revenueOf[launcher]
/ (revenueOf[launcher] + K) )
```
- `DRIP_UNITS = 800`
- `K = 100 WETH = 100e18` (wei). **Confirmed by the owner 2026-09-21.**
K is the half-point: at 100 WETH routed, a launcher has earned 400 units.
- One `mulDiv` in Solidity. Integer units; floor division favors the vault.
- `revenueOf` is denominated in **WETH wei**. K is therefore stable against
the $AMUSE price.
Reference table (floor, K = 100 WETH):
| Routed WETH | Units earned |
|---|---|
| 0.125 | 0 |
| 0.13 | 1 |
| 0.5 | 3 |
| 1 | 7 |
| 5 | 38 |
| 25 | 160 |
| 100 | 400 |
| 400 | 640 |
Properties: the first unit needs **100/799 ≈ 0.1252 WETH** of routed revenue
(dust below that earns nothing; zero revenue earns zero); the **690th unit**
costs ≈ **6.55 WETH** marginal (≈ 627.3 WETH cumulative to reach 690 units);
diminishing returns are structural; the curve never reaches 800 (the tail is
perpetual).
**Sybil note — stated honestly.** K is the **Sybil price of the vault**. The
curve is concave, so splitting R WETH of revenue across m fresh spots earns
more than routing it through one: at K = 100, routing 100 WETH as 50 × 2 WETH
across 50 spots earns ~750–784 of the 800 drip units (15.69 per spot before
flooring). The 0.05 WETH / 4h sweep cap is **per-spot** and does not slow
parallel spots — a 50-spot Sybil gets 50× the throughput. The cap paces
individual launchers; it is not a Sybil defense. What actually prices the
attack:
1. Each spot costs a deployment (gas), paid by the attacker.
2. Revenue is the spot's **pre-swap WETH balance** (§8) — anyone can transfer
WETH straight to their own spot and sweep it. Manufacturing revenue costs
**1 WETH of flow per 1 WETH of revenue**, not a multiple: there is no
wash-trading leg to pay for. And the Sybil **recaptures their unit-share
of the $AMUSE their flow buys** — the flow becomes split assets they then
earn units against. Net cost of the attack is slippage and pool fees on
the sweeps, the minority unit-share leaking to honest earners, m spot
deployments, and ~12 months of locked capital — not K. At K = 100:
~100 WETH of self-funded flow, split across ~20–50 spots to exploit the
curve's concavity, captures ~750–784 of the 800 drip units (~94–98%)
**if honest launchers are small**. The per-spot 0.05 WETH / 4h sweep cap
does not prevent this: one spot already routes ~0.3 WETH/day (~109
WETH/year), so throughput was never the barrier — parallelism serves only
the concavity exploit, and each spot is one cheap deployment.
3. The global budget (§4) caps **aggregate** extraction at ~15.3 units/week
no matter how many spots exist. A Sybil cannot drain faster than anyone
else; it can only redirect the paced flow, and only by sustaining real fee
flow for the full ≥12 months.
4. The weekly pot (§7) is Sybil-invariant by construction: shares are
pro-rata of revenue share, so splitting revenue across identities yields
the identical aggregate share.
## 4. Pacing — global budget as a true pro-rata index
The v0.1 budget was first-come-first-served: a bot could monopolize the
~2.2 units/day all year. The v0.2 formula
(`earnedOf[l] * budget() / totalEarned − claimedOf[l]`) had two arithmetic
holes: it paid **beyond entitlement** whenever `budget() > totalEarned`
(single launcher, 10 earned, day-200 budget 438 → claims 438 for 10 earned),
and it let **aggregate claims exceed the budget** (an early claimer locks a
payout against a small denominator; a later launcher claims against the same
budget again — the v0.1 weekly-pot bug moved into the budget). v0.3 replaces
it with a true Synthetix-style index: only **new** budget increments are
distributed, each pro-rata to the weights present at the time.
```
budget() = min( 800, 800 * (block.timestamp - DEPLOYED_AT) / 365 days )
```
State (additions to §9):
- `earnedOf[launcher]` — floored curve value, updated in `noteRevenue`.
- `claimedOf[launcher]`, `totalClaimed` — as v1.
- `budgetIndex` — cumulative budget per unit of weight, 1e18 scale.
- `budgetDistributed` — the `budget()` value already folded into the index.
- `accruedScaledOf[launcher]` — total index-budget attributed to launcher l,
kept at 1e18 scale; floored to whole units only in the payout calculation
(§5.1), never at settle.
- `indexPaid[launcher]` — the `budgetIndex` value l was last settled to.
- `totalUnclaimed` — Σ_l (`earnedOf[l] − claimedOf[l]`), accumulator.
**Weight decision (explicit):** the index weight is **remaining entitlement**,
`earnedOf[l] − claimedOf[l]`, and the divisor is `totalUnclaimed`. A launcher
who has claimed everything stops accruing; new budget flows to those still
owed. (Weighting by gross `earnedOf` would let fully-claimed launchers keep
accruing budget nobody else can touch — phantom share. The finding-1 cap
below stays as a backstop regardless.)
```
_advanceBudget():
d = budget() - budgetDistributed
if totalUnclaimed > 0 and d > 0:
budgetIndex += d * 1e18 / totalUnclaimed
budgetDistributed = budget()
```
If `totalUnclaimed == 0`, the budget growth is **skipped, not banked**:
`budgetDistributed` still advances, so budget that accrued while nobody was
owed anything belongs to nobody. The budget is a rate limit on claims, not a
pool that fills.
```
_settleBudget(l):
w = earnedOf[l] - claimedOf[l]
accruedScaledOf[l] += w * (budgetIndex - indexPaid[l])
indexPaid[l] = budgetIndex
```
**No flooring at settle (v0.4):** dividing by 1e18 inside `_settleBudget`
floors to whole units every time a launcher is touched, losing up to ~1 unit
per settle per launcher — a small-weight launcher who settles daily accrues
nothing while the same launcher settling once accrues correctly. Payouts
would depend on settle frequency, breaking the order-independence claim below
and invariant 11. The division happens exactly once, in the payout
calculation (§5.1).
Call order (settle-before-mutate — new earned never gets retroactive index):
- `noteRevenue(l, ...)`: `_settlePots(); _advanceBudget(); _settleBudget(l);`
**then** update `earnedOf[l]`, `totalUnclaimed`, and the week snapshot (§7).
A launcher's first `noteRevenue` initializes
`indexPaid[l] = budgetIndex` — no retroactive accrual.
- `claim(l)`: `_advanceBudget(); _settleBudget(l);` then pay (§5.1).
The lazy index is **exact, not approximate**: a launcher's weight changes
only in their own transactions (`noteRevenue`, `claim`), each of which
settles them first — so the weight applied to every elapsed index increment
is the weight that was actually in force for it. Interleaved claims across
launchers yield identical per-launcher payouts regardless of order.
- The budget grows at ~2.19 units/day ≈ **~15.3 units/week ≈ ~66/month**,
capped at 800. 800 units ⇒ **~12-month hard floor** to full distribution,
regardless of volume, launcher count, or Sybil splitting. Past 365 days the
budget stays 800 — no further accrual, no cliff.
- At low volume, revenue binds before the budget — this time actually true:
every payout is capped by remaining entitlement (§5.1).
What ~15 units/week costs in WETH depends on curve position (K = 100):
| Launcher state | WETH to earn 15 more units |
|---|---|
| New (steep part) | ~1.9 |
| Mid-curve (earned ~400) | ~7.8 |
| Deep (earned ~600) | ~32.4 |
**Why the race is gone:** `_advanceBudget` folds each time-increment exactly
once, pro-rata to the weights present at the time; `_settleBudget` attributes
it lazily and exactly. Launcher A's claim settles only A's accrual and moves
only A's `claimedOf` — B's accrued share of every past increment is already
fixed in the index and untouched. Two launchers claiming in the same block,
in either order, receive identical payouts. There is no benefit to claiming
early, often, or first.
## 5. Claims
### 5.1 Drip claims
`claim(l)` runs `_advanceBudget(); _settleBudget(l);` (§4), then:
```
entitled = earnedOf[l] - claimedOf[l]
budgeted = min(accruedScaledOf[l] / 1e18, earnedOf[l]) - claimedOf[l] // floor at 0
dripAvail = erc1155BalanceOf(distributor) - 100 - ((100 - g) - genesisClaimedTotal)
pay = min( max(budgeted, 0), dripAvail )
claimedOf[l] += pay; totalClaimed += pay; totalUnclaimed -= pay
```
- `pay` can never exceed remaining entitlement:
`min(accruedScaledOf[l] / 1e18, earnedOf[l]) − claimedOf[l]` is the finding-1
cap, enforced on every claim. Excess budget
(accrued beyond what launchers earned) stays in the pool — it belongs to
nobody.
- Σ payouts ≤ `budgetDistributed` ≤ `budget()` ≤ 800 by construction (§4):
the finding-2 hole is closed at the index, not at the claim.
- `dripAvail` reserves the 100-unit endowment **and** the unclaimed genesis
units (`g` = the deployer's pre-allocated genesis units, §2), so drip
claims can never touch either.
- Partial fills, never reverts on exhaustion: if the budget or vault is
empty, `pay` is 0 and entitlement is **preserved** (v1 behavior kept).
- `claimedOf` advances only by units actually transferred — earned ≥ claimed
always; no underflow, no double-pay.
- `claim(address to)` kept from v1: accounting on `msg.sender`,
zero-recipient check, `onERC1155BatchReceived` for contract launchers.
### 5.2 Genesis claims
- Constructor sets an immutable `genesisRoot` (merkle root over
`(leafIndex, recipient, amount)` leaves summing to `100 − g`, where `g` is
the deployer's pre-allocated genesis units at split creation, §2).
- `claimGenesis(leafIndex, recipient, amount, proof)`: verifies the proof,
requires `!genesisClaimed[leafIndex]` and
`genesisClaimedTotal + amount <= 100 - g`, transfers `amount` units to
`recipient`, marks the leaf claimed.
- Permissionless, no deadline, no admin. Unclaimed genesis units simply remain
in the distributor (reserved out of drip reach by `dripAvail`).
- Trust note, stated plainly: root correctness is the deployer's
responsibility; the tree is published offchain for verification.
## 6. Treasury — `sweepTreasury`
Replaces v1's `recycle()` (recycle-to-holders is removed).
The distributor's SplitMain credit — from the 100-unit endowment plus whatever
drip units remain undistributed — is swept permissionlessly into the
distributor itself as **treasury reserve**, over the immutable token set
**{WETH, $AMUSE}**.
`sweepTreasury(address token)`:
1. Requires `token == WETH || token == $AMUSE`. Any other token reverts —
the token set is immutable.
2. Positive-balance pre-check (kept from v1's SplitMain review): read the
distributor's withdrawable credit; if zero, clean no-op.
3. `nonReentrant` (new in v0.2 — the treasury now holds accounting state, so
the v1 "no state, no guard" reasoning no longer applies).
4. `SPLIT_MAIN.withdraw(address(distributor), 0, [token])` — pulls **only**
the distributor's own credit. The sweep itself takes **no holder list**.
But the credit only exists after someone calls `distributeFunds` on the
protocol split with a holder list that **includes the distributor** —
v1's hard operational requirement moved to the cron; it did not
disappear. The cron must include the distributor's address in every
protocol-split holder list, or there is nothing to sweep.
5. Keeper fee: **50 bps** of the withdrawn amount to `msg.sender`
(**confirmed by the owner 2026-09-21**).
6. Remainder stays in the distributor as treasury reserve.
Reserve accounting (derived, exact):
```
reserve(t) = balanceOf(t) - outstandingPots[t] for t in {WETH, $AMUSE}
```
`outstandingPots[t]` accumulates pot funding (§7) and is decremented on
reclaim. Direct donations of WETH/$AMUSE to the distributor are automatically
included in the reserve (they enlarge future pots — documented, accepted).
**Defined exits.** Every token in the immutable set has exactly two exits,
and no others:
1. **Weekly pot payouts** (§7) — pro-rata in each token. Since spots convert
child WETH → $AMUSE into the protocol split, **$AMUSE will be most of the
treasury**; the pot is its continuous, rule-defined exit.
2. **The 50 bps keeper cut** on `sweepTreasury`.
There is no admin, no discretionary spend, no rescue function. **Stray
tokens** (anything outside {WETH, $AMUSE} sent directly) have **no exit** and
are stranded by design — stated honestly rather than papered over.
Trade-off, stated honestly: v1 recycled the vault's fee share back to **all
holders** pro-rata. v2 concentrates it into the treasury, which pays
**producers** (§7). Passive holders lose the recycle stream; active producers
gain the weekly pot.
## 7. Weekly pot — closed weeks only
The v0.1 design (trailing-window checkpoints + claim-twice accounting) was
shown to overpay under interleaved dust-sweep attacks. v0.2 replaces it with
**direct per-week accounting**: revenue is snapshotted per launcher per week
at note time, the denominator is frozen by construction, and each launcher
claims each week at most once.
- `WEEK = 7 days`. `week = (block.timestamp - DEPLOYED_AT) / WEEK`
(deploy-anchored; week 0 starts at deploy).
- `noteRevenue(address launcher, uint256 wethIn)` writes, in one call:
- `revenueOf[launcher] += wethIn` (drip curve input, §3);
- `earnedOf` / `totalUnclaimed` accumulator update (§4);
- **`weekRevenue[launcher][week] += wethIn`** and
**`weekTotal[week] += wethIn`** — the per-week snapshot. All writes go
to the *current* week, so once a week ends its totals are **frozen by
construction**. No finalize, no poke, no grace period.
- `claimWeek(address launcher, uint256 w)` — permissionless, `nonReentrant`:
1. Requires `w < week` — **only closed weeks pay**. Claims for the
current (open) week revert.
2. Requires `!weekClaimed[launcher][w]` — **one claim per launcher per
week**. A second claim reverts; double-counting is impossible.
3. Requires the claim window: `week <= w + 12` (12 weeks after the week
closes). Later claims revert; see `reclaimWeek` below.
4. Pot settlement — `_settlePots()` runs at the top of `noteRevenue`,
`claimWeek`, and `sweepTreasury` (in the sweep it runs *before* the
withdrawal, so pots reflect the pre-sweep reserve), plus a standalone
permissionless `settlePots()` for the pathological case below:
```
w = lastPotSettled + 1; n = 0
while w < currentWeek() and n < 104:
if weekTotal[w] > 0:
for t in {WETH, AMUSE}:
r = balanceOf(t) - outstandingPots[t] // recomputed per week
pot[w][t] = r / 100
outstandingPots[t] += pot[w][t]
reserveSnapshot[w][t] = r
potFunded[w] = true
lastPotSettled = w; w += 1; n += 1
```
Weeks are funded **oldest-first**, each from the reserve net of
previously-settled pots — the fund-week-5-before-week-4 game is gone no
matter which entry point triggers settlement, and a single call's gas is
bounded (104 iterations ≈ two years of missed weeks; any remainder settles
on later permissionless calls, still oldest-first). Residual timing note,
stated plainly: a sweep landing between a week's close and its settlement
inflates that week's pot; the settlement call is permissionless, so anyone
can settle promptly. What the loop removes is *order* gaming, not all
timing dependence.
5. Requires `potFunded[w]` — guaranteed by the `_settlePots()` pre-call
(in the >104-missed-weeks pathological case it reverts and a later
permissionless settlement call finishes the job).
6. Pay, per token: `pay[t] = pot[w][t] * weekRevenue[launcher][w] / weekTotal[w]`.
Set `weekClaimed[launcher][w] = true`; `potPaid[w][t] += pay[t]`;
transfer to the launcher (the **earner**, never `msg.sender` — no theft
vector via front-running someone's claim).
- `reclaimWeek(uint256 w)` — permissionless, `nonReentrant`, callable when
`week > w + 12`: for each `t`, `unclaimed = pot[w][t] - potPaid[w][t]`;
`outstandingPots[t] -= unclaimed`; zero the remainder. Unclaimed funds
return to the reserve (they enlarge future pots).
Properties:
- **Sybil-invariant:** shares are pro-rata of revenue share within a closed
week; splitting revenue across identities yields the identical aggregate.
- **Equilibrium:** the reserve converges to **~100× weekly fee inflow**
(where 1% weekly outflow = inflow). It cannot drain; payouts scale with the
reserve in both directions. Perpetual by construction.
- Settlement is lazy and permissionless: a week nobody's revenue touches is
still settled the next time anyone calls `noteRevenue`, `claimWeek`, or
`sweepTreasury` — no keeper is needed to close weeks, and no week can be
funded out of order.
## 8. Spot changes
- `noteRevenue(address launcher, uint256 wethIn)`: the spot reports the
**pre-swap WETH amount**, not the $AMUSE output (kept from v0.1). Rationale:
the curve is calibrated in WETH (K = 100 WETH); denominating revenue in
$AMUSE would let the unit price drift with the $AMUSE price.
- Everything else in the spot is unchanged: immutable registration, on-chain
minOut from top-of-call quote, `MAX_SWEEP_WETH`, `SWEEP_INTERVAL`,
`MIN_SWEEP_WETH` dust floor.
- NatSpec note (honest framing, supersedes the v0.1 wording): the 0.05 WETH /
4h sweep cap paces **per-launcher** throughput. It does **not** defend
against Sybils running parallel spots — see §3.
## 8.5 Changes from v1
| v1 | v0.2 |
|---|---|
| `AMUSE_WEI_PER_UNIT` linear price | Bonding curve `800·r/(r+K)`, WETH-denominated, K = 100 (confirmed 2026-09-21, §13.1) |
| No time component | True budget index over remaining entitlement (800 units / 365 days, capped); every payout ≤ entitlement; Σ payouts ≤ budget |
| `recycle()` → all holders via `distributeFunds` | `sweepTreasury()` → multi-token treasury (keeper 50 bps confirmed); no holder list on the sweep — the cron still owes `distributeFunds` with the distributor listed |
| `noteRevenue(launcher, amuseBought)` | `noteRevenue(launcher, wethIn)` + per-week snapshot + earned accumulator |
| — | Weekly 1% pot, closed weeks only, one claim per week |
| — | 100-unit permanent endowment; 100-unit genesis via merkle root |
| 700-unit vault | 800 drip + 100 endowment + 100 genesis reserve (1,000 held) |
Kept from v1: integer units, earned ≥ claimed, entitlement preservation,
`claim(to)`, batch ERC-1155 receipt, no admin/epochs/governance, sweep guards.
## 9. Storage layout
Immutables / constants (set at construction, never change):
```
DEPLOYED_AT, K = 100e18 (confirmed), DRIP_UNITS = 800 (const),
GENESIS_UNITS = 100 (const), ENDOWMENT = 100 (const),
BUDGET_DAYS = 365 days (const), WEEK = 7 days (const),
CLAIM_WINDOW_WEEKS = 12 (const), KEEPER_BPS = 50 (const, confirmed),
WETH, AMUSE, PAYOUT_SPLIT, SPLIT_MAIN (from the clone, as v1),
genesisRoot, deployerGenesis (g, 1..10, §2)
```
Mutable state:
```
// drip
revenueOf[launcher] // cumulative WETH wei
earnedOf[launcher] // floored curve value
claimedOf[launcher]
totalClaimed // ≤ 800
// budget index (v0.4)
budgetIndex // cumulative budget per weight, 1e18 scale
budgetDistributed // budget() already folded into the index
accruedScaledOf[launcher] // index-budget attributed to l, 1e18 scale; floored at payout only
indexPaid[launcher] // budgetIndex value l was last settled to
totalUnclaimed // Σ (earnedOf - claimedOf), accumulator
// genesis
genesisClaimed[leafIndex] // bitmap
genesisClaimedTotal // ≤ 100 - g
// weekly pot
weekRevenue[launcher][week]
weekTotal[week] // frozen once the week ends
weekClaimed[launcher][week]
potFunded[week]
pot[week][token] // t ∈ {WETH, AMUSE}
potPaid[week][token]
lastPotSettled // highest week _settlePots has funded
reserveSnapshot[week][token] // reserve as computed at settlement
// treasury
outstandingPots[token] // Σ funded-but-unreclaimed pots
```
No admin roles. No upgrade path. No epochs or governance parameters.
## 10. Invariants (for tests and review)
1. `earnedOf[l]` is monotone non-decreasing in `revenueOf[l]`, bounded above
by 800.
2. `claimedOf[l] <= earnedOf[l]` always; every drip payout `<= earnedOf[l] −
claimedOf[l]` before it (finding-1 cap, §5.1).
3. `totalClaimed <= budgetDistributed <= budget() <= 800` always (12-month
floor; no post-365 accrual; budget growth while `totalUnclaimed == 0`
belongs to nobody).
4. The distributor's ERC-1155 balance never drops below 100 via any claim
path (endowment reservation in `dripAvail`; genesis capped at 100 total
including the deployer's pre-allocated `g`).
5. `genesisClaimedTotal <= 100 - g`; each leaf claimed at most once.
6. `sweepTreasury`: keeper receives exactly 50 bps of the withdrawn amount
per token; the remainder increases the reserve; no holder list; wrong
token reverts; zero credit is a clean no-op.
7. `reserve(t) == balanceOf(t) - outstandingPots[t]` for t ∈ {WETH, $AMUSE},
exactly, after every state-changing call.
8. Weekly, per closed week `w` and token `t`:
`Σ_l paid_l[w][t] <= pot[w][t]`, and
`pot[w][t] == reserveSnapshot[w][t]/100` (or 0 if `weekTotal[w] == 0`),
where `reserveSnapshot[w][t]` is the reserve as computed by `_settlePots`
when week `w` was settled — recomputed per week, oldest-first.
9. `weekTotal[w]` and `weekRevenue[l][w]` are immutable once week `w` ends.
10. One claim per launcher per week; claims only for closed weeks; claims
only within the 12-week window.
11. **Order-independence:** interleaved `claim` calls across launchers yield
identical per-launcher payouts regardless of order (the lazy index is
exact, §4); pot funding is oldest-first regardless of which entry point
triggers `_settlePots` (§7).
12. Treasury token set is exactly {WETH, $AMUSE}; no other token has an exit.
13. After `reclaimWeek(w)`, `pot[w][t]` remainder is zero and the unclaimed
amount is back in the reserve.
14. Σ_l `accruedScaledOf[l]` / 1e18 ≤ `budgetDistributed`, up to one unit of
flooring per launcher — the aggregate can never exceed the budget, no
matter the claim order.
## 11. Test plan
- Curve boundaries: the §3 table as exact assertions (floor behavior,
K = 100 WETH).
- Floor: 0.13 WETH → 1 unit; 0.125 WETH → 0; zero revenue → zero.
- 690th unit: cumulative ≈ 627.3 WETH, marginal ≈ 6.55 WETH (exact mulDiv
assertions).
- Budget: warp to 364 days — full entitlement claimable only up to
`budget()`; warp past 365 days — `budget()` stays 800, no further accrual.
- **Over-payment case 1 (exact):** single launcher, 10 earned, warp to day
200 (`budget()` = 438) → claim pays exactly 10, not 438; entitlement
preserved for the rest.
- **Over-payment case 2 (exact):** A earns 100; day 45 (`budget()` = 98) A
claims 98. B earns 100 at day 45 → B's immediate claim pays 0 (the budget
increment was already distributed). Warp to day 90; A and B claim in both
orders → identical per-launcher totals; `totalClaimed <= budget()` at every
step.
- **Budget race / index exactness (fuzz):** N launchers, random revenues,
random claims across M time-warps, orders shuffled → per-launcher totals
identical across orderings; Σ claimed ≤ `budget()`; no launcher ever paid
beyond `earnedOf − claimedOf`.
- Index dilution: a third launcher adds revenue mid-stream → earlier
launchers' subsequent accrual reflects the new divisor (exact
`_settleBudget` assertions); a launcher's first `noteRevenue` accrues
nothing retroactive (`indexPaid` initialized to current `budgetIndex`).
- **Settle-frequency independence:** a 1-unit-weight launcher settling daily
for 200 days accrues exactly what the same launcher accrues touching the
contract once — no per-settle flooring (§4); payouts identical across
settle frequencies.
- Skip-while-empty: budget growth during a long stretch with
`totalUnclaimed == 0` is unclaimable later — the first earner accrues only
from *future* increments.
- Entitlement preservation across budget exhaustion and vault exhaustion
(drip `pay` = 0, state unchanged, later claim succeeds).
- `claim(to)` to a contract without receiver hooks (v1 test kept).
- Genesis: valid proof claims; double-claim reverts; bad proof reverts;
101st unit reverts; drip `dripAvail` shrinks by unclaimed genesis.
- `sweepTreasury`: keeper cut exactness on both tokens; reserve accounting
exactness; no holder list; repeated dust calls are clean no-ops;
wrong-token reverts; zero-credit no-op; reentrancy attempt via malicious
ERC-777-style token hook reverts (nonReentrant).
- **Real-split $AMUSE (fork):** against real 0xSplits on the pinned fork —
distributor as SplitMain recipient, `sweepTreasury` pulls both WETH and
$AMUSE, keeper cut exact, reserve accounting exact.
- Weekly: two launchers, known revenue split in a closed week → exact
pro-rata in both tokens; second `claimWeek` for the same week reverts;
`claimWeek` for the current week reverts; `claimWeek` after the window
reverts; `reclaimWeek` returns the exact unclaimed remainder.
- **Frozen denominator:** note revenue in week w+1, then claim week w →
payout identical to claiming before the new revenue.
- **Cross-week funding order:** trigger settlement via `claimWeek(5)` before
`claimWeek(4)`, and via `sweepTreasury`, across weeks with different
reserves → `pot[4]`, `pot[5]`, and `reserveSnapshot` equal the oldest-first
values in all cases; a sweep landing between close and settlement is the
only timing input, and it is identical regardless of trigger.
- **Single-holder split (fork):** `createSplit` / `distributeFunds` with one
account against the real SplitMain → reverts (documents the ≥2-holder
construction requirement, §2).
- **Direct-WETH-donation revenue:** transfer WETH straight to one's own
spot, sweep, `noteRevenue` → earned accrues 1:1 with the donation
(documents the honest Sybil cost, §3 item 2).
- **claimWeek by third party:** caller ≠ launcher → funds go to the launcher
(earner), never `msg.sender`.
- **Pot-sum fuzz (interleaved claims):** N launchers, random revenues across
random weeks, random claim order and timing → for every week and token,
Σ paid ≤ pot; no launcher paid twice for a week; total paid equals the
exact pro-rata expectation. Fuzz ≥ 256 runs.
- **Donated tokens:** direct WETH/$AMUSE transfers to the distributor →
reserve and subsequent pots grow exactly; accounting invariant (§10.7)
holds.
- Sybil: one stream vs two half-streams → identical aggregate weekly share;
drip level differs per §3 (assert the ~750–784 shape at 100 WETH / 50 spots
in a pure-math test).
- Endowment: attempt to drain via drip + genesis → balance floor of 100 holds.
- Full suite on the pinned Base fork (block 51531538), Forge 1.7.1,
`--threads 1`, as v1.
## 12. Security notes
- `sweepTreasury` / `claimWeek` / `reclaimWeek` are permissionless and
`nonReentrant`. Worst case for a malicious caller is wasted gas — no state
they can corrupt, no funds they can redirect (payouts go to the revenue
earner, never the caller; the keeper fee is a fixed 50 bps).
- `claimWeek` pays the *earner*, never `msg.sender` — no theft vector via
front-running someone's claim.
- Integer division throughout favors the vault/reserve; dust accumulates,
never leaks.
- No oracle, no price feed: WETH-denominated revenue comes from the spot's
own balance before the swap. The only external trust is the pinned
$AMUSE/WETH pool and SplitMain, as in v1.
- `budget()` is monotone and capped; the index distributes only *new*
increments, pro-rata to remaining entitlement at the time; Σ payouts ≤
`budgetDistributed` ≤ `budget()` by construction, and every payout ≤ the
launcher's remaining entitlement. The two v0.2 arithmetic holes
(over-payment, aggregate over-budget) are closed at the index, not at the
claim.
## 13. Open items — owner decisions
1. **K = 100 WETH** — **confirmed 2026-09-21** (decision by Aether; the review
delegated the call, and the owner may override). The earlier confirmation
rested on the "multiple of K in wash volume" framing, which was wrong:
the honest price is ~100 WETH of *gross routed flow* — the Sybil
recaptures their unit-share of the $AMUSE their flow buys, so net cost is
slippage, fees, minority leakage to honest earners, ~20–50 spot
deployments, and ~12 months of locked capital. ~100 WETH of self-funded
flow captures ~750–784 of the 800 drip units (~94–98%) if honest
launchers are small. Kept at 100 because: (a) K also sets the dust floor —
the first unit costs ~0.125 WETH, which is the accessibility property for
small honest contributors; raising K raises that floor equally for
everyone, it doesn't discriminate against Sybils; (b) the binding Sybil
limiter is the 12-month budget (~15.3 units/week aggregate, Sybil-proof
pacing) plus honest competition for the same budget — a single
well-funded party taking most of year one's drip for ~100 WETH is a known,
bounded, one-year outcome, and year two's budget is fresh; (c) the
alternatives are mechanism redesigns, not numbers — a per-launcher
drip-share cap punishes genuinely successful honest launchers and adds
state, and a curve-shape change moves the dust floor too. Neither is
justified against the current threat model.
2. **Keeper cut 50 bps** on `sweepTreasury` — **confirmed 2026-09-21.**
3. **Genesis 100 allocations** — recipients and per-recipient amounts.
Standing direction: 100 early builders, deployer capped at 10.
The deployer's `g` (1–10) is pre-allocated at split construction (§2) and
counts toward the cap; the merkle tree covers the remaining `100 − g`.
Mechanism (merkle root, §5.2) is specified; the tree itself is TBD.
4. The protocol split mints its 1,000 units directly to the distributor
(`1000 − g`) and the deployer (`g`) at construction — no funding transfer
needed. The drip and genesis claim paths assume the distributor's balance
is present (operational, as v1).
A Muse Bot