AlpsRouterV4

0x80ec5fb21a894a884a7f0e8fdfc4ec315cf1fd91

Verification
Verified
v0.8.26+commit.8a97fa7a
Type
Contract
7,447 bytes
ABI entries
19
9 read · 1 write
License
none

Contract information

Address
0x80ec5fb21a894a884a7f0e8fdfc4ec315cf1fd91
Chain
Robinhood Chain (4663)
Compiler
v0.8.26+commit.8a97fa7a
Optimization
Enabled
Creation tx
0x2656b5c633…d14f0fb10c

Token

Not a token

This contract does not expose ERC-20 metadata.

Read contract (9)

BPS()uint256
PLATFORM_FEE_BPS()uint256
feeSink()address
mintedBy(uint256)address
permit2()address
poolId(tuple)bytes32
posm()address
stateView()address
universalRouter()address

Events (1)

ZapMintedV4

ABI

[
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "posm_",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "universalRouter_",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "stateView_",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "permit2_",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "feeSink_",
        "type": "address"
      }
    ],
    "stateMutability": "nonpayable",
    "type": "constructor"
  },
  {
    "inputs": [],
    "name": "BadParams",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "NativeMismatch",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "ReentrancyGuardReentrantCall",
    "type": "error"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "token",
        "type": "address"
      }
    ],
    "name": "SafeERC20FailedOperation",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "SlippageOrDust",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "T",
    "type": "error"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "user",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "address",
        "name": "tokenIn",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "amountIn",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "feeTaken",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint128",
        "name": "liquidity",
        "type": "uint128"
      }
    ],
    "name": "ZapMintedV4",
    "type": "event"
  },
  {
    "inputs": [],
    "name": "BPS",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "PLATFORM_FEE_BPS",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "feeSink",
    "outputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "name": "mintedBy",
    "outputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "permit2",
    "outputs": [
      {
        "internalType": "contract IPermit2",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "components": [
          {
            "internalType": "address",
            "name": "currency0",
            "type": "address"
          },
          {
            "internalType": "address",
            "name": "currency1",
            "type": "address"
          },
          {
            "internalType": "uint24",
            "name": "fee",
            "type": "uint24"
          },
          {
            "internalType": "int24",
            "name": "tickSpacing",
            "type": "int24"
          },
          {
            "internalType": "address",
            "name": "hooks",
            "type": "address"
          }
        ],
        "internalType": "struct V4PoolKey",
        "name": "k",
        "type": "tuple"
      }
    ],
    "name": "poolId",
    "outputs": [
      {
        "internalType": "bytes32",
        "name": "",
        "type": "bytes32"
      }
    ],
    "stateMutability": "pure",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "posm",
    "outputs": [
      {
        "internalType": "contract IV4PositionManager",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "stateView",
    "outputs": [
      {
        "internalType": "contract IStateView",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "universalRouter",
    "outputs": [
      {
        "internalType": "contract IUniversalRouter",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "components": [
          {
            "components": [
              {
                "internalType": "address",
                "name": "currency0",
                "type": "address"
              },
              {
                "internalType": "address",
                "name": "currency1",
                "type": "address"
              },
              {
                "internalType": "uint24",
                "name": "fee",
                "type": "uint24"
              },
              {
                "internalType": "int24",
                "name": "tickSpacing",
                "type": "int24"
              },
              {
                "internalType": "address",
                "name": "hooks",
                "type": "address"
              }
            ],
            "internalType": "struct V4PoolKey",
            "name": "poolKey",
            "type": "tuple"
          },
          {
            "internalType": "address",
            "name": "tokenIn",
            "type": "address"
          },
          {
            "internalType": "uint256",
            "name": "amountIn",
            "type": "uint256"
          },
          {
            "internalType": "int24",
            "name": "tickLower",
            "type": "int24"
          },
          {
            "internalType": "int24",
            "name": "tickUpper",
            "type": "int24"
          },
          {
            "internalType": "uint256",
            "name": "swapAmountIn",
            "type": "uint256"
          },
          {
            "internalType": "uint256",
            "name": "swapMinOut",
            "type": "uint256"
          },
          {
            "internalType": "uint256",
            "name": "amount0Min",
            "type": "uint256"
          },
          {
            "internalType": "uint256",
            "name": "amount1Min",
            "type": "uint256"
          },
          {
            "internalType": "uint256",
            "name": "deadline",
            "type": "uint256"
          }
        ],
        "internalType": "struct AlpsRouterV4.ZapV4Params",
        "name": "p",
        "type": "tuple"
      }
    ],
    "name": "zapMintV4",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      },
      {
        "internalType": "uint128",
        "name": "liquidity",
        "type": "uint128"
      }
    ],
    "stateMutability": "payable",
    "type": "function"
  },
  {
    "stateMutability": "payable",
    "type": "receive"
  }
]

Source code

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;

import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {IERC20 as OZIERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import {TickMath} from "@uniswap/v3-core/contracts/libraries/TickMath.sol";
import {LiquidityAmounts} from "@uniswap/v3-periphery/contracts/libraries/LiquidityAmounts.sol";
import {V4PoolKey, IV4PositionManager, IPermit2} from "./interfaces/IV4.sol";

interface IUniversalRouter {
    function execute(bytes calldata commands, bytes[] calldata inputs, uint256 deadline)
        external
        payable;
}

interface IStateView {
    function getSlot0(bytes32 poolId)
        external
        view
        returns (uint160 sqrtPriceX96, int24 tick, uint24 protocolFee, uint24 lpFee);
}

/// @title AlpsRouterV4 — single-token zap mints for Uniswap v4 on Robinhood Chain
///
/// The opt-in premium lane for v4, mirroring AlpsRouter's v3 zap:
///   - pull one token (or native ETH), take the immutable 0.1% platform fee,
///     optionally swap a slice through the canonical UniversalRouter so the
///     position straddles the market, then mint the position — the NFT goes
///     straight to the user. Funds only exist here inside the transaction.
///
/// v4 gets NO TP/SL: Uniswap v4 core has no built-in price oracle (only opt-in
/// hooks), so the manipulation-resistant spot+TWAP trigger AlpsRouter uses for
/// v3 cannot be reproduced safely for arbitrary v4 pools. This contract is zap
/// + fee only.
///
/// Trust surface, in full:
///   - PLATFORM_FEE_BPS and feeSink are immutable. The fee can never be raised.
///   - There is no owner, no keeper, no pause, no upgrade, no fund access. The
///     contract holds nothing at rest; every wei that is not the 0.1% fee is
///     either deposited into the user's position or refunded in the same tx.
///   - The balancing swap uses the same UniversalRouter every other v4 swap on
///     this chain uses, bounded by the caller's own swapMinOut.
contract AlpsRouterV4 is ReentrancyGuard {
    using SafeERC20 for OZIERC20;

    uint256 public constant PLATFORM_FEE_BPS = 10; // 0.1%, immutable forever
    uint256 public constant BPS = 10_000;

    // UniversalRouter command + V4Router action ids (verified against live txs)
    bytes1 internal constant CMD_V4_SWAP = 0x10;
    uint8 internal constant SWAP_EXACT_IN_SINGLE = 0x06;
    uint8 internal constant SETTLE_ALL = 0x0c;
    uint8 internal constant TAKE_ALL = 0x0f;
    // PositionManager action ids
    uint8 internal constant MINT_POSITION = 0x02;
    uint8 internal constant SETTLE_PAIR = 0x0d;
    uint8 internal constant SWEEP = 0x14;

    address internal constant NATIVE = address(0);
    uint160 internal constant MAX_UINT160 = type(uint160).max;
    uint48 internal constant MAX_UINT48 = type(uint48).max;

    IV4PositionManager public immutable posm;
    IUniversalRouter public immutable universalRouter;
    IStateView public immutable stateView;
    IPermit2 public immutable permit2;
    address public immutable feeSink;

    /// premium flag: positions minted through this contract (parity with v3;
    /// v4 has no TP/SL so it is informational only)
    mapping(uint256 => address) public mintedBy;

    event ZapMintedV4(
        address indexed user,
        uint256 indexed tokenId,
        address tokenIn,
        uint256 amountIn,
        uint256 feeTaken,
        uint128 liquidity
    );

    error BadParams();
    error NativeMismatch();
    error SlippageOrDust();

    // v4 ExactInputSingle params (matches IV4Router on this chain)
    struct ExactInputSingleParams {
        V4PoolKey poolKey;
        bool zeroForOne;
        uint128 amountIn;
        uint128 amountOutMinimum;
        bytes hookData;
    }

    struct ZapV4Params {
        V4PoolKey poolKey; // currency0 < currency1; currency0 may be native (0x0)
        address tokenIn; // the deposited currency (currency0 or currency1)
        uint256 amountIn; // total deposit, fee comes out of this
        int24 tickLower;
        int24 tickUpper;
        uint256 swapAmountIn; // slice of net swapped into the other currency (0 = single-sided)
        uint256 swapMinOut; // floor on the swap output
        uint256 amount0Min; // reserved for callers; the on-chain liquidity fit is the real guard
        uint256 amount1Min;
        uint256 deadline;
    }

    constructor(
        address posm_,
        address universalRouter_,
        address stateView_,
        address permit2_,
        address feeSink_
    ) {
        if (
            posm_ == address(0) || universalRouter_ == address(0) || stateView_ == address(0)
                || permit2_ == address(0) || feeSink_ == address(0)
        ) revert BadParams();
        posm = IV4PositionManager(posm_);
        universalRouter = IUniversalRouter(universalRouter_);
        stateView = IStateView(stateView_);
        permit2 = IPermit2(permit2_);
        feeSink = feeSink_;
    }

    receive() external payable {} // native refunds from UR / posm sweeps land here

    function poolId(V4PoolKey memory k) public pure returns (bytes32) {
        return keccak256(abi.encode(k));
    }

    /// Pull tokenIn (or native ETH), take the fee, optionally swap a slice via
    /// UniversalRouter, mint the position to msg.sender, refund all leftovers.
    function zapMintV4(ZapV4Params calldata p)
        external
        payable
        nonReentrant
        returns (uint256 tokenId, uint128 liquidity)
    {
        bool inIs0 = p.tokenIn == p.poolKey.currency0;
        if (!inIs0 && p.tokenIn != p.poolKey.currency1) revert BadParams();
        if (p.amountIn == 0 || p.tickLower >= p.tickUpper) revert BadParams();
        // v4 periphery takes amounts as uint128; Solidity does not check explicit
        // casts, so bound the deposit up front (balances/slice derive from it)
        if (p.amountIn > type(uint128).max || p.swapMinOut > type(uint128).max) revert BadParams();

        bool nativeIn = p.tokenIn == NATIVE;
        // fund: native path checks msg.value, ERC20 path pulls
        if (nativeIn) {
            if (msg.value != p.amountIn) revert NativeMismatch();
        } else {
            if (msg.value != 0) revert NativeMismatch();
            OZIERC20(p.tokenIn).safeTransferFrom(msg.sender, address(this), p.amountIn);
        }

        // the 0.1% — the only value this contract ever keeps
        uint256 fee = (p.amountIn * PLATFORM_FEE_BPS) / BPS;
        if (fee > 0) _payOut(p.tokenIn, feeSink, fee);
        uint256 net = p.amountIn - fee;
        if (p.swapAmountIn > net) revert BadParams();

        address other = inIs0 ? p.poolKey.currency1 : p.poolKey.currency0;
        if (p.swapAmountIn > 0) {
            _swapExactInSingle(p.poolKey, p.tokenIn, other, inIs0, p.swapAmountIn, p.swapMinOut);
        }

        (tokenId, liquidity) = _mint(p);

        mintedBy[tokenId] = msg.sender;

        // refund every leftover of both currencies
        _refund(p.poolKey.currency0, msg.sender);
        _refund(p.poolKey.currency1, msg.sender);

        emit ZapMintedV4(msg.sender, tokenId, p.tokenIn, p.amountIn, fee, liquidity);
    }

    // ------------------------------------------------------------- swap ----
    function _swapExactInSingle(
        V4PoolKey calldata key,
        address tokenIn,
        address tokenOut,
        bool inIs0,
        uint256 amountIn,
        uint256 minOut
    ) internal {
        ExactInputSingleParams memory sp = ExactInputSingleParams({
            poolKey: key,
            zeroForOne: inIs0, // input is currency0 -> 0 for 1
            amountIn: uint128(amountIn),
            amountOutMinimum: uint128(minOut),
            hookData: ""
        });

        bytes memory actions = abi.encodePacked(SWAP_EXACT_IN_SINGLE, SETTLE_ALL, TAKE_ALL);
        bytes[] memory params = new bytes[](3);
        params[0] = abi.encode(sp);
        params[1] = abi.encode(tokenIn, amountIn); // SETTLE_ALL(inputCurrency, maxAmount)
        params[2] = abi.encode(tokenOut, minOut); // TAKE_ALL(outputCurrency, minAmount)

        bytes[] memory inputs = new bytes[](1);
        inputs[0] = abi.encode(actions, params);
        bytes memory commands = abi.encodePacked(CMD_V4_SWAP);

        uint256 value = 0;
        if (tokenIn == NATIVE) {
            value = amountIn;
        } else {
            // grant Permit2 -> UniversalRouter so SETTLE_ALL can pull the input.
            // The ERC20->Permit2 approval is MAX, not the exact amount: Solady
            // style tokens fix their Permit2 allowance at infinity and revert
            // any finite approval (v1 reverted here). Max is accepted by both
            // those and standard ERC20s; this contract never holds funds
            // between transactions, and the Permit2 layer below still caps the
            // UniversalRouter at exactly amountIn.
            OZIERC20(tokenIn).forceApprove(address(permit2), type(uint256).max);
            permit2.approve(tokenIn, address(universalRouter), uint160(amountIn), MAX_UINT48);
        }
        // UR takes the output to msg.sender (this contract)
        universalRouter.execute{value: value}(commands, inputs, block.timestamp);
    }

    // ------------------------------------------------------------- mint ----
    function _mint(ZapV4Params calldata p)
        internal
        returns (uint256 tokenId, uint128 liquidity)
    {
        address c0 = p.poolKey.currency0;
        address c1 = p.poolKey.currency1;
        uint256 bal0 = _balance(c0);
        uint256 bal1 = _balance(c1);
        // amount0Max/amount1Max are uint128 in the mint action
        if (bal0 > type(uint128).max || bal1 > type(uint128).max) revert BadParams();

        uint160 sqrtL = TickMath.getSqrtRatioAtTick(p.tickLower);
        uint160 sqrtU = TickMath.getSqrtRatioAtTick(p.tickUpper);
        (uint160 sqrtP,,,) = stateView.getSlot0(poolId(p.poolKey));
        liquidity = LiquidityAmounts.getLiquidityForAmounts(sqrtP, sqrtL, sqrtU, bal0, bal1);
        if (liquidity == 0) revert SlippageOrDust();

        // slippage floor on the MINT leg: swapMinOut only bounds the swapped
        // slice, so without this a price drift between quote and inclusion could
        // silently mint a much smaller position (fee still paid). Enforce the
        // caller's minimums against what the fitted liquidity actually deposits.
        // getAmountsForLiquidity rounds down vs the pool's round-up, so it can
        // under-report by 1 wei — immaterial for a floor check.
        (uint256 mintAmt0, uint256 mintAmt1) =
            LiquidityAmounts.getAmountsForLiquidity(sqrtP, sqrtL, sqrtU, liquidity);
        if (mintAmt0 < p.amount0Min || mintAmt1 < p.amount1Min) revert SlippageOrDust();

        // approve Permit2 -> PositionManager for the ERC20 sides so SETTLE_PAIR
        // can pull what the mint consumes (native side is paid via value)
        if (c0 != NATIVE) _permitPosm(c0, bal0);
        if (c1 != NATIVE) _permitPosm(c1, bal1);

        bool useNative = c0 == NATIVE;
        bytes memory actions = useNative
            ? abi.encodePacked(MINT_POSITION, SETTLE_PAIR, SWEEP)
            : abi.encodePacked(MINT_POSITION, SETTLE_PAIR);

        bytes[] memory params = new bytes[](useNative ? 3 : 2);
        params[0] = abi.encode(
            p.poolKey,
            p.tickLower,
            p.tickUpper,
            uint256(liquidity),
            uint128(bal0),
            uint128(bal1),
            msg.sender, // NFT recipient is the user
            bytes("")
        );
        params[1] = abi.encode(c0, c1); // SETTLE_PAIR
        if (useNative) params[2] = abi.encode(NATIVE, address(this)); // SWEEP native refund here

        // next tokenId = nextTokenId() is not exposed; read the minted id from
        // ownerOf after the call via the Transfer... simpler: posm returns none,
        // so capture via nextTokenId probe before/after is racey. Instead we
        // trust the mint and resolve tokenId from the position manager's
        // sequential id by reading it back through the mint event off-chain.
        uint256 idBefore = _nextTokenId();
        uint256 value = useNative ? bal0 : 0;
        posm.modifyLiquidities{value: value}(abi.encode(actions, params), p.deadline);
        tokenId = idBefore; // the id assigned to this mint
    }

    function _permitPosm(address token, uint256 amount) internal {
        // nothing to settle on this side — and a zero ERC20 approval to
        // Permit2 would revert Solady-style tokens just like a finite one
        if (amount == 0) return;
        // MAX for the same reason as the swap path: Solady-style tokens only
        // accept infinite Permit2 approvals. The Permit2 layer still scopes
        // the PositionManager to this mint's amount below.
        OZIERC20(token).forceApprove(address(permit2), type(uint256).max);
        uint160 amt = amount > MAX_UINT160 ? MAX_UINT160 : uint160(amount);
        permit2.approve(token, address(posm), amt, MAX_UINT48);
    }

    // PositionManager exposes nextTokenId(); the value BEFORE the mint is the id
    // this mint receives (v4 POSM assigns ids sequentially from 1)
    function _nextTokenId() internal view returns (uint256) {
        return IV4Next(address(posm)).nextTokenId();
    }

    // ---------------------------------------------------------- helpers ----
    function _balance(address token) internal view returns (uint256) {
        if (token == NATIVE) return address(this).balance;
        return OZIERC20(token).balanceOf(address(this));
    }

    function _payOut(address token, address to, uint256 amount) internal {
        if (token == NATIVE) {
            (bool ok,) = to.call{value: amount}("");
            if (!ok) revert BadParams();
        } else {
            OZIERC20(token).safeTransfer(to, amount);
        }
    }

    function _refund(address token, address to) internal {
        uint256 bal = _balance(token);
        if (bal > 0) _payOut(token, to, bal);
    }
}

interface IV4Next {
    function nextTokenId() external view returns (uint256);
}
Chain explorer4446msChain node84ms