Robinhood MainnetChain ID: 4663
Contract detail

UniV3AnyForkAdapter

UniV3AnyForkAdapter is a verified Robinhood Chain smart contract at 0x3a7d13a4…e3a8913e with 0 indexed transactions.

Address type
Smart contract
Transactions
0
History coverage
No indexed activity found
Indexed token transfers
16
ERC-20 transfers
16
Verification
Verified
Compiler
v0.8.24+commit.e11b9ed9
Verification match
full
Creation transaction
0x7c3c829b…3a50b71f
Created at block
#28608670
Language
solidity
EVM version
cancun
License
none
Compiler optimization
Enabled · 200 runs
Verified contract

Contract source code

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.24;

import {IAdapter} from "../interfaces/IAdapter.sol";
import {TransferLib} from "../libraries/TransferLib.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {BaseAdapter, PoolProvenance} from "./BaseAdapter.sol";

interface IUniV3PoolMinimal {
    function token0() external view returns (address);
    function token1() external view returns (address);
    /// @dev exactInput when amountSpecified > 0. Returns pool-perspective deltas:
    ///      positive = pool received (input owed), negative = pool paid (output).
    function swap(
        address recipient,
        bool zeroForOne,
        int256 amountSpecified,
        uint160 sqrtPriceLimitX96,
        bytes calldata data
    ) external returns (int256 amount0, int256 amount1);
}

/// @title UniV3AnyForkAdapter
/// @notice Superset of `UniV3GenericAdapter`: identical swap path and callback gate, plus a THIRD
///         provenance lane for factories whose registry is keyed on **tickSpacing** rather than fee.
///
///         WHY A SEPARATE CONTRACT, not an edit to UniV3GenericAdapter: that source compiles the LIVE
///         Monad adapter 0x27ed74C9 (Sourcify exact_match). Changing one character of it — even a
///         comment — changes the metadata hash and costs the deployed contract its verification. Same
///         constraint that keeps BaseAdapter.sol frozen. The two files are intentional duplication;
///         fold them together only when Monad's generic adapter is next redeployed anyway.
///
///         Generic direct-pool swap adapter for ANY standard Uniswap-V3-style concentrated-liquidity
///         pool — Uniswap V3, PancakeSwap V3, Pangolin V3, MondayTrade, Pinot, Noxa, Algebra, … . There
///         is NO per-fork SwapRouter: the swap EXECUTES DIRECTLY on the pool via pool.swap() and settles
///         the input token inside the swap callback. ONE deployment serves every current + future V3
///         fork, so a new fork becomes engine discovery-only (no new contract, no new deploy).
/// @dev IMMUTABLE, stateless. PUSH model: NullRouter transfers tokenIn to this adapter BEFORE calling
///      swap(); this adapter NEVER transferFrom's the caller and holds no approvals. The pool pays the
///      output DIRECTLY to `to` (swap recipient), so the adapter ends every swap holding zero of both
///      tokens. minAmountOut / deadline / dust-sweep are enforced globally by NullRouter.
///
///      extraData = abi.encode(address pool) ONLY. Direction is derived on-chain from pool.token0().
///
///      SECURITY — callback gate via TRANSIENT storage (cancun): the pool being swapped is tstore'd
///      immediately before pool.swap and cleared the instant it returns. The swap callback requires
///      msg.sender == the tload'd pool, so it is invokable ONLY by the pool this adapter is actively
///      swapping on, and only within that transaction; outside a swap the slot reads zero and any stray
///      call reverts ("V3_BAD_CALLBACK"). Fork callbacks use DIFFERENT selectors
///      (uniswapV3SwapCallback / pancakeV3SwapCallback / algebraSwapCallback) but share the identical
///      (int256 amount0Delta, int256 amount1Delta, bytes data) ABI, so the named Uniswap callback covers
///      Uniswap-style forks and a `fallback` covers the rest — both settle through one path.
///
///      SECURITY — H1 (2026-08-05 audit): the callback gate above pins WHICH contract may call back,
///      not THAT it is a real pool. Since `pool` came from caller-supplied extraData, a hostile
///      "pool" armed the gate to itself and then named both the token and the amount in `_settle` —
///      the sharpest primitive in the report. Two independent fixes now: `onlyRouter` (BaseAdapter)
///      and CREATE2 PROVENANCE below — the pool must be the canonical CREATE2 address for its own
///      (token0, token1, fee) under one of the fork factories pinned at construction.
contract UniV3AnyForkAdapter is IAdapter, BaseAdapter {
    /// @dev Uniswap V3 sqrt-price bounds. Passing (MIN+1) for zeroForOne / (MAX-1) otherwise means
    ///      "no price limit" → the pool swaps the full exact-input amount.
    uint160 internal constant MIN_SQRT_RATIO_PLUS_1 = 4295128740;
    uint160 internal constant MAX_SQRT_RATIO_MINUS_1 = 1461446703485210103287273052203988822378723970341;

    /// @notice Transient slot holding the pool allowed to invoke the swap callback (zero outside swaps).
    bytes32 private constant EXPECTED_POOL_TSLOT = keccak256("nullterminal.univ3-anyfork.expected-pool");

    /// @notice Fork factories this adapter accepts pools from, and each fork's pool INIT_CODE_HASH.
    /// @dev Parallel arrays, written ONLY in the constructor (no setter, no owner) — "immutable" in
    ///      substance; dynamic arrays cannot use the `immutable` keyword. ONE adapter still serves
    ///      every V3 fork (that is the whole point of this contract), but a new fork now needs its
    ///      (factory, initCodeHash) entry, so it becomes a redeploy instead of discovery-only.
    ///
    ///      initCodeHash semantics — read this before deploying:
    ///      · NON-ZERO → CREATE2 derivation, no external call. The hash DIFFERS per fork and is
    ///        supplied by the deploy script from a VERIFIED value; a wrong hash makes every swap on
    ///        that fork revert (caught by the mandatory both-ways mainnet verification).
    ///      · ZERO → the fork's own on-chain REGISTRY is consulted instead (getPool / poolByPair on
    ///        the pinned factory). Equally authoritative — the factory is pinned either way — and it
    ///        is the honest option when a fork's creation-code hash cannot be verified. NEVER guess a
    ///        hash to avoid the external call; pass zero.
    address[] public factories;
    bytes32[] public initCodeHashes;

    /// @notice Candidate CREATION fee tiers to probe when a pool's CURRENT `fee()` is not its key.
    /// @dev DYNAMIC-FEE FORKS ARE REAL, measured on Monad 2026-08-05: PangolinV3 pool
    ///      0xcd00A33B…97A5 (AUSD/WMON) answers `fee() == 10` today but the factory registered it —
    ///      and salted its address — under **2500**. Deriving provenance from the pool's live fee
    ///      therefore fails for exactly the fork this adapter exists to serve. The creation fee is not
    ///      recoverable from the pool, so the deploy script pins the small set of tiers the engine
    ///      actually discovers (100/500/2500/3000/10000 on Monad) and they are probed after the live
    ///      fee. Probing extra tiers cannot weaken anything: each one derives/looks up a DIFFERENT
    ///      address, and only an exact match against `pool` is accepted.
    uint24[] public feeTiers;

    /// @notice extraData named a pool no pinned factory can be shown to have created.
    error PoolNotCanonical(address pool);
    error FactoryListMismatch();

    constructor(
        address nullRouter_,
        address[] memory factories_,
        bytes32[] memory initCodeHashes_,
        uint24[] memory feeTiers_
    ) BaseAdapter(nullRouter_) {
        if (factories_.length == 0 || factories_.length != initCodeHashes_.length) {
            revert FactoryListMismatch();
        }
        for (uint256 i = 0; i < factories_.length; i++) {
            require(factories_[i] != address(0), "V3_ZERO_FACTORY");
            factories.push(factories_[i]);
            initCodeHashes.push(initCodeHashes_[i]);
        }
        for (uint256 i = 0; i < feeTiers_.length; i++) {
            feeTiers.push(feeTiers_[i]);
        }
    }

    /// @inheritdoc IAdapter
    /// @dev extraData = abi.encode(address pool) — any standard V3-style pool of a pinned fork.
    function swap(
        address tokenIn,
        address tokenOut,
        uint256 amountIn,
        address to,
        bytes calldata extraData
    ) external override onlyRouter returns (uint256 amountOut) {
        // A too-large amountIn would wrap to a negative int256 → the pool would read it as an
        // exact-OUTPUT swap. Reject it (NullRouter never sends anywhere near this).
        require(amountIn <= uint256(type(int256).max), "V3_AMOUNT_OVERFLOW");
        address pool = abi.decode(extraData, (address));

        address token0 = IUniV3PoolMinimal(pool).token0();
        address token1 = IUniV3PoolMinimal(pool).token1();
        // H1 provenance: the pool must derive from a pinned factory. Reading token0/token1 off the
        // pool is safe HERE and only here — the CREATE2 match, not the pool's word, authenticates it.
        _requireCanonicalPool(pool, token0, token1);

        bool zeroForOne = tokenIn == token0;
        // Bind the router's economics to the pool's actual pair: without this a caller could name a
        // real pool whose tokenOut is a different asset than the one the router prices and pays out.
        require(
            zeroForOne ? tokenOut == token1 : (tokenIn == token1 && tokenOut == token0),
            "V3_PAIR_MISMATCH"
        );

        bytes32 tslot = EXPECTED_POOL_TSLOT;
        assembly {
            tstore(tslot, pool)
        }
        // recipient = `to`: the pool pays the output straight to the final receiver.
        (int256 amount0, int256 amount1) = IUniV3PoolMinimal(pool).swap(
            to,
            zeroForOne,
            int256(amountIn),
            zeroForOne ? MIN_SQRT_RATIO_PLUS_1 : MAX_SQRT_RATIO_MINUS_1,
            abi.encode(tokenIn)
        );
        assembly {
            tstore(tslot, 0)
        }

        // Output = the token the pool PAID (its negative delta).
        int256 outDelta = zeroForOne ? amount1 : amount0;
        require(outDelta < 0, "V3_NO_OUTPUT");
        amountOut = uint256(-outDelta);

        // M5: a thin fork pool can exhaust its liquidity and terminate early — the callback then pays
        // only what it consumed and the remainder is stranded on an IMMUTABLE, OWNERLESS adapter with
        // no rescue. Sweep it to `to` (always NullRouter, whose dust-sweep returns it to the user),
        // mirroring LfjPoeAdapter. Chosen over `require(full consumption)` because a partial fill that
        // still clears the user's minAmountOut is a legitimate fill — reverting it would turn a
        // liquidity condition into a failed transaction the user still pays gas for.
        uint256 leftoverIn = IERC20(tokenIn).balanceOf(address(this));
        if (leftoverIn > 0) {
            TransferLib.safeTransfer(tokenIn, to, leftoverIn);
        }
    }

    /// @dev H1: `pool` must be provably created by one of the pinned fork factories, for exactly this
    ///      token pair. Two shapes exist across V3 forks and both are tried per factory:
    ///      · PAIR-KEYED (dynamic-fee / Algebra-style): salt = keccak(token0, token1), or the
    ///        factory's `poolByPair` registry.
    ///      · FEE-KEYED (Uniswap-style): salt = keccak(token0, token1, fee), or `getPool(t0,t1,fee)`.
    ///      For the fee-keyed shape the CREATION fee is what matters and it is NOT always the pool's
    ///      current `fee()` (see `feeTiers` — PangolinV3 on Monad reports 10, was created at 2500), so
    ///      the live fee is tried first and the pinned candidate tiers after. Trying extra shapes or
    ///      tiers cannot weaken the check: each derives/looks up a different address and only an exact
    ///      match against `pool` is accepted.
    function _requireCanonicalPool(address pool, address token0, address token1) internal view {
        uint256 n = factories.length;

        // Pass 1 — pair-keyed shapes (no fee involved at all).
        bytes32 saltNoFee = keccak256(abi.encode(token0, token1));
        for (uint256 i = 0; i < n; i++) {
            bytes32 initCodeHash = initCodeHashes[i];
            if (initCodeHash != bytes32(0)) {
                if (PoolProvenance.computeAddress(factories[i], initCodeHash, saltNoFee) == pool) return;
            } else if (_poolByPairSays(factories[i], token0, token1, pool)) {
                return;
            }
        }

        // Pass 2 — fee-keyed shapes: the pool's live fee first, then the pinned creation-fee tiers.
        // fee() does not exist on every fork; a failed call simply means the live fee is unknown.
        uint24 liveFee = 0;
        (bool ok, bytes memory ret) = pool.staticcall(abi.encodeWithSignature("fee()"));
        if (ok && ret.length >= 32) {
            liveFee = uint24(abi.decode(ret, (uint256)));
            if (_feeKeyedMatch(pool, token0, token1, liveFee)) return;
        }
        uint256 m = feeTiers.length;
        for (uint256 k = 0; k < m; k++) {
            uint24 candidate = feeTiers[k];
            if (candidate == liveFee) continue; // already tried
            if (_feeKeyedMatch(pool, token0, token1, candidate)) return;
        }

        // Pass 3 — TICKSPACING-KEYED registries (Velodrome/Slipstream lineage). This is the lane
        // UniV3GenericAdapter lacks, and the reason this contract exists.
        if (_tickSpacingKeyedMatch(pool, token0, token1)) return;

        revert PoolNotCanonical(pool);
    }

    /// @dev Fee-keyed provenance across every pinned factory, for ONE candidate fee.
    function _feeKeyedMatch(address pool, address token0, address token1, uint24 fee)
        internal
        view
        returns (bool)
    {
        bytes32 salt = keccak256(abi.encode(token0, token1, fee));
        uint256 n = factories.length;
        for (uint256 i = 0; i < n; i++) {
            bytes32 initCodeHash = initCodeHashes[i];
            if (initCodeHash != bytes32(0)) {
                if (PoolProvenance.computeAddress(factories[i], initCodeHash, salt) == pool) return true;
            } else {
                (bool ok, bytes memory ret) = factories[i].staticcall(
                    abi.encodeWithSignature("getPool(address,address,uint24)", token0, token1, fee)
                );
                if (ok && ret.length >= 32 && abi.decode(ret, (address)) == pool) return true;
            }
        }
        return false;
    }

    /// @dev TICKSPACING-KEYED provenance: `getPool(address,address,int24)`, keyed on the pool's tick
    ///      spacing rather than its fee. Measured on Robinhood 4663 (2026-08-05): up_v3, ramses_v3 and
    ///      graildex_v3 answer ONLY this shape — 17 sampled pools matched 17/17 here and 0/17 on the
    ///      uint24-fee lane, and `poolByPair` reverts on all three. Their pools are EIP-1167 clones, so
    ///      there is no creation-code hash to pin either; the factory registry is the only authority.
    ///
    ///      The live `tickSpacing()` is read and used directly, with NO candidate list — deliberately
    ///      unlike `feeTiers`. A dynamic-fee fork can report a `fee()` that differs from the one it was
    ///      salted with (the PangolinV3 lesson), but tick spacing is fixed at pool creation and is the
    ///      registry key itself, so the live value IS the creation value. If a fork ever violates that,
    ///      it fails closed (no match → revert), never open.
    function _tickSpacingKeyedMatch(address pool, address token0, address token1)
        internal
        view
        returns (bool)
    {
        (bool okTs, bytes memory tsRet) = pool.staticcall(abi.encodeWithSignature("tickSpacing()"));
        if (!okTs || tsRet.length < 32) return false;
        int24 spacing = int24(abi.decode(tsRet, (int256)));
        if (spacing == 0) return false;

        uint256 n = factories.length;
        for (uint256 i = 0; i < n; i++) {
            (bool ok, bytes memory ret) = factories[i].staticcall(
                abi.encodeWithSignature("getPool(address,address,int24)", token0, token1, spacing)
            );
            if (ok && ret.length >= 32 && abi.decode(ret, (address)) == pool) return true;
        }
        return false;
    }

    /// @dev Algebra-style registry lookup. staticcall, because a factory that lacks the selector must
    ///      be a no-match — never a revert of the whole swap.
    function _poolByPairSays(address factory, address token0, address token1, address pool)
        internal
        view
        returns (bool)
    {
        (bool ok, bytes memory ret) = factory.staticcall(
            abi.encodeWithSignature("poolByPair(address,address)", token0, token1)
        );
        return ok && ret.length >= 32 && abi.decode(ret, (address)) == pool;
    }

    /// @notice Uniswap V3 swap callback — pays the pool the input it is owed. Uniswap-style forks
    ///         (incl. Pangolin, MondayTrade) call this selector directly.
    function uniswapV3SwapCallback(int256 amount0Delta, int256 amount1Delta, bytes calldata data) external {
        _settle(amount0Delta, amount1Delta, data);
    }

    /// @notice Callback for V3 forks whose callback uses a DIFFERENT selector (e.g. pancakeV3SwapCallback,
    ///         algebraSwapCallback) but the identical (int256,int256,bytes) ABI. Routed here via fallback.
    fallback() external {
        (int256 amount0Delta, int256 amount1Delta, bytes memory data) =
            abi.decode(msg.data[4:], (int256, int256, bytes));
        _settle(amount0Delta, amount1Delta, data);
    }

    /// @dev Shared settlement: gate to the active pool, then push the OWED input (positive delta) to it.
    function _settle(int256 amount0Delta, int256 amount1Delta, bytes memory data) private {
        bytes32 tslot = EXPECTED_POOL_TSLOT;
        address expected;
        assembly {
            expected := tload(tslot)
        }
        require(expected != address(0) && msg.sender == expected, "V3_BAD_CALLBACK");

        // Exactly one delta is positive (the input the pool is owed). data = abi.encode(tokenIn).
        address tokenIn = abi.decode(data, (address));
        uint256 owed = amount0Delta > 0 ? uint256(amount0Delta) : uint256(amount1Delta);
        require(owed > 0, "V3_NOTHING_OWED");
        TransferLib.safeTransfer(tokenIn, msg.sender, owed);
    }
}