// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; /* * ProofOfBlob * * A pool any token's dev can hand an amount to at a time they name. At that * time the round is executed: a tenth of the amount is bought into BLOB for * the pond's treasury, the dev's burn share is bought into the token they * chose and delivered to the dead address, and the rest is pushed to the * token's largest holders by the publisher, from a list whose hash is then * written here. * * --------------------------------------------------------------------------- * The only ways funds leave this contract are the fee swap to the treasury, * the burn swap to the dead address, the bounty to the executor, the pushed * shares to the listed wallets, and the remainder to the dead address. There * is no owner, no pause, no upgrade path, no withdraw and no arbitrary call. * The one external contract it calls that it was not handed in a call is the * router, fixed at deployment, along with the fee's token and wallet. * * Nothing in any branch returns to the committer. Read that sentence twice * before you commit anything. * --------------------------------------------------------------------------- */ interface IERC20 { function balanceOf(address account) external view returns (uint256); function transfer(address to, uint256 amount) external returns (bool); function transferFrom(address from, address to, uint256 amount) external returns (bool); function approve(address spender, uint256 amount) external returns (bool); function allowance(address owner, address spender) external returns (uint256); } /// The router's own types, spelled the same so the calldata is the same. interface IGalaxyRouter { struct Hop { uint8 kind; address pool; address tokenIn; address tokenOut; bool zeroForOne; uint24 fee; int24 tickSpacing; address hooks; uint24 feePpm; } struct Leg { uint256 amountIn; Hop[] hops; } struct SwapDescription { address tokenIn; address tokenOut; uint256 amountIn; uint256 minAmountOut; address recipient; uint256 deadline; Leg[] legs; } function swap(SwapDescription calldata description) external payable returns (uint256 amountOut); } contract ProofOfBlob { /// Native ether is the zero address, the same spelling the router uses. address internal constant NATIVE = address(0); address public constant DEAD = 0x000000000000000000000000000000000000dEaD; uint16 public constant BPS = 10_000; /// The tenth for BLOB, on every round whose target is not BLOB. uint16 public constant FEE_BPS = 1_000; uint16 public constant MAX_TOP = 500; /// How long only the publisher may execute, from `executeAt`. uint40 public constant GRACE = 1 days; /// How long after execution a round nobody distributed may be abandoned. uint40 public constant ABANDON_AFTER = 30 days; uint8 internal constant SCHEDULED = 1; uint8 internal constant EXECUTED = 2; uint8 internal constant DISTRIBUTED = 3; uint8 internal constant ABANDONED = 4; IGalaxyRouter public immutable router; address public immutable blob; address public immutable treasury; address public immutable publisher; struct Round { address committer; address target; address asset; address burnToken; uint256 amount; uint256 fee; uint256 burn; uint256 pot; uint256 distributed; uint256 minHolding; uint16 burnBps; uint16 topN; uint40 executeAt; uint40 executedAt; uint64 snapshotBlock; uint96 bounty; uint8 status; bytes32 listHash; } /// Ids start at one, so zero can only ever mean "no such round". uint256 public roundCount; mapping(uint256 => Round) public rounds; /// Set for the length of one commit, execution, batch or abandonment. uint256 private locked; /* ---------------------------------------------------------------- events */ event Committed( uint256 indexed id, address indexed committer, address indexed target, address asset, address burnToken, uint256 amount, uint256 fee, uint256 burn, uint256 pot, uint16 burnBps, uint16 topN, uint256 minHolding, uint40 executeAt, uint96 bounty, address[] excluded, string note ); event Executed( uint256 indexed id, address indexed executor, uint64 snapshotBlock, uint256 feeOut, uint256 burnedOut, bool burnedAsIs ); event Distributed(uint256 indexed id, uint256 count, uint256 total); event Finished(uint256 indexed id, bytes32 listHash, uint256 remainderBurned); event Abandoned(uint256 indexed id, address by, uint256 burned); /* ---------------------------------------------------------------- errors */ error Reentered(); error BadAmount(); error BadTarget(); error BadShare(uint16 burnBps); error BadTop(uint16 topN); error WrongValue(uint256 sent, uint256 expected); error ShortReceipt(uint256 received, uint256 expected); error NoSuchRound(uint256 id); error NotScheduled(uint256 id, uint8 status); error NotExecuted(uint256 id, uint8 status); error TooEarly(uint40 executeAt); error PublisherOnly(uint40 until); error NotPublisher(); error WrongRoute(); error RouteRequired(); error OverPot(uint256 asked, uint256 left); error LengthMismatch(); error TooSoon(uint40 until); error TransferFailed(); error BountyUnpaid(); /* ------------------------------------------------------------- modifiers */ modifier nonReentrant() { if (locked == 1) revert Reentered(); locked = 1; _; locked = 0; } constructor(address _router, address _blob, address _treasury, address _publisher) { router = IGalaxyRouter(_router); blob = _blob; treasury = _treasury; publisher = _publisher; } /* ----------------------------------------------------------------- commit */ /** * Takes the amount and the bounty and records the round. * * A native asset arrives as `amount + bounty` of value. A token arrives * by allowance, and what actually lands here has to be exactly `amount`: * a token that keeps part of every transfer would otherwise be found out * at execution, after the committer has gone. The fee comes off first, * the burn share off what is left, and the rest is the pot. */ function commit( address target, address asset, uint256 amount, address burnToken, uint16 burnBps, uint16 topN, uint256 minHolding, uint40 executeAt, uint96 bounty, address[] calldata excluded, string calldata note ) external payable nonReentrant returns (uint256 id) { if (amount == 0) revert BadAmount(); if (target == address(0) || target == DEAD) revert BadTarget(); if (burnBps > BPS) revert BadShare(burnBps); if (topN == 0 || topN > MAX_TOP) revert BadTop(topN); if (burnToken == address(0)) burnToken = target; if (asset == NATIVE) { if (msg.value != amount + bounty) revert WrongValue(msg.value, amount + bounty); } else { if (msg.value != bounty) revert WrongValue(msg.value, bounty); uint256 before = IERC20(asset).balanceOf(address(this)); _pull(asset, msg.sender, amount); uint256 received = IERC20(asset).balanceOf(address(this)) - before; if (received != amount) revert ShortReceipt(received, amount); } uint256 fee = target == blob ? 0 : (amount * FEE_BPS) / BPS; uint256 burn = ((amount - fee) * burnBps) / BPS; uint256 pot = amount - fee - burn; uint40 startAt = executeAt < block.timestamp ? uint40(block.timestamp) : executeAt; id = ++roundCount; Round storage r = rounds[id]; r.committer = msg.sender; r.target = target; r.asset = asset; r.burnToken = burnToken; r.amount = amount; r.fee = fee; r.burn = burn; r.pot = pot; r.minHolding = minHolding; r.burnBps = burnBps; r.topN = topN; r.executeAt = startAt; r.bounty = bounty; r.status = SCHEDULED; emit Committed( id, msg.sender, target, asset, burnToken, amount, fee, burn, pot, burnBps, topN, minHolding, startAt, bounty, excluded, note ); } /* ---------------------------------------------------------------- execute */ /** * Runs a round at or after its time. * * For a day from `executeAt` only the publisher may run it, with the * engine's own quote as each swap's minimum. After the day anyone may, * with any route, and the burn share may go to the dead address unswapped * when `burnRoute` has no legs. The round is marked executed before the * router is called, the router reverts unless the recipient's balance * rises by at least the minimum, and the bounty is paid last. */ function execute( uint256 id, IGalaxyRouter.SwapDescription calldata feeRoute, IGalaxyRouter.SwapDescription calldata burnRoute ) external nonReentrant { Round storage r = rounds[id]; if (r.status == 0) revert NoSuchRound(id); if (r.status != SCHEDULED) revert NotScheduled(id, r.status); if (block.timestamp < r.executeAt) revert TooEarly(r.executeAt); bool graceOver = block.timestamp >= uint256(r.executeAt) + GRACE; if (!graceOver && msg.sender != publisher) revert PublisherOnly(r.executeAt + GRACE); r.status = EXECUTED; r.executedAt = uint40(block.timestamp); r.snapshotBlock = uint64(block.number); uint256 feeOut; if (r.fee != 0) { if (r.asset == blob) { _send(blob, treasury, r.fee); feeOut = r.fee; } else { feeOut = _swap(r.asset, blob, r.fee, treasury, feeRoute); } } uint256 burnedOut; bool asIs; if (r.burn != 0) { if (r.asset == r.burnToken) { _send(r.asset, DEAD, r.burn); burnedOut = r.burn; } else if (burnRoute.legs.length == 0) { if (!graceOver) revert RouteRequired(); _send(r.asset, DEAD, r.burn); asIs = true; } else { burnedOut = _swap(r.asset, r.burnToken, r.burn, DEAD, burnRoute); } } uint96 bounty = r.bounty; if (bounty != 0) { (bool paid,) = msg.sender.call{value: bounty}(""); if (!paid) revert BountyUnpaid(); } emit Executed(id, msg.sender, r.snapshotBlock, feeOut, burnedOut, asIs); } /* ------------------------------------------------------------- distribute */ /** * Pushes shares to the listed wallets, in as many batches as the list * needs. The sum of every batch can never exceed the pot. A wallet that * refuses ether keeps its share in the pot, and the last batch burns * whatever is left in it and writes the hash of the whole list. */ function distribute( uint256 id, address[] calldata accounts, uint256[] calldata amounts, bool last, bytes32 listHash ) external nonReentrant { if (msg.sender != publisher) revert NotPublisher(); Round storage r = rounds[id]; if (r.status == 0) revert NoSuchRound(id); if (r.status != EXECUTED) revert NotExecuted(id, r.status); if (accounts.length != amounts.length) revert LengthMismatch(); uint256 total; for (uint256 i = 0; i < amounts.length; i++) { total += amounts[i]; } uint256 left = r.pot - r.distributed; if (total > left) revert OverPot(total, left); uint256 sent; for (uint256 i = 0; i < accounts.length; i++) { if (r.asset == NATIVE) { (bool ok,) = accounts[i].call{value: amounts[i]}(""); if (ok) sent += amounts[i]; } else { _send(r.asset, accounts[i], amounts[i]); sent += amounts[i]; } } r.distributed += sent; emit Distributed(id, accounts.length, sent); if (last) { r.status = DISTRIBUTED; r.listHash = listHash; uint256 remainder = r.pot - r.distributed; if (remainder != 0) { r.distributed = r.pot; _send(r.asset, DEAD, remainder); } emit Finished(id, listHash, remainder); } } /* ---------------------------------------------------------------- abandon */ /// A round executed and never finished burns its pot, once enough time has passed. function abandon(uint256 id) external nonReentrant { Round storage r = rounds[id]; if (r.status == 0) revert NoSuchRound(id); if (r.status != EXECUTED) revert NotExecuted(id, r.status); uint40 until = r.executedAt + ABANDON_AFTER; if (block.timestamp < until) revert TooSoon(until); r.status = ABANDONED; uint256 remainder = r.pot - r.distributed; r.distributed = r.pot; if (remainder != 0) _send(r.asset, DEAD, remainder); emit Abandoned(id, msg.sender, remainder); } /* ------------------------------------------------------------------ views */ /// What an executor wants to know in one call, without decoding the round. function state(uint256 id) external view returns (uint8 status, bool due, bool graceOver, bool abandonable) { Round storage r = rounds[id]; status = r.status; due = status == SCHEDULED && block.timestamp >= r.executeAt; graceOver = status == SCHEDULED && block.timestamp >= uint256(r.executeAt) + GRACE; abandonable = status == EXECUTED && block.timestamp >= uint256(r.executedAt) + ABANDON_AFTER; } /* --------------------------------------------------------------- plumbing */ /** * One swap through the router, from this contract's balance to a * recipient that is not this contract, because the router measures * delivery on the recipient's balance and this contract holds balances * mid swap. The description has to say exactly what the round says. */ function _swap( address tokenIn, address tokenOut, uint256 amountIn, address recipient, IGalaxyRouter.SwapDescription calldata description ) private returns (uint256 amountOut) { if ( description.tokenIn != tokenIn || description.tokenOut != tokenOut || description.amountIn != amountIn || description.recipient != recipient || description.minAmountOut == 0 || description.legs.length == 0 ) revert WrongRoute(); if (tokenIn == NATIVE) { amountOut = router.swap{value: amountIn}(description); } else { // Exactly the amount, consumed by the router in this same call. // Anything left afterwards is withdrawn, so no allowance outlives // the execution that granted it. _approve(tokenIn, address(router), amountIn); amountOut = router.swap(description); if (IERC20(tokenIn).allowance(address(this), address(router)) != 0) { _approve(tokenIn, address(router), 0); } } } function _pull(address token, address from, uint256 amount) private { (bool ok, bytes memory data) = token.call(abi.encodeCall(IERC20.transferFrom, (from, address(this), amount))); if (!ok || (data.length != 0 && !abi.decode(data, (bool)))) revert TransferFailed(); } /// Native or token, to anyone. Tolerates tokens that return nothing from transfer. function _send(address token, address to, uint256 amount) private { if (token == NATIVE) { (bool sent,) = to.call{value: amount}(""); if (!sent) revert TransferFailed(); return; } (bool ok, bytes memory data) = token.call(abi.encodeCall(IERC20.transfer, (to, amount))); if (!ok || (data.length != 0 && !abi.decode(data, (bool)))) revert TransferFailed(); } function _approve(address token, address spender, uint256 amount) private { (bool ok, bytes memory data) = token.call(abi.encodeCall(IERC20.approve, (spender, amount))); if (!ok || (data.length != 0 && !abi.decode(data, (bool)))) revert TransferFailed(); } }