MotionSwapRouter
0x33bb3226531fa18a6253dfd01538c76fee5acc37
Verification
Verified
v0.8.26+commit.8a97fa7a
Type
Contract
12,179 bytes
ABI entries
57
8 read · 12 write
License
none
Contract information
- Address
- 0x33bb3226531fa18a6253dfd01538c76fee5acc37
- Chain
- Robinhood Chain (4663)
- Compiler
- v0.8.26+commit.8a97fa7a
- Optimization
- Enabled
- Creator
- 0x52C8cFf40D…Fa637e1Ef9
- Creation tx
- 0xc8aadcf69a…649106a7cf
Token
Not a token
This contract does not expose ERC-20 metadata.
Read contract (8)
ROUTER_VERSION() → uint256
launchManager() → address
motionHook() → address
owner() → address
pendingOwner() → address
poolManager() → address
routeOf(address) → tuple[]
weth() → address
Events (7)
BuyWithEthExactInputSwapOwnershipTransferStartedOwnershipTransferredRouteClearedRouteSetSellToEth
ABI
[
{
"inputs": [
{
"internalType": "contract IPoolManager",
"name": "poolManager_",
"type": "address"
},
{
"internalType": "contract ILaunchManager",
"name": "launchManager_",
"type": "address"
},
{
"internalType": "contract IWETH",
"name": "weth_",
"type": "address"
},
{
"internalType": "contract IHooks",
"name": "motionHook_",
"type": "address"
},
{
"internalType": "address",
"name": "owner_",
"type": "address"
}
],
"stateMutability": "nonpayable",
"type": "constructor"
},
{
"inputs": [],
"name": "DeadlineExpired",
"type": "error"
},
{
"inputs": [],
"name": "EthTransferFailed",
"type": "error"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "paid",
"type": "uint256"
},
{
"internalType": "uint256",
"name": "expected",
"type": "uint256"
}
],
"name": "IncorrectSettlement",
"type": "error"
},
{
"inputs": [],
"name": "InvalidAmountIn",
"type": "error"
},
{
"inputs": [],
"name": "InvalidCallback",
"type": "error"
},
{
"inputs": [],
"name": "InvalidManager",
"type": "error"
},
{
"inputs": [],
"name": "InvalidNativeValue",
"type": "error"
},
{
"inputs": [],
"name": "InvalidPoolManager",
"type": "error"
},
{
"inputs": [],
"name": "InvalidRecipient",
"type": "error"
},
{
"inputs": [],
"name": "InvalidSwapDelta",
"type": "error"
},
{
"inputs": [],
"name": "InvalidWeth",
"type": "error"
},
{
"inputs": [],
"name": "OnlyPoolManager",
"type": "error"
},
{
"inputs": [],
"name": "OnlySelf",
"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": [
{
"internalType": "uint256",
"name": "amountInUsed",
"type": "uint256"
},
{
"internalType": "uint256",
"name": "expectedAmountIn",
"type": "uint256"
}
],
"name": "PartialFillNotAllowed",
"type": "error"
},
{
"inputs": [],
"name": "ReentrancyGuardReentrantCall",
"type": "error"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "hopIndex",
"type": "uint256"
}
],
"name": "RouteDisconnected",
"type": "error"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "hopIndex",
"type": "uint256"
}
],
"name": "RouteHopHasHook",
"type": "error"
},
{
"inputs": [
{
"internalType": "address",
"name": "quote",
"type": "address"
}
],
"name": "RouteNotConfigured",
"type": "error"
},
{
"inputs": [],
"name": "RouteTooLong",
"type": "error"
},
{
"inputs": [],
"name": "RouteWrongEnd",
"type": "error"
},
{
"inputs": [],
"name": "RouteWrongStart",
"type": "error"
},
{
"inputs": [
{
"internalType": "address",
"name": "token",
"type": "address"
}
],
"name": "SafeERC20FailedOperation",
"type": "error"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "amountOut",
"type": "uint256"
},
{
"internalType": "uint256",
"name": "amountOutMinimum",
"type": "uint256"
}
],
"name": "TooLittleOutput",
"type": "error"
},
{
"inputs": [],
"name": "UnexpectedNativeValue",
"type": "error"
},
{
"inputs": [],
"name": "UnexpectedV3Callback",
"type": "error"
},
{
"inputs": [],
"name": "UnknownToken",
"type": "error"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "payer",
"type": "address"
},
{
"indexed": true,
"internalType": "address",
"name": "token",
"type": "address"
},
{
"indexed": false,
"internalType": "uint256",
"name": "ethIn",
"type": "uint256"
},
{
"indexed": false,
"internalType": "uint256",
"name": "tokensOut",
"type": "uint256"
},
{
"indexed": false,
"internalType": "uint256",
"name": "ethRefunded",
"type": "uint256"
}
],
"name": "BuyWithEth",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "payer",
"type": "address"
},
{
"indexed": true,
"internalType": "address",
"name": "recipient",
"type": "address"
},
{
"indexed": true,
"internalType": "address",
"name": "currencyIn",
"type": "address"
},
{
"indexed": false,
"internalType": "address",
"name": "currencyOut",
"type": "address"
},
{
"indexed": false,
"internalType": "uint256",
"name": "amountIn",
"type": "uint256"
},
{
"indexed": false,
"internalType": "uint256",
"name": "amountOut",
"type": "uint256"
}
],
"name": "ExactInputSwap",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "previousOwner",
"type": "address"
},
{
"indexed": true,
"internalType": "address",
"name": "newOwner",
"type": "address"
}
],
"name": "OwnershipTransferStarted",
"type": "event"
},
{
"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": "quote",
"type": "address"
}
],
"name": "RouteCleared",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "quote",
"type": "address"
},
{
"indexed": false,
"internalType": "uint256",
"name": "hops",
"type": "uint256"
}
],
"name": "RouteSet",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "payer",
"type": "address"
},
{
"indexed": true,
"internalType": "address",
"name": "token",
"type": "address"
},
{
"indexed": false,
"internalType": "uint256",
"name": "tokensIn",
"type": "uint256"
},
{
"indexed": false,
"internalType": "uint256",
"name": "ethOut",
"type": "uint256"
}
],
"name": "SellToEth",
"type": "event"
},
{
"inputs": [],
"name": "ROUTER_VERSION",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "acceptOwnership",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "token",
"type": "address"
},
{
"internalType": "uint256",
"name": "minTokensOut",
"type": "uint256"
},
{
"internalType": "address",
"name": "recipient",
"type": "address"
},
{
"internalType": "uint256",
"name": "deadline",
"type": "uint256"
}
],
"name": "buyWithEth",
"outputs": [
{
"internalType": "uint256",
"name": "tokensOut",
"type": "uint256"
}
],
"stateMutability": "payable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "quote",
"type": "address"
}
],
"name": "clearRoute",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"components": [
{
"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": "key",
"type": "tuple"
},
{
"internalType": "bool",
"name": "zeroForOne",
"type": "bool"
},
{
"internalType": "uint256",
"name": "amountIn",
"type": "uint256"
},
{
"internalType": "uint256",
"name": "minAmountOut",
"type": "uint256"
},
{
"internalType": "uint160",
"name": "sqrtPriceLimitX96",
"type": "uint160"
},
{
"internalType": "address",
"name": "recipient",
"type": "address"
},
{
"internalType": "uint256",
"name": "deadline",
"type": "uint256"
},
{
"internalType": "bytes",
"name": "hookData",
"type": "bytes"
}
],
"internalType": "struct MotionSwapRouter.ExactInputSingleParams",
"name": "params",
"type": "tuple"
}
],
"name": "exactInputSingle",
"outputs": [
{
"internalType": "uint256",
"name": "amountInUsed",
"type": "uint256"
},
{
"internalType": "uint256",
"name": "amountOut",
"type": "uint256"
}
],
"stateMutability": "payable",
"type": "function"
},
{
"inputs": [],
"name": "launchManager",
"outputs": [
{
"internalType": "contract ILaunchManager",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "motionHook",
"outputs": [
{
"internalType": "contract IHooks",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "owner",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "pendingOwner",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "poolManager",
"outputs": [
{
"internalType": "contract IPoolManager",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "renounceOwnership",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "quote",
"type": "address"
}
],
"name": "routeOf",
"outputs": [
{
"components": [
{
"internalType": "enum MotionSwapRouter.Venue",
"name": "venue",
"type": "uint8"
},
{
"internalType": "address",
"name": "pool",
"type": "address"
},
{
"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": "key",
"type": "tuple"
}
],
"internalType": "struct MotionSwapRouter.Hop[]",
"name": "",
"type": "tuple[]"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "quote",
"type": "address"
},
{
"internalType": "uint256",
"name": "amount",
"type": "uint256"
}
],
"name": "routeQuoteToEthSelf",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "token",
"type": "address"
},
{
"internalType": "uint256",
"name": "tokensIn",
"type": "uint256"
},
{
"internalType": "uint256",
"name": "minEthOut",
"type": "uint256"
},
{
"internalType": "address",
"name": "recipient",
"type": "address"
},
{
"internalType": "uint256",
"name": "deadline",
"type": "uint256"
}
],
"name": "sellToEth",
"outputs": [
{
"internalType": "uint256",
"name": "ethOut",
"type": "uint256"
}
],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "quote",
"type": "address"
},
{
"components": [
{
"internalType": "enum MotionSwapRouter.Venue",
"name": "venue",
"type": "uint8"
},
{
"internalType": "address",
"name": "pool",
"type": "address"
},
{
"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": "key",
"type": "tuple"
}
],
"internalType": "struct MotionSwapRouter.Hop[]",
"name": "route",
"type": "tuple[]"
}
],
"name": "setRoute",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "quote",
"type": "address"
},
{
"internalType": "uint256",
"name": "minQuoteOut",
"type": "uint256"
},
{
"internalType": "address",
"name": "recipient",
"type": "address"
}
],
"name": "swapExactEthForQuote",
"outputs": [
{
"internalType": "uint256",
"name": "quoteOut",
"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": "data",
"type": "bytes"
}
],
"name": "uniswapV3SwapCallback",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "bytes",
"name": "rawData",
"type": "bytes"
}
],
"name": "unlockCallback",
"outputs": [
{
"internalType": "bytes",
"name": "",
"type": "bytes"
}
],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [],
"name": "weth",
"outputs": [
{
"internalType": "contract IWETH",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"stateMutability": "payable",
"type": "receive"
}
]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 {ReentrancyGuardTransient} from "@openzeppelin/contracts/utils/ReentrancyGuardTransient.sol";
import {TransientSlot} from "@openzeppelin/contracts/utils/TransientSlot.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {Ownable2Step} from "@openzeppelin/contracts/access/Ownable2Step.sol";
import {IUnlockCallback} from "@uniswap/v4-core/src/interfaces/callback/IUnlockCallback.sol";
import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol";
import {IHooks} from "@uniswap/v4-core/src/interfaces/IHooks.sol";
import {BalanceDelta, BalanceDeltaLibrary} from "@uniswap/v4-core/src/types/BalanceDelta.sol";
import {Currency, CurrencyLibrary} from "@uniswap/v4-core/src/types/Currency.sol";
import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol";
import {TickMath} from "@uniswap/v4-core/src/libraries/TickMath.sol";
import {ILaunchManager} from "./interfaces/ILaunchManager.sol";
import {TokenState} from "./types/LaunchTypes.sol";
interface IWETH is IERC20 {
function deposit() external payable;
function withdraw(uint256 amount) external;
}
interface IUniswapV3Pool {
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);
}
/// @title MotionSwapRouter
/// @notice Production router (version 2). Keeps V1's single-pool exact-input
/// V4 swap and adds owner-pinned multi-venue routes so any launch coin can be
/// bought or sold with native ETH in one transaction, across both lifecycle
/// phases: the bonding curve (via LaunchManager) and the graduated V4 pool.
/// @dev Routes exist because this chain carries parasitic quote pools with
/// confiscatory LP fees; the router only ever crosses pools the owner has
/// pinned. A route walks from native ETH to a quote asset over Uniswap V4 or
/// V3 pools and is reversed on exit. The owner may retarget a route at any
/// time but can never move user funds: the user's single min-output bound is
/// enforced on the final leg, route hops are hookless, and the contract holds
/// no balance between transactions.
contract MotionSwapRouter is IUnlockCallback, ReentrancyGuardTransient, Ownable2Step {
using BalanceDeltaLibrary for BalanceDelta;
using CurrencyLibrary for Currency;
using SafeERC20 for IERC20;
using TransientSlot for bytes32;
using TransientSlot for TransientSlot.Bytes32Slot;
using TransientSlot for TransientSlot.AddressSlot;
// -------------------------------------------------------------------
// Types
// -------------------------------------------------------------------
enum Venue {
UniV4,
UniV3
}
struct Hop {
Venue venue;
/// @dev V3 pool address; ignored for V4 hops.
address pool;
/// @dev V4 pool key; ignored for V3 hops.
PoolKey key;
}
struct ExactInputSingleParams {
PoolKey key;
bool zeroForOne;
uint256 amountIn;
uint256 minAmountOut;
uint160 sqrtPriceLimitX96;
address recipient;
uint256 deadline;
bytes hookData;
}
struct V4SwapData {
PoolKey key;
bool zeroForOne;
uint256 amountIn;
uint160 sqrtPriceLimitX96;
address takeTo;
address payer;
bytes hookData;
}
// -------------------------------------------------------------------
// Constants and immutables
// -------------------------------------------------------------------
uint256 public constant ROUTER_VERSION = 2;
/// @dev Transient slots for the callback guards, cleared automatically at
/// the end of every transaction.
bytes32 private constant _CALLBACK_HASH_SLOT =
keccak256("motion.swaprouter.v2.expectedCallbackHash");
bytes32 private constant _V3_POOL_SLOT = keccak256("motion.swaprouter.v2.expectedV3Pool");
IPoolManager public immutable poolManager;
ILaunchManager public immutable launchManager;
IWETH public immutable weth;
IHooks public immutable motionHook;
// -------------------------------------------------------------------
// Storage
// -------------------------------------------------------------------
mapping(address quote => Hop[]) private _routes;
// -------------------------------------------------------------------
// Events
// -------------------------------------------------------------------
event ExactInputSwap(
address indexed payer,
address indexed recipient,
address indexed currencyIn,
address currencyOut,
uint256 amountIn,
uint256 amountOut
);
event RouteSet(address indexed quote, uint256 hops);
event RouteCleared(address indexed quote);
event BuyWithEth(
address indexed payer, address indexed token, uint256 ethIn, uint256 tokensOut, uint256 ethRefunded
);
event SellToEth(address indexed payer, address indexed token, uint256 tokensIn, uint256 ethOut);
// -------------------------------------------------------------------
// Errors
// -------------------------------------------------------------------
error InvalidPoolManager();
error InvalidManager();
error InvalidWeth();
error OnlyPoolManager();
error OnlySelf();
error InvalidRecipient();
error InvalidAmountIn();
error DeadlineExpired();
error InvalidNativeValue();
error UnexpectedNativeValue();
error InvalidCallback();
error InvalidSwapDelta();
error PartialFillNotAllowed(uint256 amountInUsed, uint256 expectedAmountIn);
error TooLittleOutput(uint256 amountOut, uint256 amountOutMinimum);
error IncorrectSettlement(uint256 paid, uint256 expected);
error RouteNotConfigured(address quote);
error RouteTooLong();
error RouteDisconnected(uint256 hopIndex);
error RouteWrongStart();
error RouteWrongEnd();
error RouteHopHasHook(uint256 hopIndex);
error UnknownToken();
error UnexpectedV3Callback();
error EthTransferFailed();
// -------------------------------------------------------------------
// Constructor
// -------------------------------------------------------------------
constructor(IPoolManager poolManager_, ILaunchManager launchManager_, IWETH weth_, IHooks motionHook_, address owner_)
Ownable(owner_)
{
if (address(poolManager_) == address(0) || address(poolManager_).code.length == 0) {
revert InvalidPoolManager();
}
// A code-bearing typo must not silently pass: this view is part of the
// canonical V4 PoolManager ABI.
try poolManager_.protocolFeeController() returns (address) {}
catch {
revert InvalidPoolManager();
}
if (address(launchManager_) == address(0) || address(launchManager_).code.length == 0) revert InvalidManager();
if (address(weth_) == address(0) || address(weth_).code.length == 0) revert InvalidWeth();
poolManager = poolManager_;
launchManager = launchManager_;
weth = weth_;
motionHook = motionHook_;
}
/// @dev Accepts ETH from WETH withdrawals, PoolManager takes, and
/// LaunchManager refunds. Every leg forwards its ETH before the call ends,
/// so the router holds no balance at rest; direct transfers are lost.
receive() external payable {}
// -------------------------------------------------------------------
// Route registry (owner)
// -------------------------------------------------------------------
/// @notice Returns the hop list that carries native ETH into `quote`.
function routeOf(address quote) external view returns (Hop[] memory) {
return _routes[quote];
}
/// @notice Pins the hop list that carries native ETH into `quote`.
/// @dev Validated hop by hop: the first hop must take native ETH (V4) or
/// WETH (V3), each hop must consume the previous hop's output token, and
/// the last hop must emit the quote asset. V4 hops must be hookless: a
/// hooked bridge pool could confiscate value on the reverse (refund or
/// sell) leg, which carries no user min-output bound.
function setRoute(address quote, Hop[] calldata route) external onlyOwner {
if (route.length == 0 || quote == address(0)) revert RouteNotConfigured(quote);
if (route.length > 4) revert RouteTooLong();
delete _routes[quote];
address current = address(0);
for (uint256 i = 0; i < route.length; i++) {
(address tokenA, address tokenB) = _hopTokens(route[i]);
// A V3 hop fed by native ETH implicitly consumes WETH (auto-wrap).
address input = current == address(0) && route[i].venue != Venue.UniV4 ? address(weth) : current;
if (input == tokenA) {
current = tokenB;
} else if (input == tokenB) {
current = tokenA;
} else {
revert RouteDisconnected(i);
}
if (route[i].venue == Venue.UniV4) {
if (address(route[i].key.hooks) != address(0)) revert RouteHopHasHook(i);
if (i == 0 && !route[i].key.currency0.isAddressZero()) revert RouteWrongStart();
}
_routes[quote].push(route[i]);
}
if (current != quote) revert RouteWrongEnd();
emit RouteSet(quote, route.length);
}
/// @notice Removes the route for `quote`.
function clearRoute(address quote) external onlyOwner {
delete _routes[quote];
emit RouteCleared(quote);
}
// -------------------------------------------------------------------
// Trading (external)
// -------------------------------------------------------------------
/// @notice Swaps exactly `amountIn` of one pool currency for the other
/// through a single V4 pool. Behaviour is identical to router V1.
/// @dev Fail-closed on partial fills: the quote-input hook fee is assessed
/// before the pool executes, so accepting a partial fill would charge that
/// fee against unfilled input.
function exactInputSingle(ExactInputSingleParams calldata params)
external
payable
nonReentrant
returns (uint256 amountInUsed, uint256 amountOut)
{
if (params.recipient == address(0)) revert InvalidRecipient();
if (block.timestamp > params.deadline) revert DeadlineExpired();
Currency input = params.zeroForOne ? params.key.currency0 : params.key.currency1;
if (input.isAddressZero()) {
if (msg.value != params.amountIn) revert InvalidNativeValue();
} else if (msg.value != 0) {
revert UnexpectedNativeValue();
}
(amountInUsed, amountOut) = _v4Swap(
V4SwapData({
key: params.key,
zeroForOne: params.zeroForOne,
amountIn: params.amountIn,
sqrtPriceLimitX96: params.sqrtPriceLimitX96,
takeTo: params.recipient,
payer: msg.sender,
hookData: params.hookData
})
);
if (amountOut < params.minAmountOut) revert TooLittleOutput(amountOut, params.minAmountOut);
Currency output = params.zeroForOne ? params.key.currency1 : params.key.currency0;
emit ExactInputSwap(
msg.sender, params.recipient, Currency.unwrap(input), Currency.unwrap(output), amountInUsed, amountOut
);
}
/// @notice Buys `token` with native ETH in one transaction, whatever the
/// coin's quote asset and lifecycle phase.
/// @param minTokensOut Lower bound on the coin amount delivered.
/// @dev A graduating curve buy refunds excess quote; that refund is
/// converted back to ETH (or returned in the quote asset if routing dust
/// would revert) so the caller never holds the quote asset.
function buyWithEth(address token, uint256 minTokensOut, address recipient, uint256 deadline)
external
payable
nonReentrant
returns (uint256 tokensOut)
{
if (recipient == address(0)) recipient = msg.sender;
if (block.timestamp > deadline) revert DeadlineExpired();
if (msg.value == 0) revert InvalidAmountIn();
TokenState memory state = launchManager.tokenStateOf(token);
if (state.creator == address(0)) revert UnknownToken();
address quote = state.quote;
uint256 quoteAmount = quote == address(0) ? msg.value : _routeEthToQuote(quote, msg.value);
uint256 ethRefunded;
if (state.migrated) {
(, tokensOut) = _swapCoinPool(token, quote, quoteAmount, recipient, true);
} else {
// Measure the manager's refund as a balance delta around the buy,
// never as the router's absolute balance: a donated balance must
// not be attributed to this caller, nor be able to make the refund
// path revert and brick buys. Only a graduating buy refunds.
uint256 balanceBefore = _selfBalance(quote);
if (quote == address(0)) {
tokensOut = launchManager.buy{value: quoteAmount}(token, quoteAmount, 0, recipient, deadline);
} else {
IERC20(quote).forceApprove(address(launchManager), quoteAmount);
tokensOut = launchManager.buy(token, quoteAmount, 0, recipient, deadline);
IERC20(quote).forceApprove(address(launchManager), 0);
}
uint256 refund = _selfBalance(quote) + quoteAmount - balanceBefore;
if (refund > 0) ethRefunded = _returnRefundAsEth(quote, refund);
}
if (tokensOut < minTokensOut) revert TooLittleOutput(tokensOut, minTokensOut);
emit BuyWithEth(msg.sender, token, msg.value, tokensOut, ethRefunded);
}
/// @notice Swaps native ETH into `quote` through the pinned route and sends
/// it to `recipient`. Used by LaunchManager to fund an ETH-paid dev buy on a
/// non-ETH-quoted launch, but callable by anyone as a plain swap.
/// @param minQuoteOut Lower bound on the quote delivered.
function swapExactEthForQuote(address quote, uint256 minQuoteOut, address recipient)
external
payable
nonReentrant
returns (uint256 quoteOut)
{
if (recipient == address(0)) revert InvalidRecipient();
if (msg.value == 0) revert InvalidAmountIn();
quoteOut = _routeEthToQuote(quote, msg.value);
if (quoteOut < minQuoteOut) revert TooLittleOutput(quoteOut, minQuoteOut);
IERC20(quote).safeTransfer(recipient, quoteOut);
}
/// @notice Sells `tokensIn` of `token` and pays the caller native ETH in
/// one transaction, whatever the coin's quote asset and lifecycle phase.
/// @param minEthOut Lower bound on the ETH delivered.
function sellToEth(address token, uint256 tokensIn, uint256 minEthOut, address recipient, uint256 deadline)
external
nonReentrant
returns (uint256 ethOut)
{
if (recipient == address(0)) recipient = msg.sender;
if (block.timestamp > deadline) revert DeadlineExpired();
if (tokensIn == 0) revert InvalidAmountIn();
TokenState memory state = launchManager.tokenStateOf(token);
if (state.creator == address(0)) revert UnknownToken();
address quote = state.quote;
IERC20(token).safeTransferFrom(msg.sender, address(this), tokensIn);
uint256 quoteOut;
if (state.migrated) {
(, quoteOut) = _swapCoinPool(token, quote, tokensIn, address(this), false);
} else {
IERC20(token).forceApprove(address(launchManager), tokensIn);
quoteOut = launchManager.sell(token, tokensIn, 0, address(this), deadline);
IERC20(token).forceApprove(address(launchManager), 0);
}
ethOut = quote == address(0) ? quoteOut : _routeQuoteToEth(quote, quoteOut);
if (ethOut < minEthOut) revert TooLittleOutput(ethOut, minEthOut);
_sendEth(recipient, ethOut);
emit SellToEth(msg.sender, token, tokensIn, ethOut);
}
// -------------------------------------------------------------------
// Callbacks (external, guarded)
// -------------------------------------------------------------------
/// @inheritdoc IUnlockCallback
function unlockCallback(bytes calldata rawData) external returns (bytes memory) {
if (msg.sender != address(poolManager)) revert OnlyPoolManager();
bytes32 expected = _CALLBACK_HASH_SLOT.asBytes32().tload();
if (expected == bytes32(0) || keccak256(rawData) != expected) revert InvalidCallback();
V4SwapData memory data = abi.decode(rawData, (V4SwapData));
BalanceDelta delta = poolManager.swap(
data.key,
IPoolManager.SwapParams({
zeroForOne: data.zeroForOne,
amountSpecified: -int256(data.amountIn),
sqrtPriceLimitX96: data.sqrtPriceLimitX96
}),
data.hookData
);
int128 inputDelta = data.zeroForOne ? delta.amount0() : delta.amount1();
int128 outputDelta = data.zeroForOne ? delta.amount1() : delta.amount0();
if (inputDelta >= 0 || outputDelta <= 0) revert InvalidSwapDelta();
uint256 amountInUsed = uint256(-int256(inputDelta));
uint256 amountOut = uint256(uint128(outputDelta));
if (amountInUsed != data.amountIn) revert PartialFillNotAllowed(amountInUsed, data.amountIn);
Currency input = data.zeroForOne ? data.key.currency0 : data.key.currency1;
if (input.isAddressZero()) {
uint256 paid = poolManager.settle{value: amountInUsed}();
if (paid != amountInUsed) revert IncorrectSettlement(paid, amountInUsed);
} else {
poolManager.sync(input);
if (data.payer == address(this)) {
IERC20(Currency.unwrap(input)).safeTransfer(address(poolManager), amountInUsed);
} else {
IERC20(Currency.unwrap(input)).safeTransferFrom(data.payer, address(poolManager), amountInUsed);
}
uint256 paid = poolManager.settle();
if (paid != amountInUsed) revert IncorrectSettlement(paid, amountInUsed);
}
Currency output = data.zeroForOne ? data.key.currency1 : data.key.currency0;
poolManager.take(output, data.takeTo, amountOut);
return abi.encode(amountInUsed, amountOut);
}
/// @notice Pays the V3 pool during a router-initiated swap.
/// @dev Guarded to the pool the router is currently swapping through.
function uniswapV3SwapCallback(int256 amount0Delta, int256 amount1Delta, bytes calldata data) external {
address expected = _V3_POOL_SLOT.asAddress().tload();
if (msg.sender != expected || expected == address(0)) revert UnexpectedV3Callback();
address tokenIn = abi.decode(data, (address));
uint256 owed = uint256(amount0Delta > 0 ? amount0Delta : amount1Delta);
IERC20(tokenIn).safeTransfer(msg.sender, owed);
}
/// @notice Self-only wrapper so the refund path can try/catch a route
/// failure and fall back to returning the quote asset.
function routeQuoteToEthSelf(address quote, uint256 amount) external returns (uint256) {
if (msg.sender != address(this)) revert OnlySelf();
return _routeQuoteToEth(quote, amount);
}
// -------------------------------------------------------------------
// Route execution (internal)
// -------------------------------------------------------------------
function _routeEthToQuote(address quote, uint256 amountIn) private returns (uint256 amountOut) {
Hop[] memory route = _routes[quote];
if (route.length == 0) revert RouteNotConfigured(quote);
address current = address(0);
amountOut = amountIn;
for (uint256 i = 0; i < route.length; i++) {
(current, amountOut) = _executeHop(route[i], current, amountOut);
}
}
function _routeQuoteToEth(address quote, uint256 amountIn) private returns (uint256 amountOut) {
Hop[] memory route = _routes[quote];
if (route.length == 0) revert RouteNotConfigured(quote);
address current = quote;
amountOut = amountIn;
for (uint256 i = route.length; i > 0; i--) {
(current, amountOut) = _executeHop(route[i - 1], current, amountOut);
}
if (current == address(weth)) {
weth.withdraw(amountOut);
current = address(0);
}
// setRoute guarantees the reversed walk lands on native ETH or WETH.
assert(current == address(0));
}
/// @dev Swaps `amountIn` of `tokenIn` through `hop`, returning the other
/// pool token and the amount received. Wraps ETH when a V3 hop needs WETH;
/// V4 hops spend native ETH directly.
function _executeHop(Hop memory hop, address tokenIn, uint256 amountIn)
private
returns (address tokenOut, uint256 amountOut)
{
if (tokenIn == address(0) && hop.venue != Venue.UniV4) {
weth.deposit{value: amountIn}();
tokenIn = address(weth);
}
if (hop.venue == Venue.UniV4) {
bool zeroForOne = Currency.unwrap(hop.key.currency0) == tokenIn;
tokenOut = Currency.unwrap(zeroForOne ? hop.key.currency1 : hop.key.currency0);
(, amountOut) = _v4Swap(
V4SwapData({
key: hop.key,
zeroForOne: zeroForOne,
amountIn: amountIn,
sqrtPriceLimitX96: 0,
takeTo: address(this),
payer: address(this),
hookData: ""
})
);
} else {
(tokenOut, amountOut) = _v3Swap(hop.pool, tokenIn, amountIn);
}
}
/// @dev Swaps through the coin's own graduated pool. `quoteIn` true means
/// quote -> coin (buy), false means coin -> quote (sell). The pool key is
/// reconstructed from the quote config so a non-default tick spacing works.
function _swapCoinPool(address token, address quote, uint256 amountIn, address takeTo, bool quoteIn)
private
returns (uint256 amountInUsed, uint256 amountOut)
{
(Currency c0, Currency c1) = quote < token
? (Currency.wrap(quote), Currency.wrap(token))
: (Currency.wrap(token), Currency.wrap(quote));
PoolKey memory key = PoolKey({
currency0: c0,
currency1: c1,
fee: 0,
tickSpacing: launchManager.quoteConfigOf(quote).poolTickSpacing,
hooks: motionHook
});
bool zeroForOne = quoteIn ? quote < token : token < quote;
(amountInUsed, amountOut) = _v4Swap(
V4SwapData({
key: key,
zeroForOne: zeroForOne,
amountIn: amountIn,
sqrtPriceLimitX96: 0,
takeTo: takeTo,
payer: address(this),
hookData: ""
})
);
}
// -------------------------------------------------------------------
// Venue swaps (internal)
// -------------------------------------------------------------------
function _v4Swap(V4SwapData memory data) private returns (uint256 amountInUsed, uint256 amountOut) {
if (data.amountIn == 0 || data.amountIn > uint256(uint128(type(int128).max))) revert InvalidAmountIn();
if (data.sqrtPriceLimitX96 == 0) {
data.sqrtPriceLimitX96 = data.zeroForOne ? TickMath.MIN_SQRT_PRICE + 1 : TickMath.MAX_SQRT_PRICE - 1;
}
bytes memory callbackData = abi.encode(data);
_CALLBACK_HASH_SLOT.asBytes32().tstore(keccak256(callbackData));
(amountInUsed, amountOut) = abi.decode(poolManager.unlock(callbackData), (uint256, uint256));
_CALLBACK_HASH_SLOT.asBytes32().tstore(bytes32(0));
}
function _v3Swap(address pool, address tokenIn, uint256 amountIn)
private
returns (address tokenOut, uint256 amountOut)
{
IUniswapV3Pool p = IUniswapV3Pool(pool);
address token0 = p.token0();
bool zeroForOne = tokenIn == token0;
tokenOut = zeroForOne ? p.token1() : token0;
_V3_POOL_SLOT.asAddress().tstore(pool);
(int256 amount0, int256 amount1) = p.swap(
address(this),
zeroForOne,
int256(amountIn),
zeroForOne ? TickMath.MIN_SQRT_PRICE + 1 : TickMath.MAX_SQRT_PRICE - 1,
abi.encode(tokenIn)
);
_V3_POOL_SLOT.asAddress().tstore(address(0));
int256 inputDelta = zeroForOne ? amount0 : amount1;
int256 outputDelta = zeroForOne ? amount1 : amount0;
if (inputDelta <= 0 || outputDelta >= 0) revert InvalidSwapDelta();
if (uint256(inputDelta) != amountIn) revert PartialFillNotAllowed(uint256(inputDelta), amountIn);
amountOut = uint256(-outputDelta);
}
// -------------------------------------------------------------------
// Refunds and helpers (internal)
// -------------------------------------------------------------------
/// @dev Returns a graduation refund to the caller as ETH. Native quote is
/// sent directly; an ERC20 quote is routed back to ETH, but if that routing
/// reverts (e.g. dust rounding to zero through a hop) the refund is handed
/// back in the quote asset rather than reverting the whole buy.
function _returnRefundAsEth(address quote, uint256 refund) private returns (uint256 ethRefunded) {
if (quote == address(0)) {
_sendEth(msg.sender, refund);
return refund;
}
try this.routeQuoteToEthSelf(quote, refund) returns (uint256 ethOut) {
_sendEth(msg.sender, ethOut);
ethRefunded = ethOut;
} catch {
IERC20(quote).safeTransfer(msg.sender, refund);
}
}
function _hopTokens(Hop calldata hop) private view returns (address token0, address token1) {
if (hop.venue == Venue.UniV4) {
return (Currency.unwrap(hop.key.currency0), Currency.unwrap(hop.key.currency1));
}
return (IUniswapV3Pool(hop.pool).token0(), IUniswapV3Pool(hop.pool).token1());
}
function _selfBalance(address token) private view returns (uint256) {
return token == address(0) ? address(this).balance : IERC20(token).balanceOf(address(this));
}
function _sendEth(address to, uint256 amount) private {
if (amount == 0) return;
(bool success,) = to.call{value: amount}("");
if (!success) revert EthTransferFailed();
}
}
Chain explorer693msChain node73ms