StockFeeConverter

0xe3b703626df7ebca5ac944d38d7ca02c09a93df2

Verification
Verified
v0.8.26+commit.8a97fa7a
Type
Contract
4,161 bytes
ABI entries
12
3 read · 2 write
License
none

Contract information

Address
0xe3b703626df7ebca5ac944d38d7ca02c09a93df2
Chain
Robinhood Chain (4663)
Compiler
v0.8.26+commit.8a97fa7a
Optimization
Enabled
Creation tx
0xf4fa173014…659c704b7d

Token

Not a token

This contract does not expose ERC-20 metadata.

Read contract (3)

poolManager()address
usdg()address
weth()address

Events (1)

Converted

ABI

[
  {
    "inputs": [
      {
        "internalType": "contract IPoolManager",
        "name": "poolManager_",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "usdg_",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "weth_",
        "type": "address"
      }
    ],
    "stateMutability": "nonpayable",
    "type": "constructor"
  },
  {
    "inputs": [],
    "name": "BadHop",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "OnlyPoolManager",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "ReentrancyGuardReentrantCall",
    "type": "error"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "token",
        "type": "address"
      }
    ],
    "name": "SafeERC20FailedOperation",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "Slippage",
    "type": "error"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "stock",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "stockIn",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "wethOut",
        "type": "uint256"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "to",
        "type": "address"
      }
    ],
    "name": "Converted",
    "type": "event"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "stock",
        "type": "address"
      },
      {
        "internalType": "uint256",
        "name": "stockAmount",
        "type": "uint256"
      },
      {
        "internalType": "uint256",
        "name": "minWethOut",
        "type": "uint256"
      },
      {
        "components": [
          {
            "internalType": "Currency",
            "name": "currency0",
            "type": "address"
          },
          {
            "internalType": "Currency",
            "name": "currency1",
            "type": "address"
          },
          {
            "internalType": "uint24",
            "name": "fee",
            "type": "uint24"
          },
          {
            "internalType": "int24",
            "name": "tickSpacing",
            "type": "int24"
          },
          {
            "internalType": "contract IHooks",
            "name": "hooks",
            "type": "address"
          }
        ],
        "internalType": "struct PoolKey",
        "name": "hop1",
        "type": "tuple"
      },
      {
        "components": [
          {
            "internalType": "Currency",
            "name": "currency0",
            "type": "address"
          },
          {
            "internalType": "Currency",
            "name": "currency1",
            "type": "address"
          },
          {
            "internalType": "uint24",
            "name": "fee",
            "type": "uint24"
          },
          {
            "internalType": "int24",
            "name": "tickSpacing",
            "type": "int24"
          },
          {
            "internalType": "contract IHooks",
            "name": "hooks",
            "type": "address"
          }
        ],
        "internalType": "struct PoolKey",
        "name": "hop2",
        "type": "tuple"
      },
      {
        "internalType": "address",
        "name": "to",
        "type": "address"
      }
    ],
    "name": "convert",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "wethOut",
        "type": "uint256"
      }
    ],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "poolManager",
    "outputs": [
      {
        "internalType": "contract IPoolManager",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "bytes",
        "name": "data",
        "type": "bytes"
      }
    ],
    "name": "unlockCallback",
    "outputs": [
      {
        "internalType": "bytes",
        "name": "",
        "type": "bytes"
      }
    ],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "usdg",
    "outputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "weth",
    "outputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  }
]

Source code

// SPDX-License-Identifier: BUSL-1.1
pragma solidity 0.8.26;

import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import {IPoolManager} from "v4-core/interfaces/IPoolManager.sol";
import {IUnlockCallback} from "v4-core/interfaces/callback/IUnlockCallback.sol";
import {PoolKey} from "v4-core/types/PoolKey.sol";
import {Currency} from "v4-core/types/Currency.sol";
import {BalanceDelta} from "v4-core/types/BalanceDelta.sol";
import {SwapParams} from "v4-core/types/PoolOperation.sol";
import {TickMath} from "v4-core/libraries/TickMath.sol";

/// @notice Converts a Stock-Mode fee (skimmed in a tokenized-stock ERC-20) into WETH by swapping two hops
/// DIRECTLY against Uniswap V4 pools: stock → USDG → WETH. Robinhood stocks have no direct WETH pool; their
/// native liquidity is against USDG, and USDG has deep WETH pools, so this two-hop route is the on-chain
/// path. Both hop pools are supplied by the caller (the keeper picks the deepest HOOKLESS pool per hop) and
/// validated here for membership + hooklessness. Stateless / holds no funds between calls; unspent stock is
/// refunded and any USDG dust from an imperfect middle leg is forwarded to `to`. Modeled on SqueezBuyback.
contract StockFeeConverter is IUnlockCallback, ReentrancyGuard {
    using SafeERC20 for IERC20;

    IPoolManager public immutable poolManager;
    /// USDG — the middle hop currency (6 decimals). WETH — the output.
    address public immutable usdg;
    address public immutable weth;

    error OnlyPoolManager();
    error Slippage();
    error BadHop();

    /// @param stock the input stock; `stockIn` actually spent; `wethOut` delivered to `to`.
    event Converted(address indexed stock, uint256 stockIn, uint256 wethOut, address indexed to);

    constructor(IPoolManager poolManager_, address usdg_, address weth_) {
        poolManager = poolManager_;
        usdg = usdg_;
        weth = weth_;
    }

    /// @notice Swap `stockAmount` of `stock` → WETH via `hop1` (stock/USDG) then `hop2` (USDG/WETH), and
    /// send the WETH to `to`. `minWethOut` bounds total slippage. Pulls `stockAmount` from the caller
    /// (needs approval); refunds whatever the first hop can't spend. Both hops must be hookless and contain
    /// the expected currencies. Returns the WETH delivered.
    function convert(
        address stock,
        uint256 stockAmount,
        uint256 minWethOut,
        PoolKey calldata hop1,
        PoolKey calldata hop2,
        address to
    ) external nonReentrant returns (uint256 wethOut) {
        // Validate hops: hop1 == {stock, USDG} hookless; hop2 == {USDG, WETH} hookless. Reverting here
        // (rather than trusting the caller) means a mis-supplied pool can't silently route through a
        // malicious hook or the wrong pair.
        if (address(hop1.hooks) != address(0) || address(hop2.hooks) != address(0)) revert BadHop();
        if (!_isPair(hop1, stock, usdg) || !_isPair(hop2, usdg, weth)) revert BadHop();

        IERC20(stock).safeTransferFrom(msg.sender, address(this), stockAmount);
        (uint256 stockSpent, uint256 out) =
            abi.decode(poolManager.unlock(abi.encode(stock, stockAmount, minWethOut, hop1, hop2, to)), (uint256, uint256));
        wethOut = out;

        uint256 refund = stockAmount - stockSpent;
        if (refund != 0) IERC20(stock).safeTransfer(msg.sender, refund);
        emit Converted(stock, stockSpent, wethOut, to);
    }

    function unlockCallback(bytes calldata data) external returns (bytes memory) {
        if (msg.sender != address(poolManager)) revert OnlyPoolManager();
        (address stock, uint256 stockAmount, uint256 minWethOut, PoolKey memory hop1, PoolKey memory hop2, address to) =
            abi.decode(data, (address, uint256, uint256, PoolKey, PoolKey, address));

        // Hop 1: exact-in stock → USDG.
        (uint256 stockSpent, uint256 usdgOut) = _swapExactIn(hop1, stock, stockAmount);
        // Hop 2: exact-in that USDG → WETH.
        (uint256 usdgSpent, uint256 wethOut) = _swapExactIn(hop2, usdg, usdgOut);
        if (wethOut < minWethOut) revert Slippage();

        // Settle what we owe (the stock spent in hop1) and take what we're owed (WETH out; plus any USDG the
        // middle leg couldn't consume — sent to `to` rather than stranded).
        poolManager.sync(Currency.wrap(stock));
        if (stockSpent != 0) IERC20(stock).safeTransfer(address(poolManager), stockSpent);
        poolManager.settle();
        uint256 usdgDust = usdgOut - usdgSpent; // >= 0 (exact-in can't spend more than supplied)
        if (usdgDust != 0) poolManager.take(Currency.wrap(usdg), to, usdgDust);
        if (wethOut != 0) poolManager.take(Currency.wrap(weth), to, wethOut);

        return abi.encode(stockSpent, wethOut);
    }

    /// @dev Exact-input swap of `tokenIn` through `key`; returns (input spent, output received). Direction is
    /// derived from which currency `tokenIn` is, so callers don't pass zeroForOne.
    function _swapExactIn(PoolKey memory key, address tokenIn, uint256 amountIn)
        private
        returns (uint256 spent, uint256 received)
    {
        if (amountIn == 0) return (0, 0);
        bool zeroForOne = Currency.unwrap(key.currency0) == tokenIn;
        BalanceDelta d = poolManager.swap(
            key,
            SwapParams({
                zeroForOne: zeroForOne,
                amountSpecified: -int256(amountIn),
                sqrtPriceLimitX96: zeroForOne ? TickMath.MIN_SQRT_PRICE + 1 : TickMath.MAX_SQRT_PRICE - 1
            }),
            ""
        );
        // input is the negative leg, output the positive leg.
        if (zeroForOne) {
            spent = uint256(uint128(-d.amount0()));
            received = uint256(uint128(d.amount1()));
        } else {
            spent = uint256(uint128(-d.amount1()));
            received = uint256(uint128(d.amount0()));
        }
    }

    /// @dev True if `key`'s two currencies are exactly {a, b} (order-independent).
    function _isPair(PoolKey memory key, address a, address b) private pure returns (bool) {
        address c0 = Currency.unwrap(key.currency0);
        address c1 = Currency.unwrap(key.currency1);
        return (c0 == a && c1 == b) || (c0 == b && c1 == a);
    }
}
Chain explorer2911msChain node92ms