CashCatDividendDistributor

0xca8b8e3ffe1f48a3555059aacbb962bfb668f522

Verification
Verified
v0.8.28+commit.7893614a
Type
Contract
22,052 bytes
ABI entries
62
28 read · 8 write
License
none

Contract information

Address
0xca8b8e3ffe1f48a3555059aacbb962bfb668f522
Chain
Robinhood Chain (4663)
Compiler
v0.8.28+commit.7893614a
Optimization
Enabled
Creation tx
0x00e700beb6…a6f3eae692

Token

Not a token

This contract does not expose ERC-20 metadata.

Read contract (28)

BOUNTY_BPS()uint256
ENROLL_RESERVE_SHARE_BPS()uint256
ENROLL_WINDOW()uint256
FINALIZE_RESERVE_SHARE_BPS()uint256
KEEPER_RESERVE_BPS()uint256
MAX_ADAPTER_DATA()uint256
MAX_ELIGIBLE_HOLDERS()uint256
MAX_SWAP_PER_CALL()uint256
MIN_ROUND_POT()uint256
TIP_PER_ENTRY()uint256
assetPot(bytes32)uint256
claimableOf(bytes32)uint256
configOf(bytes32)tuple
enrollTipReserve(bytes32, uint64)uint256
enrolledBalance(bytes32, uint64, address)uint128
entriesOf(bytes32, uint64)uint256
ethPot(bytes32)uint256
factory()address
finalizeTipReserve(bytes32, uint64)uint256
hook()address
nextStep(bytes32)uint8
payoutTipReserve(bytes32, uint64)uint256
pendingCarry(bytes32)bool
poolManager()address
roundCheckpoint(bytes32, uint64)uint64
rounds(bytes32)uint64, uint8, uint40, uint32, uint32, uint128, uint128, uint128
v3Factory()address
weth()address

Events (5)

EnrolledRegisteredRoundClosedRoundFinalizedRoundStarted

ABI

[
  {
    "inputs": [
      {
        "internalType": "contract IPoolManager",
        "name": "poolManager_",
        "type": "address"
      },
      {
        "internalType": "contract CashCatHook",
        "name": "hook_",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "factory_",
        "type": "address"
      },
      {
        "internalType": "contract IWETH9",
        "name": "weth_",
        "type": "address"
      },
      {
        "internalType": "contract IUniswapV3Factory",
        "name": "v3Factory_",
        "type": "address"
      }
    ],
    "stateMutability": "nonpayable",
    "type": "constructor"
  },
  {
    "inputs": [],
    "name": "AdapterSwapFailed",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "AlreadyRegistered",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "EnrollmentClosed",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "EnrollmentOpen",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "InvalidDividendConfig",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "NoActiveRound",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "NotFactory",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "NotPoolManager",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "NotV3Pool",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "NothingToDistribute",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "PayFailed",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "ReentrancyGuardReentrantCall",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "RoundActive",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "StartedThisBlock",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "SwapInputTooHigh",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "UnknownPool",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "V3OwedTooHigh",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "ValueTooLarge",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "WrongPhase",
    "type": "error"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "PoolId",
        "name": "poolId",
        "type": "bytes32"
      },
      {
        "indexed": true,
        "internalType": "uint64",
        "name": "roundId",
        "type": "uint64"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "added",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "totalEligible",
        "type": "uint256"
      }
    ],
    "name": "Enrolled",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "PoolId",
        "name": "poolId",
        "type": "bytes32"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "token",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "dividendToken",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "uint8",
        "name": "route",
        "type": "uint8"
      },
      {
        "indexed": false,
        "internalType": "uint96",
        "name": "minBalance",
        "type": "uint96"
      }
    ],
    "name": "Registered",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "PoolId",
        "name": "poolId",
        "type": "bytes32"
      },
      {
        "indexed": true,
        "internalType": "uint64",
        "name": "roundId",
        "type": "uint64"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "distributed",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "carried",
        "type": "uint256"
      }
    ],
    "name": "RoundClosed",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "PoolId",
        "name": "poolId",
        "type": "bytes32"
      },
      {
        "indexed": true,
        "internalType": "uint64",
        "name": "roundId",
        "type": "uint64"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "holders",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "totalEligible",
        "type": "uint256"
      }
    ],
    "name": "RoundFinalized",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "PoolId",
        "name": "poolId",
        "type": "bytes32"
      },
      {
        "indexed": true,
        "internalType": "uint64",
        "name": "roundId",
        "type": "uint64"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "potAsset",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "ethConverted",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint40",
        "name": "enrollEnd",
        "type": "uint40"
      }
    ],
    "name": "RoundStarted",
    "type": "event"
  },
  {
    "inputs": [],
    "name": "BOUNTY_BPS",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "ENROLL_RESERVE_SHARE_BPS",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "ENROLL_WINDOW",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "FINALIZE_RESERVE_SHARE_BPS",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "KEEPER_RESERVE_BPS",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "MAX_ADAPTER_DATA",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "MAX_ELIGIBLE_HOLDERS",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "MAX_SWAP_PER_CALL",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "MIN_ROUND_POT",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "TIP_PER_ENTRY",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "PoolId",
        "name": "",
        "type": "bytes32"
      }
    ],
    "name": "assetPot",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "PoolId",
        "name": "poolId",
        "type": "bytes32"
      }
    ],
    "name": "claimableOf",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "PoolId",
        "name": "poolId",
        "type": "bytes32"
      }
    ],
    "name": "configOf",
    "outputs": [
      {
        "components": [
          {
            "internalType": "address",
            "name": "token",
            "type": "address"
          },
          {
            "internalType": "address",
            "name": "dividendToken",
            "type": "address"
          },
          {
            "internalType": "uint96",
            "name": "minBalance",
            "type": "uint96"
          },
          {
            "internalType": "enum CashCatDividendDistributor.Route",
            "name": "route",
            "type": "uint8"
          },
          {
            "internalType": "bool",
            "name": "v3TokenIs0",
            "type": "bool"
          },
          {
            "internalType": "bool",
            "name": "v4InputIs0",
            "type": "bool"
          },
          {
            "internalType": "bool",
            "name": "v4InputIsWeth",
            "type": "bool"
          },
          {
            "components": [
              {
                "internalType": "Currency",
                "name": "currency0",
                "type": "address"
              },
              {
                "internalType": "Currency",
                "name": "currency1",
                "type": "address"
              },
              {
                "internalType": "uint24",
                "name": "fee",
                "type": "uint24"
              },
              {
                "internalType": "int24",
                "name": "tickSpacing",
                "type": "int24"
              },
              {
                "internalType": "contract IHooks",
                "name": "hooks",
                "type": "address"
              }
            ],
            "internalType": "struct PoolKey",
            "name": "v4Key",
            "type": "tuple"
          },
          {
            "internalType": "contract IUniswapV3Pool",
            "name": "v3Pool",
            "type": "address"
          },
          {
            "internalType": "contract IDividendSwapAdapter",
            "name": "adapter",
            "type": "address"
          },
          {
            "internalType": "bytes",
            "name": "adapterData",
            "type": "bytes"
          }
        ],
        "internalType": "struct CashCatDividendDistributor.PoolConfig",
        "name": "",
        "type": "tuple"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "PoolId",
        "name": "poolId",
        "type": "bytes32"
      },
      {
        "internalType": "address[]",
        "name": "holders",
        "type": "address[]"
      }
    ],
    "name": "enroll",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "PoolId",
        "name": "",
        "type": "bytes32"
      },
      {
        "internalType": "uint64",
        "name": "",
        "type": "uint64"
      }
    ],
    "name": "enrollTipReserve",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "PoolId",
        "name": "",
        "type": "bytes32"
      },
      {
        "internalType": "uint64",
        "name": "",
        "type": "uint64"
      },
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "name": "enrolledBalance",
    "outputs": [
      {
        "internalType": "uint128",
        "name": "",
        "type": "uint128"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "PoolId",
        "name": "poolId",
        "type": "bytes32"
      },
      {
        "internalType": "uint64",
        "name": "roundId",
        "type": "uint64"
      }
    ],
    "name": "entriesOf",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "PoolId",
        "name": "",
        "type": "bytes32"
      }
    ],
    "name": "ethPot",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "factory",
    "outputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "PoolId",
        "name": "poolId",
        "type": "bytes32"
      }
    ],
    "name": "finalizeEnrollment",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "PoolId",
        "name": "",
        "type": "bytes32"
      },
      {
        "internalType": "uint64",
        "name": "",
        "type": "uint64"
      }
    ],
    "name": "finalizeTipReserve",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "hook",
    "outputs": [
      {
        "internalType": "contract CashCatHook",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "PoolId",
        "name": "poolId",
        "type": "bytes32"
      }
    ],
    "name": "nextStep",
    "outputs": [
      {
        "internalType": "uint8",
        "name": "",
        "type": "uint8"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "PoolId",
        "name": "poolId",
        "type": "bytes32"
      },
      {
        "internalType": "uint256",
        "name": "maxEntries",
        "type": "uint256"
      }
    ],
    "name": "payout",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "PoolId",
        "name": "",
        "type": "bytes32"
      },
      {
        "internalType": "uint64",
        "name": "",
        "type": "uint64"
      }
    ],
    "name": "payoutTipReserve",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "PoolId",
        "name": "",
        "type": "bytes32"
      }
    ],
    "name": "pendingCarry",
    "outputs": [
      {
        "internalType": "bool",
        "name": "",
        "type": "bool"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "poolManager",
    "outputs": [
      {
        "internalType": "contract IPoolManager",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "PoolId",
        "name": "poolId",
        "type": "bytes32"
      },
      {
        "internalType": "address",
        "name": "token",
        "type": "address"
      },
      {
        "components": [
          {
            "internalType": "address",
            "name": "dividendToken",
            "type": "address"
          },
          {
            "internalType": "uint96",
            "name": "minBalance",
            "type": "uint96"
          },
          {
            "internalType": "enum CashCatDividendDistributor.Route",
            "name": "route",
            "type": "uint8"
          },
          {
            "components": [
              {
                "internalType": "Currency",
                "name": "currency0",
                "type": "address"
              },
              {
                "internalType": "Currency",
                "name": "currency1",
                "type": "address"
              },
              {
                "internalType": "uint24",
                "name": "fee",
                "type": "uint24"
              },
              {
                "internalType": "int24",
                "name": "tickSpacing",
                "type": "int24"
              },
              {
                "internalType": "contract IHooks",
                "name": "hooks",
                "type": "address"
              }
            ],
            "internalType": "struct PoolKey",
            "name": "v4Key",
            "type": "tuple"
          },
          {
            "internalType": "address",
            "name": "v3Pool",
            "type": "address"
          },
          {
            "internalType": "address",
            "name": "adapter",
            "type": "address"
          },
          {
            "internalType": "bytes",
            "name": "adapterData",
            "type": "bytes"
          }
        ],
        "internalType": "struct CashCatDividendDistributor.DividendParams",
        "name": "p",
        "type": "tuple"
      }
    ],
    "name": "register",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "PoolId",
        "name": "",
        "type": "bytes32"
      },
      {
        "internalType": "uint64",
        "name": "",
        "type": "uint64"
      }
    ],
    "name": "roundCheckpoint",
    "outputs": [
      {
        "internalType": "uint64",
        "name": "",
        "type": "uint64"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "PoolId",
        "name": "",
        "type": "bytes32"
      }
    ],
    "name": "rounds",
    "outputs": [
      {
        "internalType": "uint64",
        "name": "id",
        "type": "uint64"
      },
      {
        "internalType": "uint8",
        "name": "phase",
        "type": "uint8"
      },
      {
        "internalType": "uint40",
        "name": "enrollEnd",
        "type": "uint40"
      },
      {
        "internalType": "uint32",
        "name": "count",
        "type": "uint32"
      },
      {
        "internalType": "uint32",
        "name": "paidCursor",
        "type": "uint32"
      },
      {
        "internalType": "uint128",
        "name": "potAsset",
        "type": "uint128"
      },
      {
        "internalType": "uint128",
        "name": "totalEligible",
        "type": "uint128"
      },
      {
        "internalType": "uint128",
        "name": "distributed",
        "type": "uint128"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "PoolId",
        "name": "poolId",
        "type": "bytes32"
      }
    ],
    "name": "selfEnroll",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "PoolId",
        "name": "poolId",
        "type": "bytes32"
      }
    ],
    "name": "startRound",
    "outputs": [
      {
        "internalType": "uint64",
        "name": "roundId",
        "type": "uint64"
      }
    ],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "int256",
        "name": "amount0Delta",
        "type": "int256"
      },
      {
        "internalType": "int256",
        "name": "amount1Delta",
        "type": "int256"
      },
      {
        "internalType": "bytes",
        "name": "",
        "type": "bytes"
      }
    ],
    "name": "uniswapV3SwapCallback",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "bytes",
        "name": "data",
        "type": "bytes"
      }
    ],
    "name": "unlockCallback",
    "outputs": [
      {
        "internalType": "bytes",
        "name": "",
        "type": "bytes"
      }
    ],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "v3Factory",
    "outputs": [
      {
        "internalType": "contract IUniswapV3Factory",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "weth",
    "outputs": [
      {
        "internalType": "contract IWETH9",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "stateMutability": "payable",
    "type": "receive"
  }
]

Source code

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;

import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import {IUniswapV3Pool} from "@uniswap/v3-core/contracts/interfaces/IUniswapV3Pool.sol";
import {IUniswapV3Factory} from "@uniswap/v3-core/contracts/interfaces/IUniswapV3Factory.sol";
import {IUniswapV3SwapCallback} from "@uniswap/v3-core/contracts/interfaces/callback/IUniswapV3SwapCallback.sol";
import {IPoolManager} from "v4-core/src/interfaces/IPoolManager.sol";
import {IUnlockCallback} from "v4-core/src/interfaces/callback/IUnlockCallback.sol";
import {StateLibrary} from "v4-core/src/libraries/StateLibrary.sol";
import {TickMath} from "v4-core/src/libraries/TickMath.sol";
import {PoolKey} from "v4-core/src/types/PoolKey.sol";
import {PoolId, PoolIdLibrary} from "v4-core/src/types/PoolId.sol";
import {Currency} from "v4-core/src/types/Currency.sol";
import {BalanceDelta} from "v4-core/src/types/BalanceDelta.sol";
import {SwapParams} from "v4-core/src/types/PoolOperation.sol";
import {CurrencySettler} from "./lib/CurrencySettler.sol";
import {CashCatHook} from "./CashCatHook.sol";
import {IWETH9} from "./CashCatBuybackBurner.sol";

interface ICashCatCheckpointToken is IERC20 {
    function supportsBalanceCheckpoints() external pure returns (bool);
    function checkpointClock() external view returns (uint64);
    function balanceOfAt(address account, uint64 checkpointId) external view returns (uint256);
}

interface IDividendSwapAdapter {
    function validate(address dividendToken, bytes calldata routeData) external view returns (bool);
    function swap(address dividendToken, bytes calldata routeData) external payable returns (uint256 amountOut);
}

/// @title CashCatDividendDistributor
/// @notice Fee recipient for dividend launches: the creator share of every fee
///         is converted into the token's chosen dividend asset and pushed to
///         eligible holders, pro rata. One contract serves every dividend pool.
///
///         Nobody operates this. Every step of a distribution round is
///         permissionless and pays its caller from the pool's own fees, the
///         same keeper-bounty economics that drive the platform's burns. The
///         contract has no owner, no withdrawal path, and no way to redirect
///         a pool's stream — not for the creator, not for the platform.
///
///         Rounds are two-pass because pro-rata needs its denominator first:
///           1. startRound   claims fees, converts one bounded chunk to the
///                           dividend asset, opens enrollment
///           2. enroll       anyone submits holder addresses in batches; the
///                           contract reads each balance from the token itself
///                           (amounts cannot be forged, duplicates are
///                           rejected, and anyone omitted can selfEnroll)
///           3. finalize     closes enrollment after the window
///           4. payout       pays recorded holders their share, in batches
///
/// @dev The dividend buy carries no slippage guard on purpose (for self-paying
///      tokens, moving the price is the point; for foreign assets the chunk
///      cap does the bounding). Exposure is bounded two ways: each round
///      converts at most MAX_SWAP_PER_CALL of fees, and a pool starts at most
///      one round per block — backlogs drain across rounds rather than
///      becoming one sandwich-sized clip. The dividend asset itself is chosen
///      by the creator at launch and is displayed on every surface; a
///      malicious choice can only ever damage that token's own fee stream.
contract CashCatDividendDistributor is IUnlockCallback, IUniswapV3SwapCallback, ReentrancyGuard {
    using CurrencySettler for Currency;
    using StateLibrary for IPoolManager;
    using PoolIdLibrary for PoolKey;

    error NotFactory();
    error NotPoolManager();
    error NotV3Pool();
    error V3OwedTooHigh();
    error SwapInputTooHigh();
    error AdapterSwapFailed();
    error UnknownPool();
    error AlreadyRegistered();
    error InvalidDividendConfig();
    error RoundActive();
    error NoActiveRound();
    error WrongPhase();
    error EnrollmentClosed();
    error EnrollmentOpen();
    error NothingToDistribute();
    error StartedThisBlock();
    error PayFailed();
    error ValueTooLarge();

    /// @notice How the ETH fee stream becomes the dividend asset.
    enum Route {
        ETH, // no conversion: holders receive native ETH
        V4, // buy on a direct native-ETH/WETH Uniswap v4 pool
        V3, // buy on a Uniswap v3 WETH pool (e.g. CASHCAT)
        ADAPTER // isolated external adapter for V2, multi-hop, curves, and future venues
    }

    struct DividendParams {
        address dividendToken; // what holders receive; address(0) for Route.ETH
        uint96 minBalance; // eligibility floor, in launched-token wei
        Route route;
        PoolKey v4Key; // Route.V4: the pool the dividend asset trades on
        address v3Pool; // Route.V3: the WETH pool the asset trades on
        address adapter; // Route.ADAPTER: isolated route executor
        bytes adapterData; // immutable adapter-specific route description
    }

    struct PoolConfig {
        address token; // the launched cashcat token (eligibility source)
        address dividendToken;
        uint96 minBalance;
        Route route;
        bool v3TokenIs0; // Route.V3: dividend token is token0 of the pool
        bool v4InputIs0; // Route.V4: native ETH/WETH is currency0
        bool v4InputIsWeth; // Route.V4: wrap ETH before settlement
        PoolKey v4Key;
        IUniswapV3Pool v3Pool;
        IDividendSwapAdapter adapter;
        bytes adapterData;
    }

    struct Round {
        uint64 id; // 0 = pool has never run a round
        uint8 phase; // 0 idle, 1 enrolling, 2 paying
        uint40 enrollEnd;
        uint32 count; // entries recorded
        uint32 paidCursor; // entries paid so far
        uint128 potAsset; // amount being distributed this round
        uint128 totalEligible; // sum of enrolled balances
        uint128 distributed; // actually transferred so far
    }

    event Registered(
        PoolId indexed poolId, address indexed token, address indexed dividendToken, uint8 route, uint96 minBalance
    );
    event RoundStarted(
        PoolId indexed poolId, uint64 indexed roundId, uint256 potAsset, uint256 ethConverted, uint40 enrollEnd
    );
    event Enrolled(PoolId indexed poolId, uint64 indexed roundId, uint256 added, uint256 totalEligible);
    event RoundFinalized(PoolId indexed poolId, uint64 indexed roundId, uint256 holders, uint256 totalEligible);
    event RoundClosed(PoolId indexed poolId, uint64 indexed roundId, uint256 distributed, uint256 carried);

    /// @notice A round opens only once this much ETH has accrued — keeps
    ///         bounties worth the gas and rounds worth the noise.
    uint256 public constant MIN_ROUND_POT = 0.005 ether;
    /// @notice Most ETH a single round converts. Bounds what a sandwich around
    ///         one dividend buy can be worth; larger pots drain over rounds.
    uint256 public constant MAX_SWAP_PER_CALL = 0.5 ether;
    /// @notice Share of each round's chunk paid to whoever starts it, in bps.
    uint256 public constant BOUNTY_BPS = 100; // 1%
    /// @notice Additional share reserved for the transactions that must happen
    ///         after `startRound`. Without a dedicated reserve, a normal pot
    ///         below MAX_SWAP_PER_CALL would leave no ETH for enrollment,
    ///         finalization, or payout keepers after the starter consumed the
    ///         whole chunk.
    uint256 public constant KEEPER_RESERVE_BPS = 100; // 1%
    uint256 public constant ENROLL_RESERVE_SHARE_BPS = 2_500; // 25% of reserve
    uint256 public constant FINALIZE_RESERVE_SHARE_BPS = 2_500; // 25% of reserve
    /// @notice Flat ETH tip per holder processed, paid to enroll/payout
    ///         callers from the pool's own fee pot — several times the gas an
    ///         entry costs on this chain, so batch cranking is profitable.
    uint256 public constant TIP_PER_ENTRY = 1e13; // 0.00001 ETH
    /// @notice Enrollment window per round. Batches take seconds; the window
    ///         exists so stragglers can self-enroll before payment locks.
    uint256 public constant ENROLL_WINDOW = 10 minutes;
    /// @notice The eligibility floor must bound a round to at most this many
    ///         full-weight holders. This keeps permissionless cranks and their
    ///         per-entry tips economically bounded.
    uint256 public constant MAX_ELIGIBLE_HOLDERS = 10_000;
    /// @dev Gas forwarded to a dividend transfer — a poison recipient (or
    ///      poison asset) can waste this much, never the whole batch.
    uint256 private constant PAY_GAS_CAP = 100_000;
    uint256 public constant MAX_ADAPTER_DATA = 4096;

    uint160 private constant MIN_SQRT_RATIO_PLUS_1 = 4295128740;
    uint160 private constant MAX_SQRT_RATIO_MINUS_1 = 1461446703485210103287273052203988822378723970341;

    IPoolManager public immutable poolManager;
    CashCatHook public immutable hook;
    address public immutable factory;
    IWETH9 public immutable weth;
    IUniswapV3Factory public immutable v3Factory;

    mapping(PoolId => PoolConfig) private _cfg;
    /// @notice Claimed ETH waiting to be converted, per pool.
    mapping(PoolId => uint256) public ethPot;
    /// @notice Dividend asset waiting for the next round (round carry / dust).
    mapping(PoolId => uint256) public assetPot;
    /// @notice Set when a full pot carried from a zero-eligible round and is
    ///         waiting to be redistributed. Distinguishes a meaningful carry
    ///         (redistribute it) from a normal round's integer-division dust
    ///         (fold into the next real round, never trigger one alone).
    mapping(PoolId => bool) public pendingCarry;
    mapping(PoolId => Round) public rounds;
    mapping(PoolId => mapping(uint64 => address[])) private _entries;
    /// @notice Enrolled snapshot balance for a (pool, round, holder); nonzero
    ///         doubles as the "already enrolled" flag.
    mapping(PoolId => mapping(uint64 => mapping(address => uint128))) public enrolledBalance;
    /// @notice Immutable token-local balance checkpoint used by each round.
    mapping(PoolId => mapping(uint64 => uint64)) public roundCheckpoint;
    /// @notice Dedicated ETH funding for each post-start phase. These buckets
    ///         are round-local and cannot be consumed by a later round.
    mapping(PoolId => mapping(uint64 => uint256)) public enrollTipReserve;
    mapping(PoolId => mapping(uint64 => uint256)) public finalizeTipReserve;
    mapping(PoolId => mapping(uint64 => uint256)) public payoutTipReserve;
    /// @dev block.number tracks the parent chain on an Orbit rollup — coarser
    ///      (stricter) than one L2 block, which only slows drain cadence.
    mapping(PoolId => uint256) private _lastStartBlock;

    /// @dev The v3 swap currently in flight — the ONLY thing the v3 callback
    ///      authenticates against. Set around our own `pool.swap` call and
    ///      cleared after; transient so it never persists past the tx. The
    ///      callback authorising against a caller-supplied address instead of
    ///      this would let anyone claim to be the pool and drain the contract.
    address private transient _activeV3Pool;
    /// @dev WETH allowance remaining for the in-flight v3 swap. Every callback
    ///      consumes it, so even a hostile "pool" making repeated requests can
    ///      waste at most its own round's chunk — never another pool's ETH.
    uint256 private transient _activeV3Remaining;

    constructor(
        IPoolManager poolManager_,
        CashCatHook hook_,
        address factory_,
        IWETH9 weth_,
        IUniswapV3Factory v3Factory_
    ) {
        poolManager = poolManager_;
        hook = hook_;
        factory = factory_;
        weth = weth_;
        v3Factory = v3Factory_;
    }

    // ————————————————————— registration (factory) —————————————————————

    /// @notice The factory wires each dividend pool in at launch, after
    ///         validating nothing — validation lives here so the rules are
    ///         verifiable in one place.
    function register(PoolId poolId, address token, DividendParams calldata p) external {
        if (msg.sender != factory) revert NotFactory();
        if (_cfg[poolId].token != address(0)) revert AlreadyRegistered();
        if (token == address(0)) revert InvalidDividendConfig();

        // Dividend eligibility must come from immutable historical balances.
        // Reject an old/non-CashCat token implementation before the pool can be
        // registered into a round lifecycle that would later be unserviceable.
        try ICashCatCheckpointToken(token).supportsBalanceCheckpoints() returns (bool supported) {
            if (!supported) revert InvalidDividendConfig();
        } catch {
            revert InvalidDividendConfig();
        }
        uint256 supply = IERC20(token).totalSupply();
        if (supply == 0 || supply > type(uint128).max) revert InvalidDividendConfig();
        uint256 minimumFloor = ((supply - 1) / MAX_ELIGIBLE_HOLDERS) + 1;
        if (p.minBalance < minimumFloor) revert InvalidDividendConfig();

        bool v3TokenIs0 = false;
        bool v4InputIs0 = false;
        bool v4InputIsWeth = false;
        if (p.route == Route.ETH) {
            if (
                p.dividendToken != address(0) || p.adapter != address(0) || p.adapterData.length != 0
                    || p.v3Pool != address(0)
            ) revert InvalidDividendConfig();
        } else if (p.route == Route.V4) {
            if (
                p.dividendToken == address(0) || p.adapter != address(0) || p.adapterData.length != 0
                    || p.v3Pool != address(0)
            ) revert InvalidDividendConfig();
            address c0 = Currency.unwrap(p.v4Key.currency0);
            address c1 = Currency.unwrap(p.v4Key.currency1);
            if ((c0 == address(0) || c0 == address(weth)) && c1 == p.dividendToken) {
                v4InputIs0 = true;
                v4InputIsWeth = c0 == address(weth);
            } else if ((c1 == address(0) || c1 == address(weth)) && c0 == p.dividendToken) {
                v4InputIs0 = false;
                v4InputIsWeth = c1 == address(weth);
            } else {
                revert InvalidDividendConfig();
            }
            (uint160 sqrtPriceX96,,,) = poolManager.getSlot0(p.v4Key.toId());
            if (sqrtPriceX96 == 0) revert InvalidDividendConfig();
        } else if (p.route == Route.V3) {
            // Route.V3: accept any live direct WETH pair from the canonical
            // factory. This keeps arbitrary ERC-20 payouts open without
            // trusting a launcher-supplied contract that merely impersonates
            // token0()/token1().
            if (
                p.dividendToken == address(0) || p.v3Pool == address(0) || p.adapter != address(0)
                    || p.adapterData.length != 0
            ) revert InvalidDividendConfig();
            IUniswapV3Pool v3Pool = IUniswapV3Pool(p.v3Pool);
            address t0 = v3Pool.token0();
            address t1 = v3Pool.token1();
            if (t0 == p.dividendToken && t1 == address(weth)) v3TokenIs0 = true;
            else if (t0 == address(weth) && t1 == p.dividendToken) v3TokenIs0 = false;
            else revert InvalidDividendConfig();
            if (
                v3Pool.factory() != address(v3Factory) || v3Factory.getPool(t0, t1, v3Pool.fee()) != p.v3Pool
                    || v3Pool.liquidity() == 0
            ) revert InvalidDividendConfig();
        } else {
            if (
                p.dividendToken == address(0) || p.adapter.code.length == 0 || p.v3Pool != address(0)
                    || p.adapterData.length > MAX_ADAPTER_DATA
            ) revert InvalidDividendConfig();
            try IDividendSwapAdapter(p.adapter).validate(p.dividendToken, p.adapterData) returns (bool valid) {
                if (!valid) revert InvalidDividendConfig();
            } catch {
                revert InvalidDividendConfig();
            }
        }

        _cfg[poolId] = PoolConfig({
            token: token,
            dividendToken: p.dividendToken,
            minBalance: p.minBalance,
            route: p.route,
            v3TokenIs0: v3TokenIs0,
            v4InputIs0: v4InputIs0,
            v4InputIsWeth: v4InputIsWeth,
            v4Key: p.v4Key,
            v3Pool: IUniswapV3Pool(p.v3Pool),
            adapter: IDividendSwapAdapter(p.adapter),
            adapterData: p.adapterData
        });
        emit Registered(poolId, token, p.dividendToken, uint8(p.route), p.minBalance);
    }

    // ————————————————————— the round lifecycle —————————————————————

    /// @notice Claims the pool's accrued fees, converts one bounded chunk into
    ///         the dividend asset, and opens enrollment. Callable by anyone;
    ///         pays the caller 1% of the chunk.
    function startRound(PoolId poolId) external nonReentrant returns (uint64 roundId) {
        PoolConfig storage cfg = _cfg[poolId];
        if (cfg.token == address(0)) revert UnknownPool();
        Round storage r = rounds[poolId];
        if (r.phase != 0) revert RoundActive();
        if (_lastStartBlock[poolId] == block.number) revert StartedThisBlock();
        _lastStartBlock[poolId] = block.number;

        // pull everything claimable into the pool's ETH pot first
        if (hook.pending(poolId) > 0 || hook.tab(poolId) > 0) {
            ethPot[poolId] += hook.claim(poolId); // paid in native ETH
        }
        uint256 pot = ethPot[poolId];
        // a round can also run on a carried pot alone (a zero-eligible round's
        // asset waiting to be redistributed) — but never on dust alone
        if (pot < MIN_ROUND_POT && !pendingCarry[poolId]) revert NothingToDistribute();
        pendingCarry[poolId] = false;

        // Capture eligibility before any dividend-asset conversion. For a
        // reflection route the swap itself moves the launched token, but those
        // newly bought distribution tokens must not alter holder weights.
        uint64 eligibilityCheckpoint = ICashCatCheckpointToken(cfg.token).checkpointClock();

        uint256 converted = 0;
        uint256 bounty = 0;
        uint256 keeperReserve = 0;
        if (pot >= MIN_ROUND_POT) {
            uint256 chunk = pot > MAX_SWAP_PER_CALL ? MAX_SWAP_PER_CALL : pot;
            ethPot[poolId] = pot - chunk;
            bounty = (chunk * BOUNTY_BPS) / 10_000;
            keeperReserve = (chunk * KEEPER_RESERVE_BPS) / 10_000;
            uint256 spend = chunk - bounty - keeperReserve;
            if (cfg.route == Route.ETH) {
                assetPot[poolId] += spend;
                converted = spend;
            } else if (cfg.route == Route.V4) {
                uint256 balanceBefore = IERC20(cfg.dividendToken).balanceOf(address(this));
                if (cfg.v4InputIsWeth) weth.deposit{value: spend}();
                (, uint256 ethSpent) =
                    abi.decode(poolManager.unlock(abi.encode(cfg.v4Key, spend, cfg.v4InputIs0)), (uint256, uint256));
                if (cfg.v4InputIsWeth && ethSpent < spend) weth.withdraw(spend - ethSpent);
                // partial fill (price limit) leaves unspent ETH — back to the pot
                if (ethSpent < spend) ethPot[poolId] += spend - ethSpent;
                // Account only for tokens the distributor actually received.
                // A fee-on-transfer or otherwise unusual ERC-20 may deliver
                // less than the pool's nominal BalanceDelta.
                assetPot[poolId] += IERC20(cfg.dividendToken).balanceOf(address(this)) - balanceBefore;
                converted = ethSpent;
            } else if (cfg.route == Route.V3) {
                (uint256 out, uint256 ethSpent) = _buyV3(cfg, spend);
                assetPot[poolId] += out;
                // A v3 exact-input swap can stop at its price limit before
                // consuming the full amount. The callback's cumulative
                // allowance records exactly what the pool actually pulled;
                // keep the untouched ETH owned by this pool's next round.
                if (ethSpent < spend) ethPot[poolId] += spend - ethSpent;
                converted = ethSpent;
            } else {
                (uint256 out, uint256 ethSpent) = _buyAdapter(cfg, spend);
                assetPot[poolId] += out;
                if (ethSpent < spend) ethPot[poolId] += spend - ethSpent;
                converted = ethSpent;
            }
        }

        uint256 potAsset = assetPot[poolId];
        if (potAsset == 0) revert NothingToDistribute();
        if (potAsset > type(uint128).max) revert ValueTooLarge();
        assetPot[poolId] = 0;

        roundId = r.id + 1;
        roundCheckpoint[poolId][roundId] = eligibilityCheckpoint;
        uint256 enrollReserve = (keeperReserve * ENROLL_RESERVE_SHARE_BPS) / 10_000;
        uint256 finalizeReserve = (keeperReserve * FINALIZE_RESERVE_SHARE_BPS) / 10_000;
        enrollTipReserve[poolId][roundId] = enrollReserve;
        finalizeTipReserve[poolId][roundId] = finalizeReserve;
        payoutTipReserve[poolId][roundId] = keeperReserve - enrollReserve - finalizeReserve;
        r.id = roundId;
        r.phase = 1;
        r.enrollEnd = uint40(block.timestamp + ENROLL_WINDOW);
        r.count = 0;
        r.paidCursor = 0;
        r.potAsset = uint128(potAsset);
        r.totalEligible = 0;
        r.distributed = 0;

        if (bounty > 0) {
            _payKeeper(msg.sender, bounty);
        }
        emit RoundStarted(poolId, roundId, potAsset, converted, r.enrollEnd);
    }

    /// @notice Records holders for the open round. The contract reads every
    ///         balance from the token itself — submitted addresses can be
    ///         incomplete, never inflated. Pays the caller a flat tip per
    ///         newly-enrolled holder, from the pool's own fee pot.
    function enroll(PoolId poolId, address[] calldata holders) external nonReentrant {
        uint256 added = _enroll(poolId, holders);
        if (added > 0) {
            uint256 tip = added * TIP_PER_ENTRY;
            uint64 roundId = rounds[poolId].id;
            uint256 pot = enrollTipReserve[poolId][roundId];
            if (tip > pot) tip = pot;
            if (tip > 0) {
                enrollTipReserve[poolId][roundId] = pot - tip;
                _payKeeper(msg.sender, tip);
            }
        }
    }

    /// @notice Enroll yourself — the censorship escape hatch. No tip.
    function selfEnroll(PoolId poolId) external nonReentrant {
        address[] memory self = new address[](1);
        self[0] = msg.sender;
        _enrollMemory(poolId, self);
    }

    /// @notice Closes enrollment once the window has passed. An empty round
    ///         carries its pot to the next one instead of stranding it.
    function finalizeEnrollment(PoolId poolId) external nonReentrant {
        Round storage r = rounds[poolId];
        if (r.phase != 1) revert WrongPhase();
        if (block.timestamp <= r.enrollEnd) revert EnrollmentOpen();
        uint64 roundId = r.id;
        uint256 finalizerTip = finalizeTipReserve[poolId][roundId];
        finalizeTipReserve[poolId][roundId] = 0;
        if (r.count == 0 || r.totalEligible == 0) {
            assetPot[poolId] += r.potAsset;
            pendingCarry[poolId] = true; // a full pot, not dust — redistribute it
            r.phase = 0;
            emit RoundClosed(poolId, r.id, 0, r.potAsset);
            // With no payout phase, the finalizer also earns every unused
            // post-start reserve and prevents it becoming stranded ETH.
            finalizerTip += enrollTipReserve[poolId][roundId] + payoutTipReserve[poolId][roundId];
            enrollTipReserve[poolId][roundId] = 0;
            payoutTipReserve[poolId][roundId] = 0;
            if (finalizerTip > 0) _payKeeper(msg.sender, finalizerTip);
            return;
        }
        // Enrollment has closed. Any unused enrollment funding now rewards
        // payout callers, especially the caller that closes the round.
        payoutTipReserve[poolId][roundId] += enrollTipReserve[poolId][roundId];
        enrollTipReserve[poolId][roundId] = 0;
        r.phase = 2;
        emit RoundFinalized(poolId, r.id, r.count, r.totalEligible);
        if (finalizerTip > 0) _payKeeper(msg.sender, finalizerTip);
    }

    /// @notice Pays enrolled holders their pro-rata share, up to `maxEntries`
    ///         per call. Anyone can crank; each processed holder tips the
    ///         caller. A transfer that fails is skipped — its share carries to
    ///         the next round, and one poison recipient can never jam a round.
    function payout(PoolId poolId, uint256 maxEntries) external nonReentrant {
        PoolConfig storage cfg = _cfg[poolId];
        Round storage r = rounds[poolId];
        if (r.phase != 2) revert WrongPhase();
        if (maxEntries == 0) revert NothingToDistribute();

        address[] storage entries = _entries[poolId][r.id];
        uint256 end = r.paidCursor + maxEntries;
        if (end > r.count) end = r.count;
        uint256 processed = end - r.paidCursor;
        if (processed == 0) revert NothingToDistribute();

        uint256 distributedNow = 0;
        for (uint256 i = r.paidCursor; i < end; i++) {
            address holder = entries[i];
            uint256 share = (uint256(r.potAsset) * enrolledBalance[poolId][r.id][holder]) / r.totalEligible;
            if (share > 0 && _pay(cfg, holder, share)) {
                distributedNow += share;
            }
        }
        r.paidCursor = uint32(end);
        r.distributed += uint128(distributedNow);

        if (end == r.count) {
            // round complete: whatever wasn't (or couldn't be) paid carries
            uint256 carried = r.potAsset - r.distributed;
            assetPot[poolId] += carried;
            r.phase = 0;
            emit RoundClosed(poolId, r.id, r.distributed, carried);
        }

        uint256 tip = processed * TIP_PER_ENTRY;
        uint256 pot = payoutTipReserve[poolId][r.id];
        if (tip > pot) tip = pot;
        payoutTipReserve[poolId][r.id] = pot - tip;
        // The caller that completes the round receives the unused remainder,
        // so there is always an incentive to advance the final batch.
        if (end == r.count) {
            tip += payoutTipReserve[poolId][r.id];
            payoutTipReserve[poolId][r.id] = 0;
        }
        if (tip > 0) {
            _payKeeper(msg.sender, tip);
        }
    }

    // ————————————————————— internals —————————————————————

    function _enroll(PoolId poolId, address[] calldata holders) private returns (uint256 added) {
        (Round storage r, PoolConfig storage cfg) = _openRound(poolId);
        uint64 id = r.id;
        uint256 eligible = r.totalEligible;
        for (uint256 i = 0; i < holders.length; i++) {
            address holder = holders[i];
            if (_excludedHolder(holder) || enrolledBalance[poolId][id][holder] != 0) continue;
            uint256 bal = ICashCatCheckpointToken(cfg.token).balanceOfAt(holder, roundCheckpoint[poolId][id]);
            if (bal == 0 || bal < cfg.minBalance) continue;
            _entries[poolId][id].push(holder);
            enrolledBalance[poolId][id][holder] = uint128(bal);
            eligible += bal;
            added++;
        }
        if (added > 0) {
            r.totalEligible = uint128(eligible);
            r.count += uint32(added);
            emit Enrolled(poolId, id, added, eligible);
        }
    }

    function _enrollMemory(PoolId poolId, address[] memory holders) private {
        (Round storage r, PoolConfig storage cfg) = _openRound(poolId);
        uint64 id = r.id;
        for (uint256 i = 0; i < holders.length; i++) {
            address holder = holders[i];
            if (_excludedHolder(holder) || enrolledBalance[poolId][id][holder] != 0) continue;
            uint256 bal = ICashCatCheckpointToken(cfg.token).balanceOfAt(holder, roundCheckpoint[poolId][id]);
            if (bal == 0 || bal < cfg.minBalance) continue;
            _entries[poolId][id].push(holder);
            enrolledBalance[poolId][id][holder] = uint128(bal);
            r.totalEligible += uint128(bal);
            r.count += 1;
            emit Enrolled(poolId, id, 1, r.totalEligible);
        }
    }

    function _openRound(PoolId poolId) private view returns (Round storage r, PoolConfig storage cfg) {
        cfg = _cfg[poolId];
        if (cfg.token == address(0)) revert UnknownPool();
        r = rounds[poolId];
        if (r.phase != 1) revert NoActiveRound();
        if (block.timestamp > r.enrollEnd) revert EnrollmentClosed();
    }

    function _excludedHolder(address holder) private view returns (bool) {
        return holder == address(0) || holder == address(poolManager) || holder == address(this);
    }

    function _payKeeper(address to, uint256 amount) private {
        (bool paid,) = to.call{value: amount}("");
        if (!paid) revert PayFailed();
    }

    /// @dev Sends `amount` of the dividend asset with a hard gas cap, so a
    ///      poison recipient or poison asset wastes one slot, not the batch.
    function _pay(PoolConfig storage cfg, address to, uint256 amount) private returns (bool ok) {
        if (cfg.route == Route.ETH) {
            (ok,) = to.call{value: amount, gas: PAY_GAS_CAP}("");
        } else {
            bytes memory data;
            (ok, data) = cfg.dividendToken.call{gas: PAY_GAS_CAP}(abi.encodeCall(IERC20.transfer, (to, amount)));
            if (!ok) return false;
            if (data.length == 0) return true;
            if (data.length < 32) return false;
            uint256 returned;
            assembly ("memory-safe") {
                returned := mload(add(data, 0x20))
            }
            ok = returned == 1;
        }
    }

    // ————————————————————— the buys —————————————————————

    function unlockCallback(bytes calldata data) external returns (bytes memory) {
        if (msg.sender != address(poolManager)) revert NotPoolManager();
        (PoolKey memory key, uint256 ethIn, bool inputIs0) = abi.decode(data, (PoolKey, uint256, bool));
        BalanceDelta delta = poolManager.swap(
            key,
            SwapParams({
                zeroForOne: inputIs0,
                amountSpecified: -int256(ethIn),
                sqrtPriceLimitX96: inputIs0 ? TickMath.MIN_SQRT_PRICE + 1 : TickMath.MAX_SQRT_PRICE - 1
            }),
            ""
        );
        int128 inputDelta = inputIs0 ? delta.amount0() : delta.amount1();
        int128 outputDelta = inputIs0 ? delta.amount1() : delta.amount0();
        if (inputDelta >= 0 || outputDelta <= 0) revert AdapterSwapFailed();
        uint256 out = uint256(uint128(outputDelta));
        uint256 ethSpent = uint256(uint128(-inputDelta));
        // Arbitrary v4 pools may have arbitrary hooks. Even if a hook changes
        // the swap delta, it can never make this pool settle more than the
        // bounded input assigned to its own round.
        if (ethSpent > ethIn) revert SwapInputTooHigh();
        Currency inputCurrency = inputIs0 ? key.currency0 : key.currency1;
        Currency outputCurrency = inputIs0 ? key.currency1 : key.currency0;
        inputCurrency.settle(poolManager, address(this), ethSpent, false);
        outputCurrency.take(poolManager, address(this), out, false);
        return abi.encode(out, ethSpent);
    }

    function _buyAdapter(PoolConfig storage cfg, uint256 ethIn) private returns (uint256 out, uint256 ethSpent) {
        uint256 assetBefore = IERC20(cfg.dividendToken).balanceOf(address(this));
        uint256 ethBefore = address(this).balance;
        (bool ok,) = address(cfg.adapter).call{value: ethIn}(
            abi.encodeCall(IDividendSwapAdapter.swap, (cfg.dividendToken, cfg.adapterData))
        );
        if (!ok) revert AdapterSwapFailed();
        uint256 assetAfter = IERC20(cfg.dividendToken).balanceOf(address(this));
        if (assetAfter <= assetBefore) revert AdapterSwapFailed();
        out = assetAfter - assetBefore;

        // The adapter never receives approval over distributor assets. It gets
        // only this pool's bounded input as call value. Any ETH it refunds is
        // measured and returned to this pool's own pot.
        uint256 balanceFloor = ethBefore - ethIn;
        uint256 refund = address(this).balance > balanceFloor ? address(this).balance - balanceFloor : 0;
        if (refund > ethIn) refund = ethIn;
        ethSpent = ethIn - refund;
    }

    function _buyV3(PoolConfig storage cfg, uint256 ethIn) private returns (uint256 out, uint256 ethSpent) {
        // WETH in, dividend token out; exact input, no price limit (bounded
        // by the chunk cap and the block gate instead). The callback is armed
        // ONLY for the duration of this exact call, and only for this pool.
        bool zeroForOne = !cfg.v3TokenIs0;
        uint256 balanceBefore = IERC20(cfg.dividendToken).balanceOf(address(this));
        _activeV3Pool = address(cfg.v3Pool);
        _activeV3Remaining = ethIn;
        cfg.v3Pool.swap(
            address(this),
            zeroForOne,
            int256(ethIn),
            zeroForOne ? MIN_SQRT_RATIO_PLUS_1 : MAX_SQRT_RATIO_MINUS_1,
            ""
        );
        ethSpent = ethIn - _activeV3Remaining;
        _activeV3Pool = address(0);
        _activeV3Remaining = 0;
        // Trust the balance delta we actually received, not a token's or
        // pool's nominal return value. This keeps accounting correct for
        // supported non-standard ERC-20 transfer behaviour.
        out = IERC20(cfg.dividendToken).balanceOf(address(this)) - balanceBefore;
    }

    /// @dev The v3 pool collects payment here. Authenticated against the swap
    ///      currently in flight — NOT a caller-supplied address — so it is
    ///      reachable only from the exact pool this contract is mid-swap with,
    ///      and can never pay out more than that swap's own input.
    function uniswapV3SwapCallback(int256 amount0Delta, int256 amount1Delta, bytes calldata) external override {
        if (_activeV3Pool == address(0) || msg.sender != _activeV3Pool) revert NotV3Pool();
        uint256 owed = uint256(amount0Delta > 0 ? amount0Delta : amount1Delta);
        uint256 remaining = _activeV3Remaining;
        if (owed > remaining) revert V3OwedTooHigh();
        // Consume the allowance before either external call. A hostile pool may
        // invoke the callback more than once, so bounding each call against the
        // original input is insufficient: all callbacks in this swap must share
        // one cumulative allowance.
        _activeV3Remaining = remaining - owed;
        weth.deposit{value: owed}();
        if (!weth.transfer(msg.sender, owed)) revert PayFailed();
    }

    // ————————————————————— views —————————————————————

    /// @notice Everything a UI needs to drive the crank: 0 = start a round,
    ///         1 = enroll, 2 = finalize, 3 = payout, 4 = nothing to do yet.
    function nextStep(PoolId poolId) external view returns (uint8) {
        if (_cfg[poolId].token == address(0)) return 4; // not a dividend pool
        Round storage r = rounds[poolId];
        if (r.phase == 1) return block.timestamp > r.enrollEnd ? 2 : 1;
        if (r.phase == 2) return 3;
        uint256 claimable = _claimableEth(poolId);
        if (claimable >= MIN_ROUND_POT || pendingCarry[poolId]) return 0;
        return 4;
    }

    /// @notice ETH accrued toward the next round (claimed and unclaimed). Zero
    ///         for a pool this distributor doesn't serve.
    function claimableOf(PoolId poolId) external view returns (uint256) {
        if (_cfg[poolId].token == address(0)) return 0;
        return _claimableEth(poolId);
    }

    /// @dev `pending` is the hook's total unswept trading fee, including the
    ///      platform share. claim() can pay this distributor only the pool's
    ///      creator/dividend share, plus any already-swept tab. Mirror the
    ///      hook's exact integer split so readiness and UI bounty estimates
    ///      cannot overstate what startRound can actually receive.
    function _claimableEth(PoolId poolId) private view returns (uint256) {
        (,, uint16 creatorFeeBps,,,,) = hook.poolConfigs(poolId);
        uint256 unsweptDividendShare = (hook.pending(poolId) * creatorFeeBps) / 10_000;
        return unsweptDividendShare + hook.tab(poolId) + ethPot[poolId];
    }

    function configOf(PoolId poolId) external view returns (PoolConfig memory) {
        return _cfg[poolId];
    }

    function entriesOf(PoolId poolId, uint64 roundId) external view returns (uint256) {
        return _entries[poolId][roundId].length;
    }

    receive() external payable {} // the hook pays claims in native ETH
}
Chain explorer2920msChain node86ms