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
- Creator
- 0xb668382cF4…A809787CDa
- 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