StockBooster

0x038a7f4e4e89448ad74e044337c9ac25c11e726b

Verification
Verified
v0.8.26+commit.8a97fa7a
Type
Contract
7,519 bytes
ABI entries
63
21 read · 15 write
License
none

Contract information

Address
0x038a7f4e4e89448ad74e044337c9ac25c11e726b
Chain
Robinhood Chain (4663)
Compiler
v0.8.26+commit.8a97fa7a
Optimization
Enabled
Creation tx
0x060b7fc587…9fd0815905

Token

Not a token

This contract does not expose ERC-20 metadata.

Read contract (21)

STOCK_COUNT()uint8
activation()address
allowlistedRouters(address)bool
currentRound()uint256
getStockTokens()address[3]
keepers(address)bool
minDropWei()uint256
minStockPerEth(address)uint256
nft()address
owner()address
paidRound(uint256)uint256
pendingEth()uint256
roundActive()bool
roundAmounts(uint256)uint256
roundCursor()uint256
roundRemaining(uint256)uint256
roundState()uint256, bool, uint256, uint256, uint256[3], uint256[3]
roundStocks(uint256)address
roundTotalWeight()uint256
router()address
stockTokens(uint256)address

Events (11)

BoosterEthRescuedBoosterTokenRescuedDropCancelledDropFinishedDropPaidDropStartedDropTransferSkippedMinStockPerEthUpdatedOwnershipTransferredRouterUpdatedStockTokensUpdated

ABI

[
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "nft_",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "activation_",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "owner_",
        "type": "address"
      }
    ],
    "stateMutability": "nonpayable",
    "type": "constructor"
  },
  {
    "inputs": [],
    "name": "DropBelowThreshold",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "DropRoundInProgress",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "InvalidConfig",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "NoActiveWeight",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "NoDropRoundInProgress",
    "type": "error"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "owner",
        "type": "address"
      }
    ],
    "name": "OwnableInvalidOwner",
    "type": "error"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "account",
        "type": "address"
      }
    ],
    "name": "OwnableUnauthorizedAccount",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "PublicStartNotConfigured",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "ReentrancyGuardReentrantCall",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "RouterNotAllowlisted",
    "type": "error"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "token",
        "type": "address"
      }
    ],
    "name": "SafeERC20FailedOperation",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "StockListNotSet",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "Unauthorized",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "ZeroAddress",
    "type": "error"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "to",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "amount",
        "type": "uint256"
      }
    ],
    "name": "BoosterEthRescued",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "token",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "to",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "amount",
        "type": "uint256"
      }
    ],
    "name": "BoosterTokenRescued",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "uint256",
        "name": "round",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "cursorAtCancel",
        "type": "uint256"
      }
    ],
    "name": "DropCancelled",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "uint256",
        "name": "round",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "recipients",
        "type": "uint256"
      }
    ],
    "name": "DropFinished",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "uint256",
        "name": "round",
        "type": "uint256"
      },
      {
        "indexed": true,
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "wallet",
        "type": "address"
      }
    ],
    "name": "DropPaid",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "uint256",
        "name": "round",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "ethSpent",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "totalWeightSnapshot",
        "type": "uint256"
      }
    ],
    "name": "DropStarted",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "uint256",
        "name": "round",
        "type": "uint256"
      },
      {
        "indexed": true,
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "stock",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "amount",
        "type": "uint256"
      }
    ],
    "name": "DropTransferSkipped",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "stock",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "ratePerEth",
        "type": "uint256"
      }
    ],
    "name": "MinStockPerEthUpdated",
    "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": "router",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "bool",
        "name": "allowed",
        "type": "bool"
      }
    ],
    "name": "RouterUpdated",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "a",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "b",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "c",
        "type": "address"
      }
    ],
    "name": "StockTokensUpdated",
    "type": "event"
  },
  {
    "inputs": [],
    "name": "STOCK_COUNT",
    "outputs": [
      {
        "internalType": "uint8",
        "name": "",
        "type": "uint8"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "activation",
    "outputs": [
      {
        "internalType": "contract IActivationManager",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "name": "allowlistedRouters",
    "outputs": [
      {
        "internalType": "bool",
        "name": "",
        "type": "bool"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "cancelRound",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "maxRecipients",
        "type": "uint256"
      }
    ],
    "name": "continueDrop",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "currentRound",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256[3]",
        "name": "minOuts",
        "type": "uint256[3]"
      },
      {
        "internalType": "uint256",
        "name": "deadline",
        "type": "uint256"
      },
      {
        "internalType": "uint256",
        "name": "maxRecipients",
        "type": "uint256"
      }
    ],
    "name": "executeDrop",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "getStockTokens",
    "outputs": [
      {
        "internalType": "address[3]",
        "name": "",
        "type": "address[3]"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "name": "keepers",
    "outputs": [
      {
        "internalType": "bool",
        "name": "",
        "type": "bool"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "minDropWei",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "name": "minStockPerEth",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "nft",
    "outputs": [
      {
        "internalType": "contract IBrokerTokenBound",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "owner",
    "outputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "name": "paidRound",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "pendingEth",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "renounceOwnership",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "to",
        "type": "address"
      },
      {
        "internalType": "uint256",
        "name": "amount",
        "type": "uint256"
      }
    ],
    "name": "rescueEth",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "tokenAddr",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "to",
        "type": "address"
      },
      {
        "internalType": "uint256",
        "name": "amount",
        "type": "uint256"
      }
    ],
    "name": "rescueToken",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "roundActive",
    "outputs": [
      {
        "internalType": "bool",
        "name": "",
        "type": "bool"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "name": "roundAmounts",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "roundCursor",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "name": "roundRemaining",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "roundState",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "round",
        "type": "uint256"
      },
      {
        "internalType": "bool",
        "name": "active",
        "type": "bool"
      },
      {
        "internalType": "uint256",
        "name": "cursor",
        "type": "uint256"
      },
      {
        "internalType": "uint256",
        "name": "totalWeight",
        "type": "uint256"
      },
      {
        "internalType": "uint256[3]",
        "name": "amounts",
        "type": "uint256[3]"
      },
      {
        "internalType": "uint256[3]",
        "name": "remaining",
        "type": "uint256[3]"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "name": "roundStocks",
    "outputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "roundTotalWeight",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "router",
    "outputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "keeper",
        "type": "address"
      },
      {
        "internalType": "bool",
        "name": "enabled",
        "type": "bool"
      }
    ],
    "name": "setKeeper",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "minDropWei_",
        "type": "uint256"
      }
    ],
    "name": "setMinDropWei",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "stock",
        "type": "address"
      },
      {
        "internalType": "uint256",
        "name": "ratePerEth",
        "type": "uint256"
      }
    ],
    "name": "setMinStockPerEth",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "router_",
        "type": "address"
      }
    ],
    "name": "setRouter",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "router_",
        "type": "address"
      },
      {
        "internalType": "bool",
        "name": "allowed",
        "type": "bool"
      }
    ],
    "name": "setRouterAllowlisted",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address[3]",
        "name": "stocks",
        "type": "address[3]"
      }
    ],
    "name": "setStockTokens",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256[3]",
        "name": "minOuts",
        "type": "uint256[3]"
      },
      {
        "internalType": "uint256",
        "name": "deadline",
        "type": "uint256"
      }
    ],
    "name": "startDrop",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "deadline",
        "type": "uint256"
      }
    ],
    "name": "startDropPublic",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "name": "stockTokens",
    "outputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "newOwner",
        "type": "address"
      }
    ],
    "name": "transferOwnership",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "stateMutability": "payable",
    "type": "receive"
  }
]

Source code

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

import {IERC20} from "openzeppelin-contracts/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol";
import {Ownable} from "openzeppelin-contracts/contracts/access/Ownable.sol";
import {ReentrancyGuard} from "openzeppelin-contracts/contracts/utils/ReentrancyGuard.sol";
import {IActivationManager} from "../interfaces/IActivationManager.sol";
import {IBrokerTokenBound} from "../interfaces/IBrokerTokenBound.sol";
import {IStonkSwapRouter} from "../interfaces/IStonkSwapRouter.sol";
import {
    ZeroAddress,
    Unauthorized,
    RouterNotAllowlisted,
    StockListNotSet,
    NoActiveWeight,
    DropBelowThreshold,
    DropRoundInProgress,
    NoDropRoundInProgress,
    PublicStartNotConfigured,
    InvalidConfig
} from "../libs/Errors.sol";
import {Events} from "../libs/Events.sol";

/// @title StockBooster
/// @notice The pingable reward engine for StonkBrokers. Accrues ETH (70% of every Anvil-market
/// trade fee is forwarded here by StonkNFTAMMVault), and when triggered:
///   1. swaps its ETH balance into the 3 owner-assigned stock tokens in EQUAL THIRDS through an
///      allowlisted router adapter (never arbitrary routers/calldata), then
///   2. push-airdrops those stock tokens into the token-bound wallets of ACTIVATED brokers,
///      pro-rata by activation-tier weight, in bounded pages (never one unbounded loop).
///
/// Exactly 3 stock token addresses are always assigned; the owner can swap which three at any
/// time between drop rounds. Rewards go to the NFT's canonical ERC-6551 wallet (tokenWallet),
/// never to the holder's EOA, so the stock stays with the NFT if it is later sold.
///
/// A drop is a two-phase round to stay gas-bounded:
///   startDrop()    — swap ETH -> 3 stocks, snapshot totalActiveWeight, open the round
///   continueDrop() — pay up to maxRecipients activated brokers per call until the round closes
/// Per-token round tags prevent double-payment even if the active set mutates between pages
/// (activations/transfers mid-round). Anything unpaid (dust, skipped tokens) simply rolls into
/// the next round's pot.
contract StockBooster is Ownable, ReentrancyGuard {
    using SafeERC20 for IERC20;

    uint8 public constant STOCK_COUNT = 3;

    IBrokerTokenBound public immutable nft;
    IActivationManager public immutable activation;

    address[STOCK_COUNT] public stockTokens;
    mapping(address => bool) public allowlistedRouters;
    address public router;

    /// @notice Minimum accrued ETH before a drop may start (keeper spam guard).
    uint256 public minDropWei = 0.01 ether;
    mapping(address => bool) public keepers;

    /// @notice Owner-maintained slippage FLOOR for the permissionless start path, per stock:
    /// the minimum stock-token wei that must come out per 1 ETH (1e18 wei) swapped in. It is the
    /// on-chain guard that makes startDropPublic safe — a public caller cannot choose minOut, so
    /// the swap can never be sandwiched below this rate. 0 = unset => startDropPublic disabled for
    /// that stock (the keeper path is unaffected; it supplies its own off-chain-quoted minOuts).
    /// Set conservatively (e.g. 90-98% of the live two-hop rate) and refresh as price drifts;
    /// if it ever sits ABOVE the true rate the only effect is that public starts revert (no loss)
    /// until refreshed — the keeper can always still start via startDrop.
    mapping(address => uint256) public minStockPerEth;

    // --- Drop round state ---
    uint256 public currentRound; // 0 = never dropped; incremented when a round starts
    bool public roundActive;
    uint256 public roundTotalWeight;
    uint256 public roundCursor;
    address[STOCK_COUNT] public roundStocks;
    uint256[STOCK_COUNT] public roundAmounts; // purchased this round
    uint256[STOCK_COUNT] public roundRemaining; // still undistributed this round
    /// @dev Last round each tokenId was paid in; guards double-pay across pages.
    mapping(uint256 => uint256) public paidRound;

    modifier onlyOwnerOrKeeper() {
        if (msg.sender != owner() && !keepers[msg.sender]) revert Unauthorized();
        _;
    }

    constructor(address nft_, address activation_, address owner_) Ownable(owner_) {
        if (nft_ == address(0) || activation_ == address(0)) revert ZeroAddress();
        nft = IBrokerTokenBound(nft_);
        activation = IActivationManager(activation_);
    }

    /// @notice Accrues ETH fee shares forwarded by the AMM vault (or anyone topping up rewards).
    receive() external payable {}

    // ------------------------------------------------------------------
    // Owner config
    // ------------------------------------------------------------------

    /// @notice Assigns the 3 stock token contracts drops are swapped into. Always exactly 3;
    /// changeable any time between rounds.
    function setStockTokens(address[STOCK_COUNT] calldata stocks) external onlyOwner {
        if (roundActive) revert DropRoundInProgress();
        for (uint256 i = 0; i < STOCK_COUNT; i++) {
            if (stocks[i] == address(0)) revert ZeroAddress();
            for (uint256 j = i + 1; j < STOCK_COUNT; j++) {
                if (stocks[i] == stocks[j]) revert InvalidConfig(); // 3 distinct CAs required
            }
        }
        stockTokens = stocks;
        emit Events.StockTokensUpdated(stocks[0], stocks[1], stocks[2]);
    }

    function setRouterAllowlisted(address router_, bool allowed) external onlyOwner {
        if (router_ == address(0)) revert ZeroAddress();
        allowlistedRouters[router_] = allowed;
        emit Events.RouterUpdated(router_, allowed);
    }

    /// @notice Selects the active router adapter; must already be allowlisted.
    function setRouter(address router_) external onlyOwner {
        if (roundActive) revert DropRoundInProgress();
        if (!allowlistedRouters[router_]) revert RouterNotAllowlisted();
        router = router_;
    }

    function setMinDropWei(uint256 minDropWei_) external onlyOwner {
        minDropWei = minDropWei_;
    }

    function setKeeper(address keeper, bool enabled) external onlyOwner {
        if (keeper == address(0)) revert ZeroAddress();
        keepers[keeper] = enabled;
    }

    /// @notice Sets the permissionless-start slippage floor for `stock` (min stock wei out per
    /// 1 ETH in). Setting all three assigned stocks to a non-zero rate enables startDropPublic;
    /// setting any back to 0 disables it. Owner-only — the floor is the trust anchor for the
    /// public start path, so only governance moves it.
    function setMinStockPerEth(address stock, uint256 ratePerEth) external onlyOwner {
        if (stock == address(0)) revert ZeroAddress();
        minStockPerEth[stock] = ratePerEth;
        emit Events.MinStockPerEthUpdated(stock, ratePerEth);
    }

    // ------------------------------------------------------------------
    // Drop lifecycle
    // ------------------------------------------------------------------

    /// @notice Phase 1 (keeper/owner): swap the whole ETH balance into the 3 assigned stocks
    /// (equal thirds, last leg takes the division remainder) and open a distribution round.
    /// `minOuts` are the keeper's off-chain-quoted slippage floors for each leg. This path stays
    /// gated because it trusts the caller's minOuts — see startDropPublic for the guarded open
    /// anyone may call.
    function startDrop(uint256[STOCK_COUNT] calldata minOuts, uint256 deadline)
        public
        onlyOwnerOrKeeper
        nonReentrant
    {
        uint256[STOCK_COUNT] memory m;
        for (uint256 i = 0; i < STOCK_COUNT; i++) {
            m[i] = minOuts[i];
        }
        _openRound(m, deadline);
    }

    /// @notice Phase 1 (permissionless): identical swap-and-open, but the per-leg minOut is
    /// derived ON-CHAIN from the owner-set `minStockPerEth` floor instead of trusting the caller.
    /// This lets any participant (the frontend gates it to StonkBroker holders) kick off a drop
    /// without the treasury being sandwichable: the caller has no slippage lever to zero out.
    /// Reverts with PublicStartNotConfigured until the owner has set a floor for all 3 stocks.
    function startDropPublic(uint256 deadline) external nonReentrant {
        uint256 balance = address(this).balance;
        uint256[STOCK_COUNT] memory legs = _legSplit(balance);
        uint256[STOCK_COUNT] memory minOuts;
        for (uint256 i = 0; i < STOCK_COUNT; i++) {
            address stock = stockTokens[i];
            uint256 rate = minStockPerEth[stock];
            if (stock == address(0) || rate == 0) revert PublicStartNotConfigured();
            minOuts[i] = (legs[i] * rate) / 1e18;
        }
        _openRound(minOuts, deadline);
    }

    /// @dev Equal-thirds split of `balance`; the last leg absorbs the division remainder. Shared
    /// by _openRound (the actual swap amounts) and startDropPublic (the floor minOut base) so the
    /// two can never drift apart.
    function _legSplit(uint256 balance) internal pure returns (uint256[STOCK_COUNT] memory legs) {
        uint256 third = balance / STOCK_COUNT;
        legs[0] = third;
        legs[1] = third;
        legs[2] = balance - 2 * third;
    }

    /// @dev The swap-and-open core, shared by both start paths. `minOuts` are already resolved by
    /// the caller (keeper quote) or by the on-chain floor (public path); everything else — the
    /// round guards, the equal-thirds swap, the pot snapshot, and the round bookkeeping — is
    /// identical, so both entrypoints share exactly one audited implementation.
    function _openRound(uint256[STOCK_COUNT] memory minOuts, uint256 deadline) internal {
        if (roundActive) revert DropRoundInProgress();
        if (!allowlistedRouters[router]) revert RouterNotAllowlisted();
        uint256 balance = address(this).balance;
        if (balance < minDropWei || balance == 0) revert DropBelowThreshold();
        uint256 totalWeight = activation.totalActiveWeight();
        if (totalWeight == 0) revert NoActiveWeight();

        uint256[STOCK_COUNT] memory legs = _legSplit(balance);

        for (uint256 i = 0; i < STOCK_COUNT; i++) {
            address stock = stockTokens[i];
            if (stock == address(0)) revert StockListNotSet();
            // Defense-in-depth: when a floor is configured, it backstops EVERY start path —
            // even a misconfigured keeper quote of 0 can never swap below the owner-set rate.
            uint256 floorOut = (legs[i] * minStockPerEth[stock]) / 1e18;
            uint256 minOut = minOuts[i] >= floorOut ? minOuts[i] : floorOut;
            IStonkSwapRouter(router).swapExactETHForTokens{value: legs[i]}(stock, minOut, address(this), deadline);
            // Snapshot the full held balance (swap output + any dust left from prior rounds
            // or direct donations) so nothing is ever stranded.
            uint256 pot = IERC20(stock).balanceOf(address(this));
            roundStocks[i] = stock;
            roundAmounts[i] = pot;
            roundRemaining[i] = pot;
        }

        currentRound += 1;
        roundActive = true;
        roundTotalWeight = totalWeight;
        roundCursor = 0;

        emit Events.DropStarted(currentRound, balance, totalWeight);
    }

    /// @notice Phase 2: pays up to `maxRecipients` activated brokers their pro-rata share of
    /// this round's stock, into each NFT's token-bound wallet. Call repeatedly until the round
    /// closes. Shares use tier weight over the snapshot total; per-token round tags prevent
    /// double-pay if the active set shifts between pages, and transfers are capped at the
    /// round's remaining pool so drift can never overdraw. Unpaid remainders roll forward.
    ///
    /// @dev PERMISSIONLESS BY DESIGN: anyone may pay the gas to advance or finish an in-progress
    /// round. This takes no price/slippage parameters (the swap already happened in startDrop),
    /// cannot double-pay (the paidRound guard), and cannot overdraw (the roundRemaining cap), so
    /// there is no economic decision a caller can abuse. Opening it (a) lets brokers claim their
    /// own dividends without waiting on the keeper and (b) is a liveness backstop so a large or
    /// keeper-stalled round can always be finished. Starting a round (the ETH->stock swap) stays
    /// keeper/owner-gated because that IS a price-sensitive action — see startDrop/executeDrop.
    function continueDrop(uint256 maxRecipients) public nonReentrant {
        if (!roundActive) revert NoDropRoundInProgress();
        if (maxRecipients == 0) revert InvalidConfig();

        uint256 round = currentRound;
        uint256 totalWeight = roundTotalWeight;
        uint256 paid = 0;

        while (paid < maxRecipients) {
            uint256 count = activation.activeCount();
            if (roundCursor >= count) break;

            uint256 tokenId = activation.activeTokenAt(roundCursor);
            roundCursor += 1;

            if (paidRound[tokenId] == round) continue; // already paid this round (set shifted)
            paidRound[tokenId] = round;

            uint256 weight = activation.activeWeight(tokenId);
            if (weight == 0) continue;

            address wallet = nft.tokenWallet(tokenId);
            if (wallet == address(0)) continue;

            for (uint256 i = 0; i < STOCK_COUNT; i++) {
                uint256 share = (roundAmounts[i] * weight) / totalWeight;
                if (share > roundRemaining[i]) share = roundRemaining[i];
                if (share == 0) continue;
                // Non-reverting transfer (lesson from The Index's StockDistributor + fee hook
                // stack on Robinhood Chain): real stock tokens can carry transfer restrictions,
                // and one poisoned recipient must never brick the whole round. A failed leg is
                // skipped and its share rolls into the next round's pot.
                if (_tryTransfer(roundStocks[i], wallet, share)) {
                    roundRemaining[i] -= share;
                } else {
                    emit Events.DropTransferSkipped(round, tokenId, roundStocks[i], share);
                }
            }

            emit Events.DropPaid(round, tokenId, wallet);
            paid += 1;
        }

        if (roundCursor >= activation.activeCount()) {
            roundActive = false;
            emit Events.DropFinished(round, roundCursor);
        }
    }

    /// @notice Convenience one-call ping for small active sets: swap then distribute up to
    /// `maxRecipients` in the same transaction (finish any remainder with continueDrop()).
    function executeDrop(uint256[STOCK_COUNT] calldata minOuts, uint256 deadline, uint256 maxRecipients)
        external
        onlyOwnerOrKeeper
    {
        startDrop(minOuts, deadline);
        continueDrop(maxRecipients);
    }

    // ------------------------------------------------------------------
    // Emergency escape hatches (owner only)
    // ------------------------------------------------------------------

    /// @notice Aborts a stuck round (e.g. a stock token that reverts for every recipient).
    /// Already-paid recipients keep what they got; the undistributed remainder stays here and
    /// is swept into the next round's pot by the balance snapshot in startDrop.
    function cancelRound() external onlyOwner {
        if (!roundActive) revert NoDropRoundInProgress();
        roundActive = false;
        emit Events.DropCancelled(currentRound, roundCursor);
    }

    /// @notice Owner rescue for stranded ETH — mirrors StockDistributor.recoverExcess on
    /// Robinhood Chain: an admin valve so funds can never be permanently stuck. Blocked
    /// mid-round so it can't touch a round's committed pot.
    function rescueEth(address to, uint256 amount) external onlyOwner {
        if (roundActive) revert DropRoundInProgress();
        if (to == address(0)) revert ZeroAddress();
        (bool ok,) = payable(to).call{value: amount}("");
        require(ok, "eth send failed");
        emit Events.BoosterEthRescued(to, amount);
    }

    /// @notice Owner rescue for stranded ERC20s (delisted stock tokens, mistaken sends).
    function rescueToken(address tokenAddr, address to, uint256 amount) external onlyOwner {
        if (roundActive) revert DropRoundInProgress();
        if (to == address(0)) revert ZeroAddress();
        IERC20(tokenAddr).safeTransfer(to, amount);
        emit Events.BoosterTokenRescued(tokenAddr, to, amount);
    }

    /// @dev safeTransfer semantics without the revert: returns false on a reverting or
    /// false-returning token instead of aborting the page.
    function _tryTransfer(address tokenAddr, address to, uint256 amount) internal returns (bool) {
        (bool ok, bytes memory data) =
            tokenAddr.call(abi.encodeWithSelector(IERC20.transfer.selector, to, amount));
        return ok && (data.length == 0 || abi.decode(data, (bool)));
    }

    // ------------------------------------------------------------------
    // Views
    // ------------------------------------------------------------------

    function pendingEth() external view returns (uint256) {
        return address(this).balance;
    }

    function getStockTokens() external view returns (address[STOCK_COUNT] memory) {
        return stockTokens;
    }

    function roundState()
        external
        view
        returns (
            uint256 round,
            bool active,
            uint256 cursor,
            uint256 totalWeight,
            uint256[STOCK_COUNT] memory amounts,
            uint256[STOCK_COUNT] memory remaining
        )
    {
        return (currentRound, roundActive, roundCursor, roundTotalWeight, roundAmounts, roundRemaining);
    }
}
Chain explorer1651msChain node84ms