StockDividendDistributorV2

0x6682d7a0541261db789441df9bcc48c4c023980e

Verification
Verified
v0.8.26+commit.8a97fa7a
Type
Contract
9,286 bytes
ABI entries
58
14 read · 15 write
License
none

Contract information

Address
0x6682d7a0541261db789441df9bcc48c4c023980e
Chain
Robinhood Chain (4663)
Compiler
v0.8.26+commit.8a97fa7a
Optimization
Enabled
Creation tx
0x4fe4317d1c…bdf944af9a

Token

Not a token

This contract does not expose ERC-20 metadata.

Read contract (14)

BPS()uint256
dividendToken()address
isExcluded(address)bool
isKeeper(address)bool
measureToken()address
owner()address
pendingOwner()address
poolKey()tuple
poolManager()address
poolSet()bool
sellBps()uint256
sellToken()address
totalConverted()uint256
totalDistributed()uint256

Events (11)

BeneficiaryReassignedConvertedDividendExcludedSetHarvestKeeperSetMeasureTokenSetOwnershipTransferStartedOwnershipTransferredPoolConfiguredSellBpsSet

ABI

[
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "_dividendToken",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "_measureToken",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "_poolManager",
        "type": "address"
      }
    ],
    "stateMutability": "nonpayable",
    "type": "constructor"
  },
  {
    "inputs": [],
    "name": "BadBps",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "BadPool",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "BadTotalWeight",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "EmptyHolders",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "ExecFailed",
    "type": "error"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "got",
        "type": "uint256"
      },
      {
        "internalType": "uint256",
        "name": "minOut",
        "type": "uint256"
      }
    ],
    "name": "InsufficientOutput",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "NotArmed",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "NotAuthorized",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "NotOwner",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "NotPoolManager",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "NothingToConvert",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "NothingToDistribute",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "PoolNotSet",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "Reentrancy",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "TransferFailed",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "ZeroWeight",
    "type": "error"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "source",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "bytes32",
        "name": "poolId",
        "type": "bytes32"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "newBeneficiary",
        "type": "address"
      }
    ],
    "name": "BeneficiaryReassigned",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "amountIn",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "amountOut",
        "type": "uint256"
      }
    ],
    "name": "Converted",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "holder",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "amount",
        "type": "uint256"
      }
    ],
    "name": "Dividend",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "account",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "bool",
        "name": "excluded",
        "type": "bool"
      }
    ],
    "name": "ExcludedSet",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "pot",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "paid",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "holdersPaid",
        "type": "uint256"
      }
    ],
    "name": "Harvest",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "keeper",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "bool",
        "name": "enabled",
        "type": "bool"
      }
    ],
    "name": "KeeperSet",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "token",
        "type": "address"
      }
    ],
    "name": "MeasureTokenSet",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "currentOwner",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "pendingOwner",
        "type": "address"
      }
    ],
    "name": "OwnershipTransferStarted",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "previousOwner",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "newOwner",
        "type": "address"
      }
    ],
    "name": "OwnershipTransferred",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "sellToken",
        "type": "address"
      }
    ],
    "name": "PoolConfigured",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "bps",
        "type": "uint256"
      }
    ],
    "name": "SellBpsSet",
    "type": "event"
  },
  {
    "inputs": [],
    "name": "BPS",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "acceptOwnership",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "source",
        "type": "address"
      },
      {
        "internalType": "bytes32",
        "name": "poolId",
        "type": "bytes32"
      }
    ],
    "name": "collectFees",
    "outputs": [
      {
        "internalType": "uint128",
        "name": "fees0",
        "type": "uint128"
      },
      {
        "internalType": "uint128",
        "name": "fees1",
        "type": "uint128"
      }
    ],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "source",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "asset",
        "type": "address"
      }
    ],
    "name": "collectFeesByAsset",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "minOut",
        "type": "uint256"
      }
    ],
    "name": "convert",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "spent",
        "type": "uint256"
      },
      {
        "internalType": "uint256",
        "name": "bought",
        "type": "uint256"
      }
    ],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "dividendToken",
    "outputs": [
      {
        "internalType": "contract IERC20Sdd",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "target",
        "type": "address"
      },
      {
        "internalType": "uint256",
        "name": "value",
        "type": "uint256"
      },
      {
        "internalType": "bytes",
        "name": "data",
        "type": "bytes"
      }
    ],
    "name": "exec",
    "outputs": [
      {
        "internalType": "bytes",
        "name": "",
        "type": "bytes"
      }
    ],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address[]",
        "name": "holders",
        "type": "address[]"
      },
      {
        "internalType": "uint256",
        "name": "totalWeight",
        "type": "uint256"
      }
    ],
    "name": "harvest",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "name": "isExcluded",
    "outputs": [
      {
        "internalType": "bool",
        "name": "",
        "type": "bool"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "name": "isKeeper",
    "outputs": [
      {
        "internalType": "bool",
        "name": "",
        "type": "bool"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "measureToken",
    "outputs": [
      {
        "internalType": "contract IERC20Sdd",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "owner",
    "outputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "pendingOwner",
    "outputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "poolKey",
    "outputs": [
      {
        "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": "",
        "type": "tuple"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "poolManager",
    "outputs": [
      {
        "internalType": "contract IPoolManager",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "poolSet",
    "outputs": [
      {
        "internalType": "bool",
        "name": "",
        "type": "bool"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "source",
        "type": "address"
      },
      {
        "internalType": "bytes32",
        "name": "poolId",
        "type": "bytes32"
      },
      {
        "internalType": "address",
        "name": "newBeneficiary",
        "type": "address"
      }
    ],
    "name": "reassignBeneficiary",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "token",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "to",
        "type": "address"
      },
      {
        "internalType": "uint256",
        "name": "amount",
        "type": "uint256"
      }
    ],
    "name": "rescue",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "sellBps",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "sellToken",
    "outputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "account",
        "type": "address"
      },
      {
        "internalType": "bool",
        "name": "excluded",
        "type": "bool"
      }
    ],
    "name": "setExcluded",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "keeper",
        "type": "address"
      },
      {
        "internalType": "bool",
        "name": "enabled",
        "type": "bool"
      }
    ],
    "name": "setKeeper",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "token",
        "type": "address"
      }
    ],
    "name": "setMeasureToken",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "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": "key",
        "type": "tuple"
      }
    ],
    "name": "setPool",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "bps",
        "type": "uint256"
      }
    ],
    "name": "setSellBps",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "totalConverted",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "totalDistributed",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "newOwner",
        "type": "address"
      }
    ],
    "name": "transferOwnership",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "bytes",
        "name": "data",
        "type": "bytes"
      }
    ],
    "name": "unlockCallback",
    "outputs": [
      {
        "internalType": "bytes",
        "name": "",
        "type": "bytes"
      }
    ],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "stateMutability": "payable",
    "type": "receive"
  }
]

Source code

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

import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol";
import {Currency} from "@uniswap/v4-core/src/types/Currency.sol";
import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol";
import {IUnlockCallback} from "@uniswap/v4-core/src/interfaces/callback/IUnlockCallback.sol";
import {SwapParams} from "@uniswap/v4-core/src/types/PoolOperation.sol";
import {BalanceDelta} from "@uniswap/v4-core/src/types/BalanceDelta.sol";

interface IERC20Sdd {
    function balanceOf(address account) external view returns (uint256);
    function transfer(address recipient, uint256 amount) external returns (bool);
}

/// Doppler DopplerHookInitializer-style fee source (FeesManager)
interface IFeesByPool {
    function collectFees(bytes32 poolId) external returns (uint128 fees0, uint128 fees1);
    function updateBeneficiary(bytes32 poolId, address newBeneficiary) external;
}

/// Doppler RehypeDopplerHook-style fee source
interface IFeesByAsset {
    function collectFees(address asset) external;
}

/// @notice Production fee receiver + dividend distributor for Doppler-launched tokens
/// on Robinhood Chain.
///
/// Registered as the pool's fee beneficiary, it pulls its share via `collectFees`
/// (fees arrive in both pool currencies), converts an owner-tunable percentage of
/// the non-dividend leg into the dividend token through the same v4 pool
/// (`convert`), and pays out its entire dividend-token balance pro-rata to a
/// keeper-supplied holder list weighted by LIVE measure-token balances (`harvest`).
/// The keeper supplies only the list; amounts cannot be forged.
///
/// Escape hatches, because the beneficiary role is irrevocably bound to this
/// address once assigned: `reassignBeneficiary` forwards the role to a successor,
/// and owner-gated `exec`/`rescue` cover any fee-source variant or stray asset.
contract StockDividendDistributorV2 is IUnlockCallback {
    uint256 public constant BPS = 10_000;
    uint160 internal constant MIN_SQRT_PRICE = 4295128739;
    uint160 internal constant MAX_SQRT_PRICE = 1461446703485210103287273052203988822378723970342;

    IERC20Sdd public immutable dividendToken;
    IPoolManager public immutable poolManager;
    IERC20Sdd public measureToken;

    address public owner;
    address public pendingOwner;
    mapping(address => bool) public isKeeper;
    mapping(address => bool) public isExcluded;

    // v4 pool used by convert(); one currency must be the dividend token
    PoolKey internal _poolKey;
    bool public poolSet;
    bool internal _sellIsCurrency0;

    /// Fraction of the sell-token balance sold per convert() call, in bps.
    uint256 public sellBps = 1_000; // 10% default

    uint256 public totalDistributed; // dividend tokens paid out, for UI
    uint256 public totalConverted; // dividend tokens bought via convert, for UI

    bool internal _unlockArmed;
    uint256 private _entered;

    event Harvest(uint256 pot, uint256 paid, uint256 holdersPaid);
    event Dividend(address indexed holder, uint256 amount);
    event Converted(uint256 amountIn, uint256 amountOut);
    event PoolConfigured(address indexed sellToken);
    event SellBpsSet(uint256 bps);
    event KeeperSet(address indexed keeper, bool enabled);
    event ExcludedSet(address indexed account, bool excluded);
    event MeasureTokenSet(address indexed token);
    event BeneficiaryReassigned(address indexed source, bytes32 indexed poolId, address indexed newBeneficiary);
    event OwnershipTransferStarted(address indexed currentOwner, address indexed pendingOwner);
    event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);

    error NotAuthorized();
    error NotOwner();
    error Reentrancy();
    error EmptyHolders();
    error NothingToDistribute();
    error ZeroWeight();
    error BadTotalWeight();
    error TransferFailed();
    error PoolNotSet();
    error BadPool();
    error NothingToConvert();
    error InsufficientOutput(uint256 got, uint256 minOut);
    error NotPoolManager();
    error NotArmed();
    error BadBps();
    error ExecFailed();

    modifier onlyOwner() {
        if (msg.sender != owner) revert NotOwner();
        _;
    }

    modifier onlyOperator() {
        if (!isKeeper[msg.sender] && msg.sender != owner) revert NotAuthorized();
        _;
    }

    modifier nonReentrant() {
        if (_entered != 0) revert Reentrancy();
        _entered = 1;
        _;
        _entered = 0;
    }

    constructor(address _dividendToken, address _measureToken, address _poolManager) {
        require(_dividendToken != address(0), "dividend token is zero address");
        require(_measureToken != address(0), "measure token is zero address");
        require(_poolManager != address(0), "pool manager is zero address");
        dividendToken = IERC20Sdd(_dividendToken);
        measureToken = IERC20Sdd(_measureToken);
        poolManager = IPoolManager(_poolManager);
        owner = msg.sender;
        isKeeper[msg.sender] = true;
    }

    receive() external payable {}

    // ---------- fee collection ----------

    /// @notice Pulls this contract's beneficiary fee share from a Doppler-style fee
    /// source keyed by pool id (e.g. DopplerHookInitializer).
    function collectFees(address source, bytes32 poolId)
        external
        nonReentrant
        onlyOperator
        returns (uint128 fees0, uint128 fees1)
    {
        (fees0, fees1) = IFeesByPool(source).collectFees(poolId);
    }

    /// @notice Same for fee sources keyed by asset address (e.g. RehypeDopplerHook).
    function collectFeesByAsset(address source, address asset) external nonReentrant onlyOperator {
        IFeesByAsset(source).collectFees(asset);
    }

    // ---------- convert ----------

    /// @notice Sells `sellBps` of the current sell-token balance into the dividend
    /// token through the configured v4 pool. `minOut` is the caller's slippage
    /// floor (quote off-chain); the staged percentage keeps any single call from
    /// dumping the full inventory into a thin pool.
    function convert(uint256 minOut) external nonReentrant onlyOperator returns (uint256 spent, uint256 bought) {
        if (!poolSet) revert PoolNotSet();

        Currency sellCurrency = _sellIsCurrency0 ? _poolKey.currency0 : _poolKey.currency1;
        uint256 amountIn = IERC20Sdd(Currency.unwrap(sellCurrency)).balanceOf(address(this)) * sellBps / BPS;
        if (amountIn == 0) revert NothingToConvert();

        _unlockArmed = true;
        bytes memory result = poolManager.unlock(abi.encode(amountIn));
        _unlockArmed = false;

        (spent, bought) = abi.decode(result, (uint256, uint256));
        if (bought < minOut) revert InsufficientOutput(bought, minOut);
        totalConverted += bought;
        emit Converted(spent, bought);
    }

    function unlockCallback(bytes calldata data) external returns (bytes memory) {
        if (msg.sender != address(poolManager)) revert NotPoolManager();
        if (!_unlockArmed) revert NotArmed();

        uint256 amountIn = abi.decode(data, (uint256));
        bool zeroForOne = _sellIsCurrency0;

        BalanceDelta delta = poolManager.swap(
            _poolKey,
            SwapParams({
                zeroForOne: zeroForOne,
                amountSpecified: -int256(amountIn),
                sqrtPriceLimitX96: zeroForOne ? MIN_SQRT_PRICE + 1 : MAX_SQRT_PRICE - 1
            }),
            ""
        );

        // negative delta = we owe the pool (sell side), positive = owed to us
        (int128 d0, int128 d1) = (delta.amount0(), delta.amount1());
        uint256 spent = uint256(uint128(zeroForOne ? -d0 : -d1));
        uint256 bought = uint256(uint128(zeroForOne ? d1 : d0));

        Currency sellCurrency = zeroForOne ? _poolKey.currency0 : _poolKey.currency1;
        Currency buyCurrency = zeroForOne ? _poolKey.currency1 : _poolKey.currency0;

        if (spent > 0) {
            poolManager.sync(sellCurrency);
            if (!IERC20Sdd(Currency.unwrap(sellCurrency)).transfer(address(poolManager), spent)) {
                revert TransferFailed();
            }
            poolManager.settle();
        }
        if (bought > 0) {
            poolManager.take(buyCurrency, address(this), bought);
        }
        return abi.encode(spent, bought);
    }

    // ---------- harvest ----------

    /// @notice Pays the full dividend-token balance to `holders`, weighted by their
    /// live measure-token balances. `totalWeight` = 0 uses the batch's own sum;
    /// otherwise it must be at least the batch sum (multi-batch payouts take only
    /// their fair fraction of the pot).
    function harvest(address[] calldata holders, uint256 totalWeight) external nonReentrant onlyOperator {
        if (holders.length == 0) revert EmptyHolders();

        uint256 pot = dividendToken.balanceOf(address(this));
        if (pot == 0) revert NothingToDistribute();

        uint256[] memory weights = new uint256[](holders.length);
        uint256 batchWeight;
        for (uint256 i = 0; i < holders.length; i++) {
            address holder = holders[i];
            if (holder == address(0) || holder == address(this) || isExcluded[holder]) continue;
            uint256 w = measureToken.balanceOf(holder);
            weights[i] = w;
            batchWeight += w;
        }
        if (batchWeight == 0) revert ZeroWeight();

        uint256 denominator = totalWeight == 0 ? batchWeight : totalWeight;
        if (denominator < batchWeight) revert BadTotalWeight();

        uint256 paid;
        uint256 holdersPaid;
        for (uint256 i = 0; i < holders.length; i++) {
            uint256 w = weights[i];
            if (w == 0) continue;
            uint256 amount = pot * w / denominator;
            if (amount == 0) continue;
            if (!dividendToken.transfer(holders[i], amount)) revert TransferFailed();
            paid += amount;
            holdersPaid++;
            emit Dividend(holders[i], amount);
        }

        totalDistributed += paid;
        emit Harvest(pot, paid, holdersPaid);
    }

    // ---------- configuration ----------

    /// @notice Configures the v4 pool convert() trades through. One currency must
    /// be the dividend token; the other becomes the sell token (ERC-20 only).
    function setPool(PoolKey calldata key) external onlyOwner {
        address c0 = Currency.unwrap(key.currency0);
        address c1 = Currency.unwrap(key.currency1);
        address dividend = address(dividendToken);
        if (c0 != dividend && c1 != dividend) revert BadPool();
        _sellIsCurrency0 = c1 == dividend;
        address sell = _sellIsCurrency0 ? c0 : c1;
        if (sell == address(0)) revert BadPool(); // native sell side unsupported
        _poolKey = key;
        poolSet = true;
        emit PoolConfigured(sell);
    }

    function poolKey() external view returns (PoolKey memory) {
        return _poolKey;
    }

    function sellToken() public view returns (address) {
        if (!poolSet) return address(0);
        return Currency.unwrap(_sellIsCurrency0 ? _poolKey.currency0 : _poolKey.currency1);
    }

    function setSellBps(uint256 bps) external onlyOwner {
        if (bps == 0 || bps > BPS) revert BadBps();
        sellBps = bps;
        emit SellBpsSet(bps);
    }

    function setKeeper(address keeper, bool enabled) external onlyOwner {
        isKeeper[keeper] = enabled;
        emit KeeperSet(keeper, enabled);
    }

    function setExcluded(address account, bool excluded) external onlyOwner {
        isExcluded[account] = excluded;
        emit ExcludedSet(account, excluded);
    }

    function setMeasureToken(address token) external onlyOwner {
        require(token != address(0), "measure token is zero address");
        measureToken = IERC20Sdd(token);
        emit MeasureTokenSet(token);
    }

    // ---------- escape hatches ----------

    /// @notice Forwards this contract's beneficiary role on a Doppler fee source to
    /// a successor (the role binds to whoever holds it; without this it is stuck
    /// here forever).
    function reassignBeneficiary(address source, bytes32 poolId, address newBeneficiary) external onlyOwner {
        IFeesByPool(source).updateBeneficiary(poolId, newBeneficiary);
        emit BeneficiaryReassigned(source, poolId, newBeneficiary);
    }

    /// @notice Owner escape hatch for fee-source variants this ABI doesn't cover.
    /// The owner can already move every asset via rescue(), so this adds no new
    /// trust assumption.
    function exec(address target, uint256 value, bytes calldata data) external onlyOwner returns (bytes memory) {
        (bool ok, bytes memory ret) = target.call{value: value}(data);
        if (!ok) revert ExecFailed();
        return ret;
    }

    /// @notice Pulls any token (including the dividend token) or ETH out.
    function rescue(address token, address to, uint256 amount) external onlyOwner {
        if (token == address(0)) {
            (bool ok,) = to.call{value: amount}("");
            if (!ok) revert TransferFailed();
        } else {
            if (!IERC20Sdd(token).transfer(to, amount)) revert TransferFailed();
        }
    }

    // ---------- ownership (two-step) ----------

    function transferOwnership(address newOwner) external onlyOwner {
        require(newOwner != address(0), "new owner is zero address");
        pendingOwner = newOwner;
        emit OwnershipTransferStarted(owner, newOwner);
    }

    function acceptOwnership() external {
        if (msg.sender != pendingOwner) revert NotAuthorized();
        emit OwnershipTransferred(owner, msg.sender);
        owner = msg.sender;
        pendingOwner = address(0);
    }
}
Chain explorer1948msChain node80ms