StonkStockRouterV2

0xa88f54239be5124affea441eddf423bb138b00c4

Verification
Verified
v0.8.26+commit.8a97fa7a
Type
Contract
4,537 bytes
ABI entries
26
3 read · 8 write
License
mit

Contract information

Address
0xa88f54239be5124affea441eddf423bb138b00c4
Chain
Robinhood Chain (4663)
Compiler
v0.8.26+commit.8a97fa7a
Optimization
Enabled
Creation tx
0x019ad3b8c4…73bf5a524d

Token

Not a token

This contract does not expose ERC-20 metadata.

Read contract (3)

owner()address
routes(address)address, address, address, uint8, bool, bool
weth()address

Events (4)

OwnershipTransferredRouteClearedRouteSetSwappedToStock

ABI

[
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "weth_",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "owner_",
        "type": "address"
      }
    ],
    "stateMutability": "nonpayable",
    "type": "constructor"
  },
  {
    "inputs": [],
    "name": "BadCallback",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "Expired",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "InvalidConfig",
    "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": "ReentrancyGuardReentrantCall",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "RouteNotSet",
    "type": "error"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "token",
        "type": "address"
      }
    ],
    "name": "SafeERC20FailedOperation",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "SlippageExceeded",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "ZeroAddress",
    "type": "error"
  },
  {
    "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": "stock",
        "type": "address"
      }
    ],
    "name": "RouteCleared",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "stock",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "address",
        "name": "midPool",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "address",
        "name": "midToken",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "address",
        "name": "stockPool",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "enum StonkStockRouterV2.PoolKind",
        "name": "stockPoolKind",
        "type": "uint8"
      },
      {
        "indexed": false,
        "internalType": "bool",
        "name": "midZeroForOne",
        "type": "bool"
      },
      {
        "indexed": false,
        "internalType": "bool",
        "name": "stockZeroForOne",
        "type": "bool"
      }
    ],
    "name": "RouteSet",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "stock",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "ethIn",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "midOut",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "stockOut",
        "type": "uint256"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "to",
        "type": "address"
      }
    ],
    "name": "SwappedToStock",
    "type": "event"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "stock",
        "type": "address"
      }
    ],
    "name": "clearRoute",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "owner",
    "outputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "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": "token",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "to",
        "type": "address"
      },
      {
        "internalType": "uint256",
        "name": "amount",
        "type": "uint256"
      }
    ],
    "name": "rescueToken",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "name": "routes",
    "outputs": [
      {
        "internalType": "address",
        "name": "midPool",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "midToken",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "stockPool",
        "type": "address"
      },
      {
        "internalType": "enum StonkStockRouterV2.PoolKind",
        "name": "stockPoolKind",
        "type": "uint8"
      },
      {
        "internalType": "bool",
        "name": "midZeroForOne",
        "type": "bool"
      },
      {
        "internalType": "bool",
        "name": "stockZeroForOne",
        "type": "bool"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "stock",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "midPool",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "midToken",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "stockPool",
        "type": "address"
      },
      {
        "internalType": "enum StonkStockRouterV2.PoolKind",
        "name": "kind",
        "type": "uint8"
      }
    ],
    "name": "setRoute",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "tokenOut",
        "type": "address"
      },
      {
        "internalType": "uint256",
        "name": "minOut",
        "type": "uint256"
      },
      {
        "internalType": "address",
        "name": "to",
        "type": "address"
      },
      {
        "internalType": "uint256",
        "name": "deadline",
        "type": "uint256"
      }
    ],
    "name": "swapExactETHForTokens",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "amountOut",
        "type": "uint256"
      }
    ],
    "stateMutability": "payable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "newOwner",
        "type": "address"
      }
    ],
    "name": "transferOwnership",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "int256",
        "name": "amount0Delta",
        "type": "int256"
      },
      {
        "internalType": "int256",
        "name": "amount1Delta",
        "type": "int256"
      },
      {
        "internalType": "bytes",
        "name": "",
        "type": "bytes"
      }
    ],
    "name": "uniswapV3SwapCallback",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "weth",
    "outputs": [
      {
        "internalType": "contract IWETHMinimal",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  }
]

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 {IStonkSwapRouter} from "../interfaces/IStonkSwapRouter.sol";
import {ZeroAddress, InvalidConfig, SlippageExceeded} from "../libs/Errors.sol";

interface IWETHMinimal {
    function deposit() external payable;
    function transfer(address to, uint256 amount) external returns (bool);
    function withdraw(uint256 amount) external;
}

/// @dev Swap surface of a canonical Uniswap v3 pool (WETH/USDG mid pool AND the newer
/// v3-style stock pools like USDG/SLV on Robinhood Chain).
interface IUniswapV3PoolMinimal {
    function token0() external view returns (address);
    function token1() external view returns (address);
    function swap(
        address recipient,
        bool zeroForOne,
        int256 amountSpecified,
        uint160 sqrtPriceLimitX96,
        bytes calldata data
    ) external returns (int256 amount0, int256 amount1);
}

/// @dev The original Rialto stock pools (USDG <-> tokenized stock) expose swapExactIn —
/// decoded from RialtoSwap's live buys (selector 0x2203d44a). The pool pulls tokenIn via
/// transferFrom and pushes tokenOut to `to`.
interface IRialtoStockPool {
    function token0() external view returns (address);
    function token1() external view returns (address);
    function swapExactIn(bool zeroForOne, uint256 amountIn, uint256 minAmountOut, address to, uint256 deadline)
        external
        returns (uint256 amountOut);
}

/// @title StonkStockRouterV2
/// @notice Allowlisted swap adapter for the StonkBrokers boosters on Robinhood Chain, superseding
/// StonkStockRouter. Same fixed IStonkSwapRouter surface and the same two-hop path
///
///   ETH -> WETH (wrap) -> mid token (Uniswap v3 pool) -> stock (stock pool)
///
/// but the SECOND hop now supports BOTH stock-pool kinds live on Robinhood Chain:
///
///   PoolKind.Rialto — the original swapExactIn pools (AAPL / AMZN / NVDA, …)
///   PoolKind.V3     — canonical Uniswap-v3-style pools (SLV, and newer listings)
///
/// so new stocks route through whichever venue actually holds their liquidity, with one
/// owner setRoute() call and no booster changes. Routes stay fully owner-adjustable.
contract StonkStockRouterV2 is IStonkSwapRouter, Ownable, ReentrancyGuard {
    using SafeERC20 for IERC20;

    /// @dev Uniswap v3 sqrt price bounds (TickMath.MIN_SQRT_RATIO / MAX_SQRT_RATIO).
    uint160 private constant MIN_SQRT_RATIO_PLUS_ONE = 4295128740;
    uint160 private constant MAX_SQRT_RATIO_MINUS_ONE = 1461446703485210103287273052203988822378723970341;

    enum PoolKind {
        Rialto,
        V3
    }

    struct Route {
        address midPool; // Uniswap v3 pool holding WETH + midToken
        address midToken; // intermediate token (USDG today)
        address stockPool; // pool holding midToken + the stock
        PoolKind stockPoolKind; // which swap interface the stock pool speaks
        bool midZeroForOne; // hop-1 direction: WETH is midPool.token0
        bool stockZeroForOne; // hop-2 direction: midToken is stockPool.token0
    }

    IWETHMinimal public immutable weth;

    /// @notice stock token => its two-hop route. Fully owner-adjustable at any time.
    mapping(address => Route) public routes;

    /// @dev The only v3 pool allowed to hit the swap callback, and the token this router owes
    /// it — set transiently around each v3 hop (WETH for hop 1, midToken for a V3 hop 2).
    address private _expectedPool;
    address private _payToken;

    error RouteNotSet();
    error Expired();
    error BadCallback();

    event RouteSet(
        address indexed stock,
        address midPool,
        address midToken,
        address stockPool,
        PoolKind stockPoolKind,
        bool midZeroForOne,
        bool stockZeroForOne
    );
    event RouteCleared(address indexed stock);
    event SwappedToStock(address indexed stock, uint256 ethIn, uint256 midOut, uint256 stockOut, address indexed to);

    constructor(address weth_, address owner_) Ownable(owner_) {
        if (weth_ == address(0)) revert ZeroAddress();
        weth = IWETHMinimal(weth_);
    }

    // ------------------------------------------------------------------
    // Owner config — the adjustable path
    // ------------------------------------------------------------------

    /// @notice Points `stock` at a two-hop route ending in a `kind` pool. Both hops are
    /// validated against the pools' actual token pairs (both pool kinds expose token0/token1)
    /// so a fat-fingered address can never silently mis-route funds; swap directions are
    /// derived here once instead of on every swap.
    function setRoute(address stock, address midPool, address midToken, address stockPool, PoolKind kind)
        external
        onlyOwner
    {
        if (stock == address(0) || midPool == address(0) || midToken == address(0) || stockPool == address(0)) {
            revert ZeroAddress();
        }

        address m0 = IUniswapV3PoolMinimal(midPool).token0();
        address m1 = IUniswapV3PoolMinimal(midPool).token1();
        bool midZeroForOne;
        if (m0 == address(weth) && m1 == midToken) midZeroForOne = true;
        else if (m1 == address(weth) && m0 == midToken) midZeroForOne = false;
        else revert InvalidConfig();

        address s0 = IUniswapV3PoolMinimal(stockPool).token0();
        address s1 = IUniswapV3PoolMinimal(stockPool).token1();
        bool stockZeroForOne;
        if (s0 == midToken && s1 == stock) stockZeroForOne = true;
        else if (s1 == midToken && s0 == stock) stockZeroForOne = false;
        else revert InvalidConfig();

        routes[stock] = Route({
            midPool: midPool,
            midToken: midToken,
            stockPool: stockPool,
            stockPoolKind: kind,
            midZeroForOne: midZeroForOne,
            stockZeroForOne: stockZeroForOne
        });
        emit RouteSet(stock, midPool, midToken, stockPool, kind, midZeroForOne, stockZeroForOne);
    }

    function clearRoute(address stock) external onlyOwner {
        delete routes[stock];
        emit RouteCleared(stock);
    }

    /// @notice Owner rescue for any dust stranded here (partial fills, direct sends). The
    /// router holds nothing between swaps by design.
    function rescueToken(address token, address to, uint256 amount) external onlyOwner {
        if (to == address(0)) revert ZeroAddress();
        IERC20(token).safeTransfer(to, amount);
    }

    function rescueEth(address to, uint256 amount) external onlyOwner {
        if (to == address(0)) revert ZeroAddress();
        (bool ok,) = payable(to).call{value: amount}("");
        require(ok, "eth send failed");
    }

    // ------------------------------------------------------------------
    // IStonkSwapRouter
    // ------------------------------------------------------------------

    /// @inheritdoc IStonkSwapRouter
    function swapExactETHForTokens(address tokenOut, uint256 minOut, address to, uint256 deadline)
        external
        payable
        nonReentrant
        returns (uint256 amountOut)
    {
        if (to == address(0)) revert ZeroAddress();
        if (block.timestamp > deadline) revert Expired();
        Route memory route = routes[tokenOut];
        if (route.stockPool == address(0)) revert RouteNotSet();

        // Hop 0: wrap.
        weth.deposit{value: msg.value}();

        // Hop 1: WETH -> midToken directly against the v3 pool; we pay WETH in the callback.
        uint256 midOut = _v3Swap(route.midPool, address(weth), route.midZeroForOne, msg.value);

        // Hop 2: midToken -> stock via whichever pool kind holds the liquidity. Output is
        // delivered to this router and measured by balance delta (stock tokens on Robinhood
        // use scaled-UI proxies, so trust balances over return values), then forwarded —
        // guaranteeing "at least minOut to `to`".
        uint256 balBefore = IERC20(tokenOut).balanceOf(address(this));
        if (route.stockPoolKind == PoolKind.Rialto) {
            IERC20(route.midToken).forceApprove(route.stockPool, midOut);
            IRialtoStockPool(route.stockPool).swapExactIn(route.stockZeroForOne, midOut, minOut, address(this), deadline);
        } else if (route.stockPoolKind == PoolKind.V3) {
            _v3Swap(route.stockPool, route.midToken, route.stockZeroForOne, midOut);
        } else {
            revert InvalidConfig(); // unreachable; exhaustive over PoolKind
        }
        amountOut = IERC20(tokenOut).balanceOf(address(this)) - balBefore;

        if (amountOut < minOut) revert SlippageExceeded();
        IERC20(tokenOut).safeTransfer(to, amountOut);

        emit SwappedToStock(tokenOut, msg.value, midOut, amountOut, to);
    }

    /// @dev Exact-in swap against a canonical v3 pool, paying `payToken` in the callback.
    /// Returns the output amount reported by the pool's deltas.
    function _v3Swap(address pool, address payToken, bool zeroForOne, uint256 amountIn)
        internal
        returns (uint256 amountOut)
    {
        _expectedPool = pool;
        _payToken = payToken;
        (int256 amount0, int256 amount1) = IUniswapV3PoolMinimal(pool).swap(
            address(this),
            zeroForOne,
            int256(amountIn),
            zeroForOne ? MIN_SQRT_RATIO_PLUS_ONE : MAX_SQRT_RATIO_MINUS_ONE,
            ""
        );
        _expectedPool = address(0);
        _payToken = address(0);
        amountOut = uint256(-(zeroForOne ? amount1 : amount0));
    }

    // ------------------------------------------------------------------
    // Uniswap v3 callback (hop 1 + V3 hop 2 settlement)
    // ------------------------------------------------------------------

    function uniswapV3SwapCallback(int256 amount0Delta, int256 amount1Delta, bytes calldata) external {
        if (msg.sender != _expectedPool || _expectedPool == address(0)) revert BadCallback();
        uint256 owed = uint256(amount0Delta > 0 ? amount0Delta : amount1Delta);
        IERC20(_payToken).safeTransfer(msg.sender, owed);
    }
}
Chain explorer1769msChain node77ms