ClaimRouter

0xba1c08d842f72788e687fc636a0bf83d8571c0c1

Verification
Verified
v0.8.26+commit.8a97fa7a
Type
Contract
6,451 bytes
ABI entries
39
12 read · 6 write
License
mit

Contract information

Address
0xba1c08d842f72788e687fc636a0bf83d8571c0c1
Chain
Robinhood Chain (4663)
Compiler
v0.8.26+commit.8a97fa7a
Optimization
Enabled
Creation tx
0x6bba72749b…93633d805e

Token

Not a token

This contract does not expose ERC-20 metadata.

Read contract (12)

ADD_DELAY()uint256
MAX_STOCKS()uint256
allListed()address[]
core()address
guardian()address
isListed(address)bool
listedCount()uint256
listedStocks(uint256)address
pending(uint256[])uint256
stocks(address)address, uint24, int24, bool, uint40, address
universalRouter()address
weth()address

Events (5)

ClaimedClaimedIntoStockListedStockProposedStockRemoved

ABI

[
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "_core",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "_universalRouter",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "_guardian",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "_weth",
        "type": "address"
      }
    ],
    "stateMutability": "nonpayable",
    "type": "constructor"
  },
  {
    "inputs": [],
    "name": "AlreadyListed",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "AmountTooLarge",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "EthTransferFailed",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "InvalidFee",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "InvalidTickSpacing",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "NotGuardian",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "NotListed",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "NotProposed",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "Reentrancy",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "RegistryFull",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "SelfOnly",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "StockNotListed",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "TimelockPending",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "ZeroAddress",
    "type": "error"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "holder",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "ethOut",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "tokenCount",
        "type": "uint256"
      }
    ],
    "name": "Claimed",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "holder",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "stock",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "ethIn",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "bool",
        "name": "swapped",
        "type": "bool"
      }
    ],
    "name": "ClaimedInto",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "token",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "feed",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "uint24",
        "name": "fee",
        "type": "uint24"
      },
      {
        "indexed": false,
        "internalType": "int24",
        "name": "tickSpacing",
        "type": "int24"
      },
      {
        "indexed": false,
        "internalType": "address",
        "name": "hooks",
        "type": "address"
      }
    ],
    "name": "StockListed",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "token",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "feed",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "uint24",
        "name": "fee",
        "type": "uint24"
      },
      {
        "indexed": false,
        "internalType": "int24",
        "name": "tickSpacing",
        "type": "int24"
      },
      {
        "indexed": false,
        "internalType": "address",
        "name": "hooks",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "addEta",
        "type": "uint256"
      }
    ],
    "name": "StockProposed",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "token",
        "type": "address"
      }
    ],
    "name": "StockRemoved",
    "type": "event"
  },
  {
    "inputs": [],
    "name": "ADD_DELAY",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "MAX_STOCKS",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "allListed",
    "outputs": [
      {
        "internalType": "address[]",
        "name": "",
        "type": "address[]"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256[]",
        "name": "tokenIds",
        "type": "uint256[]"
      }
    ],
    "name": "claim",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "ethOut",
        "type": "uint256"
      }
    ],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256[]",
        "name": "tokenIds",
        "type": "uint256[]"
      },
      {
        "internalType": "address",
        "name": "stockToken",
        "type": "address"
      },
      {
        "internalType": "uint128",
        "name": "minOut",
        "type": "uint128"
      }
    ],
    "name": "claimInto",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "ethIn",
        "type": "uint256"
      },
      {
        "internalType": "bool",
        "name": "swapped",
        "type": "bool"
      }
    ],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "token",
        "type": "address"
      }
    ],
    "name": "commitStock",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "core",
    "outputs": [
      {
        "internalType": "contract IKaleidoCore",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "guardian",
    "outputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "token",
        "type": "address"
      }
    ],
    "name": "isListed",
    "outputs": [
      {
        "internalType": "bool",
        "name": "",
        "type": "bool"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "listedCount",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "name": "listedStocks",
    "outputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256[]",
        "name": "tokenIds",
        "type": "uint256[]"
      }
    ],
    "name": "pending",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "total",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "token",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "feed",
        "type": "address"
      },
      {
        "internalType": "uint24",
        "name": "fee",
        "type": "uint24"
      },
      {
        "internalType": "int24",
        "name": "tickSpacing",
        "type": "int24"
      },
      {
        "internalType": "address",
        "name": "hooks",
        "type": "address"
      }
    ],
    "name": "proposeStock",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "token",
        "type": "address"
      }
    ],
    "name": "removeStock",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "name": "stocks",
    "outputs": [
      {
        "internalType": "address",
        "name": "feed",
        "type": "address"
      },
      {
        "internalType": "uint24",
        "name": "fee",
        "type": "uint24"
      },
      {
        "internalType": "int24",
        "name": "tickSpacing",
        "type": "int24"
      },
      {
        "internalType": "bool",
        "name": "listed",
        "type": "bool"
      },
      {
        "internalType": "uint40",
        "name": "addEta",
        "type": "uint40"
      },
      {
        "internalType": "address",
        "name": "hooks",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "stockToken",
        "type": "address"
      },
      {
        "internalType": "uint128",
        "name": "amountIn",
        "type": "uint128"
      },
      {
        "internalType": "uint128",
        "name": "minOut",
        "type": "uint128"
      },
      {
        "internalType": "address",
        "name": "recipient",
        "type": "address"
      }
    ],
    "name": "swapEthForStock",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "universalRouter",
    "outputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "weth",
    "outputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "stateMutability": "payable",
    "type": "receive"
  }
]

Source code

// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.26;

// ClaimRouter (VEG2-1125) — the v1.1 "choose your dividend" heart. ETH LP fees accrue per Share in the core
// (accEthPerShare + feeDebt, Prism-vanilla). Here each holder picks, AT CLAIM TIME, what their pending ETH
// becomes:
//   • claim(ids)               → paid straight in ETH (always available, ~1 transfer)
//   • claimInto(ids, stock,..) → swapped ETH→stock via the UniversalRouter, delivered to the holder; if the
//                                swap fails for ANY reason (paused token, no liquidity, minOut missed), the
//                                claim falls back to ETH instead of reverting.
// The protocol NEVER custodies a stock token: every swap sends its output directly to the holder (recipient =
// msg.sender), and the router only ever holds ETH transiently within a single claim call.

import { ReentrancyGuard } from "solady/src/utils/ReentrancyGuard.sol";
import { PoolKey } from "@uniswap/v4-core/src/types/PoolKey.sol";
import { Currency } from "@uniswap/v4-core/src/types/Currency.sol";
import { IHooks } from "@uniswap/v4-core/src/interfaces/IHooks.sol";

import { V4SwapLib } from "./V4SwapLib.sol";

/// @dev The slice of the core (KaleidoToken) this router calls. `settleClaim` is router-gated on the core side.
///      `weth()` is read ONCE in the constructor to bind the router's WETH to the core's canonical one (L-03). This
///      interface is local, so widening it does NOT change the emitted ABI (abi-guard proves the freeze holds).
interface IKaleidoCore {
    function settleClaim(uint256[] calldata tokenIds, address holder) external returns (uint256 ethOut);
    function pendingFees(uint256 tokenId) external view returns (uint256);
    function weth() external view returns (address);
}

/// @dev Canonical wrapped native — the L-01 payout fallback wraps ETH to WETH when a contract holder cannot receive
///      native ETH (WETH.transfer only updates balances, so it can never be rejected).
interface IWETH9Router {
    function deposit() external payable;
    function transfer(address to, uint256 amount) external returns (bool);
}

contract ClaimRouter is ReentrancyGuard {
    /*                            ERRORS                          */

    error ZeroAddress();
    error NotGuardian();
    error SelfOnly();
    error AlreadyListed();
    error NotProposed();
    error TimelockPending();
    error RegistryFull();
    error NotListed();
    error StockNotListed();
    error AmountTooLarge();
    error EthTransferFailed();
    error InvalidFee();
    error InvalidTickSpacing();

    /*                            EVENTS                          */

    event StockProposed(
        address indexed token, address indexed feed, uint24 fee, int24 tickSpacing, address hooks, uint256 addEta
    );
    event StockListed(address indexed token, address indexed feed, uint24 fee, int24 tickSpacing, address hooks);
    event StockRemoved(address indexed token);
    event Claimed(address indexed holder, uint256 ethOut, uint256 tokenCount);
    event ClaimedInto(address indexed holder, address indexed stock, uint256 ethIn, bool swapped);

    /*                           CONSTANTS                        */

    /// @dev Registry is a picker, not a treasury — a hard cap keeps the offered set curated and the UI bounded.
    uint256 public constant MAX_STOCKS = 12;
    /// @dev Guardian may REMOVE a stock instantly (safety) but ADD only after this delay (holders can exit / audit
    ///      a newly-proposed pool + feed first). A removed stock is simply no longer offered — nothing is seized.
    uint256 public constant ADD_DELAY = 48 hours;

    /// @dev v4 pool-param bounds enforced at listing — a stock/ETH pool key is VALIDATED and stored per entry, not
    ///      assumed (VEG2-1149; the pools aren't created by us). `fee` is a static LP fee ≤ 100%
    ///      (LPFeeLibrary.MAX_LP_FEE = 1e6; this also rejects the dynamic-fee flag 0x800000, which is > 1e6).
    ///      `tickSpacing` ∈ [1, 32767] (v4 PoolManager bounds). NOTE: v4 (unlike v3) does NOT pin tickSpacing to
    ///      the fee tier, so the exact pairing is the guardian's call — surfaced in the 48h StockProposed event for
    ///      watchers to audit before commit. Hooks are ALLOWED (future-proof compliance hooks on tokenized stocks)
    ///      and likewise surfaced; swap risk is bounded by caller `minOut` + recipient=holder + the ETH fallback.
    ///      The UI MUST always pass a PriceLib-derived `minOut`. (Residual, within that bound: a hook with
    ///      before/afterSwapReturnDelta permissions could strand ≤ the single claim's own `ethIn` on an otherwise-
    ///      successful swap — no cross-holder harm, nothing bricked. Tighten by rejecting those permission bits at
    ///      propose time only if ever needed; not required by the current threat model.)
    uint24 internal constant MAX_LP_FEE = 1_000_000;
    int24 internal constant MAX_TICK_SPACING = 32_767;

    /*                          IMMUTABLES                        */

    IKaleidoCore public immutable core;
    address public immutable universalRouter;
    /// @dev Canonical wrapped native, for the L-01 WETH payout fallback (see `_payHolder`).
    address public immutable weth;
    /// @dev Guardian is immutable — a multisig rotates its own signers, the address is stable. Minimal admin: the
    ///      guardian can only curate the picker set; it can NEVER touch accrual, claims, or the backing ETH.
    address public immutable guardian;

    /*                           REGISTRY                         */

    /// @dev The full v4 PoolKey of the stock/ETH pool travels with the listing. Slot-packed: feed(20) + fee(3) +
    ///      tickSpacing(3) + listed(1) + addEta(5) = 32 bytes in one slot; `hooks` in a second.
    struct Stock {
        address feed; // Chainlink feed — UI quotes / minOut only; never read on-chain in the swap
        uint24 fee; // v4 pool fee of the stock/ETH pool
        int24 tickSpacing; // v4 pool tick spacing
        bool listed; // currently offered by claimInto
        uint40 addEta; // earliest commit time of a pending proposal (0 when listed or none pending)
        address hooks; // v4 pool hooks (address(0) = none; compliance hooks allowed, surfaced in events)
    }

    mapping(address => Stock) public stocks;
    /// @dev Enumerable set of currently-listed tokens (for the picker UI + the MAX_STOCKS cap).
    address[] public listedStocks;
    /// @dev token => (index in listedStocks) + 1; 0 means "not in the array".
    mapping(address => uint256) internal _idxPlus1;

    modifier onlyGuardian() {
        if (msg.sender != guardian) revert NotGuardian();
        _;
    }

    constructor(address _core, address _universalRouter, address _guardian, address _weth) {
        if (_core == address(0) || _universalRouter == address(0) || _guardian == address(0) || _weth == address(0)) {
            revert ZeroAddress();
        }
        // L-03: bind the router's WETH to the core's CANONICAL WETH, so the payout fallback (`_payHolder`) can never
        // wrap into a wrong or hostile token if the router is mis-deployed with a mismatched `_weth`. Both must be a
        // live contract and identical. A require string (not a new custom error) keeps the frozen ABI byte-identical.
        require(_weth.code.length > 0 && _weth == IKaleidoCore(_core).weth(), "weth != core.weth");
        core = IKaleidoCore(_core);
        universalRouter = _universalRouter;
        guardian = _guardian;
        weth = _weth;
    }

    /*                        CLAIM — ETH                         */

    /// @notice Claim the pending ETH of `tokenIds` (all owned by the caller) straight to the caller. Aggregated:
    ///         one call settles a whole multi-Share wallet and pays a single ETH transfer. `nonReentrant`; the
    ///         core enforces ownership and snapshots feeDebt (double-claim yields 0).
    function claim(uint256[] calldata tokenIds) external nonReentrant returns (uint256 ethOut) {
        ethOut = core.settleClaim(tokenIds, msg.sender); // core pushes the ETH to this router
        if (ethOut > 0) _payHolder(msg.sender, ethOut);
        emit Claimed(msg.sender, ethOut, tokenIds.length);
    }

    /*                     CLAIM — INTO STOCK                     */

    /// @notice Claim the pending ETH of `tokenIds` (all owned by the caller) and swap it into `stockToken`,
    ///         delivered directly to the caller. `minOut` (caller-supplied, UI-computed from PriceLib) bounds
    ///         slippage. If the swap fails for any reason, the claim pays out in ETH instead — it never reverts on
    ///         the swap. Returns the ETH settled and whether the swap succeeded. `nonReentrant`.
    function claimInto(uint256[] calldata tokenIds, address stockToken, uint128 minOut)
        external
        nonReentrant
        returns (uint256 ethIn, bool swapped)
    {
        if (!stocks[stockToken].listed) revert StockNotListed();

        ethIn = core.settleClaim(tokenIds, msg.sender); // core pushes the ETH to this router
        if (ethIn == 0) {
            emit ClaimedInto(msg.sender, stockToken, 0, false);
            return (0, false);
        }
        if (ethIn > type(uint128).max) revert AmountTooLarge(); // fee revenue never approaches this; defensive
        // safe: bounded by the check just above.
        // forge-lint: disable-next-line(unsafe-typecast)
        uint128 amtIn = uint128(ethIn);

        // try/catch needs an external call boundary, hence the self-call. On any swap revert the {value:} send is
        // rolled back, so this router still holds `ethIn` and pays it out as an ETH fallback.
        try this.swapEthForStock(stockToken, amtIn, minOut, msg.sender) {
            swapped = true;
        } catch {
            _payHolder(msg.sender, ethIn);
        }
        emit ClaimedInto(msg.sender, stockToken, ethIn, swapped);
    }

    /// @dev Self-only swap step, split out purely so `claimInto` can wrap it in try/catch. ETH (currency0) →
    ///      stock (currency1), recipient = the holder — the router never receives the stock. NOT `nonReentrant`
    ///      (it runs inside claimInto's guard; a second guard on a self-call would always revert).
    function swapEthForStock(address stockToken, uint128 amountIn, uint128 minOut, address recipient) external {
        if (msg.sender != address(this)) revert SelfOnly();
        V4SwapLib.swapExactInSingle(
            universalRouter, _stockKey(stockToken), true, amountIn, minOut, recipient, block.timestamp
        );
    }

    /*                      STOCK REGISTRY                        */

    /// @notice Guardian: propose listing `token` with the FULL v4 PoolKey of its stock/ETH pool (feed + fee +
    ///         tickSpacing + hooks). Committable after ADD_DELAY; the whole key is emitted now so watchers can
    ///         audit fee/hooks during the window. Re-proposing an un-listed token overwrites the key and restarts
    ///         the timer. Correcting a LISTED stock's key = `removeStock` (instant) then propose again (48h) —
    ///         there is deliberately NO instant key-change path (it would let claims be routed to a trap pool with
    ///         no visibility window). Validates the v4 pool-param bounds; hooks may be any address.
    function proposeStock(address token, address feed, uint24 fee, int24 tickSpacing, address hooks)
        external
        onlyGuardian
    {
        if (token == address(0) || feed == address(0)) revert ZeroAddress();
        if (stocks[token].listed) revert AlreadyListed();
        if (fee > MAX_LP_FEE) revert InvalidFee();
        if (tickSpacing < 1 || tickSpacing > MAX_TICK_SPACING) revert InvalidTickSpacing();

        uint256 eta = block.timestamp + ADD_DELAY;
        Stock storage s = stocks[token];
        s.feed = feed;
        s.fee = fee;
        s.tickSpacing = tickSpacing;
        s.hooks = hooks;
        // safe: a unix timestamp + 48h fits uint40 until the year ~36812.
        // forge-lint: disable-next-line(unsafe-typecast)
        s.addEta = uint40(eta);
        emit StockProposed(token, feed, fee, tickSpacing, hooks, eta);
    }

    /// @notice Guardian: finalize a proposal once its timelock has elapsed (enforces the MAX_STOCKS cap).
    function commitStock(address token) external onlyGuardian {
        Stock storage s = stocks[token];
        uint256 eta = s.addEta;
        if (eta == 0) revert NotProposed();
        if (block.timestamp < eta) revert TimelockPending();
        if (s.listed) revert AlreadyListed();
        if (listedStocks.length >= MAX_STOCKS) revert RegistryFull();

        s.listed = true;
        s.addEta = 0;
        _idxPlus1[token] = listedStocks.length + 1;
        listedStocks.push(token);
        emit StockListed(token, s.feed, s.fee, s.tickSpacing, s.hooks);
    }

    /// @notice Guardian: remove `token` from the offered set instantly (no timelock). Holders are unaffected —
    ///         a de-listed stock is simply no longer selectable in `claimInto`.
    function removeStock(address token) external onlyGuardian {
        Stock storage s = stocks[token];
        if (!s.listed) revert NotListed();
        s.listed = false;

        uint256 i = _idxPlus1[token]; // 1-based
        uint256 last = listedStocks.length;
        if (i != last) {
            address moved = listedStocks[last - 1];
            listedStocks[i - 1] = moved;
            _idxPlus1[moved] = i;
        }
        listedStocks.pop();
        _idxPlus1[token] = 0;
        emit StockRemoved(token);
    }

    /*                           VIEWS                            */

    /// @notice Aggregate pending ETH across `tokenIds` (UI helper; each is core.pendingFees).
    function pending(uint256[] calldata tokenIds) external view returns (uint256 total) {
        for (uint256 i; i < tokenIds.length; ++i) {
            total += core.pendingFees(tokenIds[i]);
        }
    }

    function isListed(address token) external view returns (bool) {
        return stocks[token].listed;
    }

    function listedCount() external view returns (uint256) {
        return listedStocks.length;
    }

    function allListed() external view returns (address[] memory) {
        return listedStocks;
    }

    /*                          INTERNALS                         */

    function _stockKey(address stockToken) internal view returns (PoolKey memory) {
        // ETH (address(0)) sorts below any token address, so it is always currency0. The pool params come from the
        // validated listing (VEG2-1149), not a canonical assumption.
        Stock storage s = stocks[stockToken];
        return PoolKey({
            currency0: Currency.wrap(address(0)),
            currency1: Currency.wrap(stockToken),
            fee: s.fee,
            tickSpacing: s.tickSpacing,
            hooks: IHooks(s.hooks)
        });
    }

    /// @dev L-01: pay a holder in native ETH; if that send fails (a contract holder with no/refusing payable
    ///      `receive`), wrap the amount to WETH and transfer that instead — `WETH.transfer` only moves balances (no
    ///      callback), so it cannot be rejected. With the canonical WETH (bound to the core's in the constructor,
    ///      L-03) and ordinary ETH rejection the claim always settles. NB (audit I-02b): NOT unconditional — a
    ///      hostile holder can gas-grief the first (native) attempt into failing so the WETH path runs; that is
    ///      holder-local and retryable, not a cross-holder DoS. Runs inside the caller's `nonReentrant` guard,
    ///      after the core already updated feeDebt (CEI).
    function _payHolder(address to, uint256 amount) private {
        (bool ok,) = to.call{ value: amount }("");
        if (ok) return;
        IWETH9Router(weth).deposit{ value: amount }();
        if (!IWETH9Router(weth).transfer(to, amount)) revert EthTransferFailed();
    }

    /// @dev Accept ETH pushed by core.settleClaim (held only transiently within a claim call).
    receive() external payable { }
}
Chain explorer5356msChain node101ms