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
- Creator
- 0x5EcC02c691…75386ad57d
- 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