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
- Creator
- 0xa2445bd356…7f032a3449
- 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