HoodV3MigratorV2
0x86c6faf889ebac8621bbb6dd8ca86aa6d51c9e6c
Verification
Verified
v0.8.28+commit.7893614a
Type
Contract
4,195 bytes
ABI entries
24
8 read · 5 write
License
none
Contract information
- Address
- 0x86c6faf889ebac8621bbb6dd8ca86aa6d51c9e6c
- Chain
- Robinhood Chain (4663)
- Compiler
- v0.8.28+commit.7893614a
- Optimization
- Enabled
- Creator
- 0x0F307b093d…D00D9a7836
- Creation tx
- 0x2749915e14…10dc808de5
Token
Not a token
This contract does not expose ERC-20 metadata.
Read contract (8)
FEE() → uint24
creatorShareBps() → uint16
launchpad() → address
locker() → address
nfpm() → address
owner() → address
protocol() → address
weth() → address
Events (2)
OwnershipTransferredV3Migrated
ABI
[
{
"inputs": [
{
"internalType": "address",
"name": "launchpad_",
"type": "address"
},
{
"internalType": "address",
"name": "nfpm_",
"type": "address"
},
{
"internalType": "address",
"name": "locker_",
"type": "address"
},
{
"internalType": "address",
"name": "weth_",
"type": "address"
},
{
"internalType": "address",
"name": "protocol_",
"type": "address"
},
{
"internalType": "uint16",
"name": "creatorShareBps_",
"type": "uint16"
},
{
"internalType": "address",
"name": "owner_",
"type": "address"
}
],
"stateMutability": "nonpayable",
"type": "constructor"
},
{
"inputs": [],
"name": "NotLaunchpad",
"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": "uint160",
"name": "got",
"type": "uint160"
},
{
"internalType": "uint160",
"name": "want",
"type": "uint160"
}
],
"name": "PoolSquatted",
"type": "error"
},
{
"inputs": [],
"name": "PriceOverflow",
"type": "error"
},
{
"inputs": [],
"name": "ReentrancyGuardReentrantCall",
"type": "error"
},
{
"inputs": [],
"name": "RescueFailed",
"type": "error"
},
{
"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": "token",
"type": "address"
},
{
"indexed": true,
"internalType": "address",
"name": "pool",
"type": "address"
},
{
"indexed": false,
"internalType": "uint256",
"name": "tokenId",
"type": "uint256"
},
{
"indexed": false,
"internalType": "uint256",
"name": "tokenLiquidity",
"type": "uint256"
},
{
"indexed": false,
"internalType": "uint256",
"name": "ethLiquidity",
"type": "uint256"
}
],
"name": "V3Migrated",
"type": "event"
},
{
"inputs": [],
"name": "FEE",
"outputs": [
{
"internalType": "uint24",
"name": "",
"type": "uint24"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "creatorShareBps",
"outputs": [
{
"internalType": "uint16",
"name": "",
"type": "uint16"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "launchpad",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "locker",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "token",
"type": "address"
},
{
"internalType": "address",
"name": "creator",
"type": "address"
}
],
"name": "migrate",
"outputs": [
{
"internalType": "address",
"name": "pool",
"type": "address"
}
],
"stateMutability": "payable",
"type": "function"
},
{
"inputs": [],
"name": "nfpm",
"outputs": [
{
"internalType": "contract INonfungiblePositionManager",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "owner",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "protocol",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "renounceOwnership",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "to",
"type": "address"
}
],
"name": "rescueETH",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "token",
"type": "address"
},
{
"internalType": "uint256",
"name": "amount",
"type": "uint256"
},
{
"internalType": "address",
"name": "to",
"type": "address"
}
],
"name": "rescueTokens",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "newOwner",
"type": "address"
}
],
"name": "transferOwnership",
"outputs": [],
"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: MIT
pragma solidity ^0.8.28;
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {Math} from "@openzeppelin/contracts/utils/math/Math.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {IWETH} from "./interfaces/IUniswapV2.sol";
import {IHoodMigrator, INonfungiblePositionManager} from "./interfaces/IV3Migration.sol";
/// @dev Minimal Uniswap v3 pool surface: we only read the current price.
interface IV3PoolSlot0 {
function slot0()
external
view
returns (
uint160 sqrtPriceX96,
int24 tick,
uint16 observationIndex,
uint16 observationCardinality,
uint16 observationCardinalityNext,
uint8 feeProtocol,
bool unlocked
);
}
/// @title HoodV3MigratorV2
/// @notice Hardened graduation target for hood.fun coins. Identical economics to
/// the original migrator (1% pool, full-range, eternal-locked to the
/// Locker with the immutable creator/protocol fee split) — but closes the
/// pool-squatting / price-manipulation vector on `createAndInitialize`.
///
/// SECURITY MODEL (survives an exposed.fun teardown):
/// - Every mint is PRICE-CHECKED against the intended graduation price
/// (Fix 1) and slippage-bounded (Fix 2). There is NO code path anywhere
/// — owner or otherwise — that mints at a chosen price. So even a leaked
/// owner key cannot seize or misprice a coin's liquidity.
/// - A squatted pool therefore only DoSes migration (funds stay safe in
/// the launchpad, attacker profits nothing). Recovery is OPERATIONAL:
/// the Safe normalizes the squatted pool (seed dust → swap to the
/// intended price) then re-calls the permissionless `launchpad.migrate`,
/// which now passes the checks. No privileged bypass is needed or given.
/// - `owner` (the Safe) can ONLY rescue funds that end up stuck IN THIS
/// CONTRACT (Fix 4). It can never touch locked LP — that lives in the
/// Locker, a separate contract with no withdraw path.
contract HoodV3MigratorV2 is IHoodMigrator, ReentrancyGuard, Ownable {
/// @dev 1% tier: canonical Uniswap fee amount 10000, tick spacing 200.
uint24 public constant FEE = 10000;
/// @dev Full range as usable ticks: nearest multiples of 200 to +/-887272.
int24 internal constant MIN_TICK = -887200;
int24 internal constant MAX_TICK = 887200;
/// @dev Fix 1 tolerance: the freshly-initialized pool price equals the
/// intended price exactly, so 1% is pure safety margin. A squatted pool
/// sits at a wildly different price and is rejected.
uint256 internal constant PRICE_TOLERANCE_BPS = 100; // 1%
uint256 internal constant BPS = 10_000;
address public immutable launchpad;
INonfungiblePositionManager public immutable nfpm;
address public immutable locker;
IWETH public immutable weth;
address public immutable protocol;
uint16 public immutable creatorShareBps;
event V3Migrated(
address indexed token,
address indexed pool,
uint256 tokenId,
uint256 tokenLiquidity,
uint256 ethLiquidity
);
error NotLaunchpad();
error PriceOverflow();
error PoolSquatted(uint160 got, uint160 want); // Fix 1: pool at an unexpected price
error RescueFailed();
modifier onlyLaunchpad() {
if (msg.sender != launchpad) revert NotLaunchpad();
_;
}
constructor(
address launchpad_,
address nfpm_,
address locker_,
address weth_,
address protocol_,
uint16 creatorShareBps_,
address owner_
) Ownable(owner_) {
require(creatorShareBps_ <= 10_000, "BAD_BPS");
require(
launchpad_ != address(0) && nfpm_ != address(0) && locker_ != address(0) && weth_ != address(0)
&& protocol_ != address(0),
"ZERO_ADDR"
);
launchpad = launchpad_;
nfpm = INonfungiblePositionManager(nfpm_);
locker = locker_;
weth = IWETH(weth_);
protocol = protocol_;
creatorShareBps = creatorShareBps_;
}
/// @inheritdoc IHoodMigrator
function migrate(address token, address creator)
external
payable
override
onlyLaunchpad
nonReentrant
returns (address pool)
{
uint256 tokenAmount = IERC20(token).balanceOf(address(this));
uint256 wethAmount = msg.value;
weth.deposit{value: wethAmount}();
// Uniswap v3 sorts pool tokens by address.
(address t0, address t1, uint256 a0, uint256 a1) = token < address(weth)
? (token, address(weth), tokenAmount, wethAmount)
: (address(weth), token, wethAmount, tokenAmount);
uint160 intended = _sqrtPriceX96(a0, a1);
pool = nfpm.createAndInitializePoolIfNecessary(t0, t1, FEE, intended);
// ---- Fix 1: reject a squatted / manipulated pool -------------------
// `createAndInitializePoolIfNecessary` silently returns a PRE-EXISTING
// pool at whatever price it was initialized to. If an attacker front-ran
// us and created this pool at a garbage price, minting here would deposit
// a skewed ratio and leave value stealable. So require the live price is
// within tolerance of the price WE intended; otherwise revert (migration
// is safely retried after the Safe normalizes the pool).
(uint160 current,,,,,,) = IV3PoolSlot0(pool).slot0();
uint256 lo = (uint256(intended) * (BPS - PRICE_TOLERANCE_BPS)) / BPS;
uint256 hi = (uint256(intended) * (BPS + PRICE_TOLERANCE_BPS)) / BPS;
if (current < lo || current > hi) revert PoolSquatted(current, intended);
IERC20(t0).approve(address(nfpm), a0);
IERC20(t1).approve(address(nfpm), a1);
(uint256 tokenId,,,) = nfpm.mint(
INonfungiblePositionManager.MintParams({
token0: t0,
token1: t1,
fee: FEE,
tickLower: MIN_TICK,
tickUpper: MAX_TICK,
amount0Desired: a0,
amount1Desired: a1,
// ---- Fix 2: slippage floor. Fix 1 (exact price check) is the real
// guard; this is a backstop. 99% (not 99.9%) so custom supplies
// across the full MIN..MAX range never false-revert on rounding
// dust in a full-range mint. Still reverts hard on a mispriced
// pool. (regression: test_migrate_acrossSupplyRange)
amount0Min: (a0 * 99) / 100,
amount1Min: (a1 * 99) / 100,
recipient: address(this),
deadline: block.timestamp
})
);
// Lock forever: hand the position to the Liquidity Locker, recording the
// immutable fee split in the same atomic transfer.
nfpm.safeTransferFrom(address(this), locker, tokenId, abi.encode(creator, protocol, creatorShareBps));
emit V3Migrated(token, pool, tokenId, tokenAmount, wethAmount);
}
// ---- Fix 4: rescue funds stuck IN THIS CONTRACT only ------------------
// Between migrations this contract should hold ~nothing (post-Fix-2 dust at
// most). These recover misdirected tokens / ETH. They CANNOT reach locked LP:
// that NFT lives in the Locker, which this contract does not own or control.
function rescueTokens(address token, uint256 amount, address to) external onlyOwner {
require(to != address(0), "ZERO_TO");
require(IERC20(token).transfer(to, amount), "TRANSFER_FAILED");
}
function rescueETH(address to) external onlyOwner nonReentrant {
require(to != address(0), "ZERO_TO");
(bool ok,) = to.call{value: address(this).balance}("");
if (!ok) revert RescueFailed();
}
/// @dev sqrtPriceX96 = floor(sqrt(amount1/amount0) * 2^96), computed without
/// intermediate overflow via 512-bit mulDiv then integer sqrt.
function _sqrtPriceX96(uint256 amount0, uint256 amount1) internal pure returns (uint160) {
uint256 ratioX192 = Math.mulDiv(amount1, 1 << 192, amount0);
uint256 sqrtP = Math.sqrt(ratioX192);
if (sqrtP > type(uint160).max) revert PriceOverflow();
return uint160(sqrtP);
}
receive() external payable {}
}
Chain explorer2941msChain node83ms