|
// SPDX-License-Identifier: MIT |
|
pragma solidity ^0.8.26; |
|
|
|
import {IPoolManager} from "v4-core/src/interfaces/IPoolManager.sol"; |
|
import {IUnlockCallback} from "v4-core/src/interfaces/callback/IUnlockCallback.sol"; |
|
import {IHooks} from "v4-core/src/interfaces/IHooks.sol"; |
|
import {PoolKey} from "v4-core/src/types/PoolKey.sol"; |
|
import {PoolId} from "v4-core/src/types/PoolId.sol"; |
|
import {Currency} from "v4-core/src/types/Currency.sol"; |
|
import {BalanceDelta} from "v4-core/src/types/BalanceDelta.sol"; |
|
import {ModifyLiquidityParams, SwapParams} from "v4-core/src/types/PoolOperation.sol"; |
|
import {BeforeSwapDelta, toBeforeSwapDelta} from "v4-core/src/types/BeforeSwapDelta.sol"; |
|
import {FixedPointMathLib} from "solady/utils/FixedPointMathLib.sol"; |
|
import {CurrencySettler} from "./CurrencySettler.sol"; |
|
import {CurveToken} from "./CurveToken.sol"; |
|
|
|
interface ICurveBuyRouter { |
|
function buy(PoolKey calldata key, uint256 minOut) external payable returns (uint256 out); |
|
} |
|
|
|
/// @title ProtectedCurveHookV3 |
|
/// @notice Identical curve/fees/invariants to v2 (quotable, claims-based), with |
|
/// ONE change: the network buy-&-burn targets an EXTERNAL, pre-existing token |
|
/// (CLAUDE) instead of a curve local to this hook. |
|
/// |
|
/// Why the mechanism differs: v2's buy-&-burn credits a network curve that lives |
|
/// on the SAME hook. CLAUDE's curve lives on the v2 hook, and its `burn()` is |
|
/// callable only by that hook — so this hook cannot mint/credit/burn it directly. |
|
/// Instead, the ¼-of-1% platform buy fee accrues in `pendingBurnFees`, and a |
|
/// permissionless `flushBurn()` redeems that ETH, BUYS CLAUDE through the |
|
/// existing CurveRouterV2 (real on-chain buy along CLAUDE's protected curve), and |
|
/// sends the received CLAUDE to a dead address — a permanent, verifiable burn. |
|
/// |
|
/// The buy happens in `flushBurn` (its own tx / its own PoolManager unlock), NOT |
|
/// during a swap, because the shared PoolManager forbids nested unlocks. |
|
/// |
|
/// CLAUDE token / router / hook are IMMUTABLE — the burn target can never be |
|
/// repointed (the same anti-rug guarantee v2 had for its network pool). |
|
contract ProtectedCurveHookV3 is IHooks, IUnlockCallback { |
|
using FixedPointMathLib for uint256; |
|
using CurrencySettler for Currency; |
|
|
|
// ---------------------------------------------------------------- state |
|
|
|
struct Curve { |
|
CurveToken token; // slot0: 20 bytes |
|
uint64 beta; // 8 bytes (WAD) |
|
uint32 createdAt; // 4 bytes |
|
address creator; // slot1: 20 bytes |
|
uint128 vault; // slot2: 16 bytes (wei ETH backing, held as 6909) |
|
uint128 supply; // 16 bytes (internal circulating supply) |
|
uint128 tokenReserve; // slot3: 16 bytes (6909 buyback inventory) |
|
} |
|
|
|
IPoolManager public immutable poolManager; |
|
address public owner; |
|
address public immutable feePayeeA; |
|
address public immutable feePayeeB; |
|
|
|
// Immutable buy-&-burn target: the CLAUDE token, the router that trades it, |
|
// and the hook its pool is bound to. Cannot be changed after deploy. |
|
address public immutable claudeToken; |
|
address public immutable claudeRouter; |
|
address public immutable claudeHook; |
|
address internal constant DEAD = 0x000000000000000000000000000000000000dEaD; |
|
|
|
mapping(PoolId => Curve) public curves; |
|
mapping(address => PoolId) public poolOf; |
|
|
|
uint256 public pendingBurnFees; // accrued ETH owed to CLAUDE buy-&-burn |
|
uint256 public totalClaudeBurned; // cumulative CLAUDE sent to DEAD |
|
mapping(address => uint256) public creatorFees; |
|
|
|
// ------------------------------------------------------------ constants |
|
|
|
uint256 internal constant WAD = 1e18; |
|
uint256 internal constant BPS = 10_000; |
|
uint256 public constant BETA_MIN = 0.825e18; |
|
uint256 public constant BETA_MAX = 0.99e18; |
|
uint256 public constant DEFAULT_BETA = 0.9e18; |
|
uint256 public constant BUY_PLATFORM_BPS = 100; |
|
uint256 public constant BUY_VAULT_BPS = 200; |
|
uint256 public constant SELL_CREATOR_BPS = 100; |
|
uint256 public constant SELL_VAULT_KEEP_BPS = 500; |
|
uint256 public constant MIN_SEED = 0.0001 ether; |
|
uint256 public constant GENESIS_TOKENS_PER_ETH = 1_000_000; |
|
uint256 internal constant MAX_SUPPLY = 1e36; |
|
uint160 internal constant SQRT_PRICE_1_1 = 79228162514264337593543950336; |
|
|
|
// unlock actions |
|
uint8 internal constant ACT_DEPOSIT = 1; |
|
uint8 internal constant ACT_WITHDRAW = 2; |
|
|
|
// --------------------------------------------------------------- events |
|
|
|
event CreateEvent( |
|
address indexed mint, PoolId indexed poolId, address indexed user, |
|
string name, string symbol, string uri, |
|
uint256 beta, uint256 vault, uint256 supply, uint256 timestamp |
|
); |
|
event TradeEvent( |
|
address indexed mint, address indexed user, bool isBuy, |
|
uint256 ethAmount, uint256 tokenAmount, uint256 vault, uint256 supply, |
|
uint256 nav, uint256 priceWad, uint256 timestamp |
|
); |
|
event BuyAndBurnEvent( |
|
uint256 ethAmount, uint256 tokenNotional, uint256 networkVault, uint256 networkSupply, uint256 timestamp |
|
); |
|
event CreatorFeesClaimed(address indexed creator, uint256 amount); |
|
|
|
// --------------------------------------------------------------- errors |
|
|
|
error OnlyPoolManager(); |
|
error OnlyOwner(); |
|
error NotLaunchpad(); |
|
error LiquidityDisabled(); |
|
error HookNotCallable(); |
|
error UnknownCurve(); |
|
error ExactOutputNotSupported(); |
|
error AmountOverflow(); |
|
error DustTrade(); |
|
error SellExceedsSupply(); |
|
error FloorViolated(); |
|
error BetaOutOfRange(); |
|
error SeedTooSmall(); |
|
error SupplyCapExceeded(); |
|
error NothingToBurn(); |
|
|
|
constructor( |
|
IPoolManager _pm, |
|
address _owner, |
|
address _a, |
|
address _b, |
|
address _claudeToken, |
|
address _claudeRouter, |
|
address _claudeHook |
|
) { |
|
poolManager = _pm; |
|
owner = _owner; |
|
feePayeeA = _a; |
|
feePayeeB = _b; |
|
claudeToken = _claudeToken; |
|
claudeRouter = _claudeRouter; |
|
claudeHook = _claudeHook; |
|
} |
|
|
|
receive() external payable {} |
|
|
|
modifier onlyPoolManager() { |
|
if (msg.sender != address(poolManager)) revert OnlyPoolManager(); |
|
_; |
|
} |
|
|
|
modifier onlyOwner() { |
|
if (msg.sender != owner) revert OnlyOwner(); |
|
_; |
|
} |
|
|
|
// --------------------------------------------------------------- launch |
|
|
|
function launch(string calldata name, string calldata symbol, string calldata uri, uint256 betaWad) |
|
external |
|
payable |
|
returns (address token, PoolId poolId) |
|
{ |
|
if (msg.value < MIN_SEED) revert SeedTooSmall(); |
|
uint256 beta = betaWad == 0 ? DEFAULT_BETA : betaWad; |
|
if (beta < BETA_MIN || beta > BETA_MAX) revert BetaOutOfRange(); |
|
|
|
CurveToken t = new CurveToken(name, symbol, uri); |
|
token = address(t); |
|
|
|
PoolKey memory key = PoolKey({ |
|
currency0: Currency.wrap(address(0)), |
|
currency1: Currency.wrap(token), |
|
fee: 0, |
|
tickSpacing: 60, |
|
hooks: IHooks(address(this)) |
|
}); |
|
poolId = key.toId(); |
|
poolManager.initialize(key, SQRT_PRICE_1_1); |
|
|
|
uint256 s0 = msg.value * GENESIS_TOKENS_PER_ETH; |
|
t.mint(msg.sender, s0); // genesis tokens to the creator (real, circulating) |
|
|
|
curves[poolId] = Curve({ |
|
token: t, |
|
beta: uint64(beta), |
|
createdAt: uint32(block.timestamp), |
|
creator: msg.sender, |
|
vault: uint128(msg.value), |
|
supply: uint128(s0), |
|
tokenReserve: 0 |
|
}); |
|
poolOf[token] = poolId; |
|
|
|
// Deposit the seed ETH into the PoolManager as a 6909 claim (the vault). |
|
poolManager.unlock(abi.encode(ACT_DEPOSIT, msg.value, address(0))); |
|
|
|
emit CreateEvent(token, poolId, msg.sender, name, symbol, uri, beta, msg.value, s0, block.timestamp); |
|
emit TradeEvent( |
|
token, msg.sender, true, msg.value, s0, msg.value, s0, |
|
_navWad(msg.value, s0), _priceWad(msg.value, s0, beta), block.timestamp |
|
); |
|
} |
|
|
|
/// @notice Permissionless. Redeems accrued burn fees, buys CLAUDE through the |
|
/// existing router, and sends the received CLAUDE to the dead address. |
|
function flushBurn() external returns (uint256 ethSpent, uint256 claudeBurned) { |
|
ethSpent = pendingBurnFees; |
|
if (ethSpent == 0) revert NothingToBurn(); |
|
pendingBurnFees = 0; // effects before interactions |
|
|
|
// 1) redeem the burn ETH from our 6909 claim into real ETH held here |
|
poolManager.unlock(abi.encode(ACT_WITHDRAW, ethSpent, address(this))); |
|
|
|
// 2) buy CLAUDE along its curve via the existing router (its own unlock) |
|
PoolKey memory k = PoolKey({ |
|
currency0: Currency.wrap(address(0)), |
|
currency1: Currency.wrap(claudeToken), |
|
fee: 0, |
|
tickSpacing: 60, |
|
hooks: IHooks(claudeHook) |
|
}); |
|
ICurveBuyRouter(claudeRouter).buy{value: ethSpent}(k, 0); |
|
|
|
// 3) burn every CLAUDE we now hold to the dead address |
|
claudeBurned = CurveToken(claudeToken).balanceOf(address(this)); |
|
if (claudeBurned > 0) { |
|
CurveToken(claudeToken).transfer(DEAD, claudeBurned); |
|
totalClaudeBurned += claudeBurned; |
|
} |
|
emit BuyAndBurnEvent(ethSpent, claudeBurned, 0, totalClaudeBurned, block.timestamp); |
|
} |
|
|
|
/// @notice Withdraw accrued ETH fees. Redeems the hook's ETH 6909 claims to |
|
/// real ETH via an unlock and forwards to the caller. |
|
function withdrawCreatorFees() external { |
|
uint256 amount = creatorFees[msg.sender]; |
|
if (amount == 0) return; |
|
creatorFees[msg.sender] = 0; |
|
poolManager.unlock(abi.encode(ACT_WITHDRAW, amount, msg.sender)); |
|
emit CreatorFeesClaimed(msg.sender, amount); |
|
} |
|
|
|
function transferOwnership(address newOwner) external onlyOwner { |
|
owner = newOwner; |
|
} |
|
|
|
// --------------------------------------------------------- unlock callback |
|
|
|
function unlockCallback(bytes calldata data) external override onlyPoolManager returns (bytes memory) { |
|
(uint8 action, uint256 amount, address who) = abi.decode(data, (uint8, uint256, address)); |
|
Currency eth = Currency.wrap(address(0)); |
|
if (action == ACT_DEPOSIT) { |
|
eth.settle(poolManager, address(this), amount, false); |
|
eth.take(poolManager, address(this), amount, true); |
|
} else if (action == ACT_WITHDRAW) { |
|
eth.settle(poolManager, address(this), amount, true); |
|
eth.take(poolManager, who, amount, false); |
|
} |
|
return ""; |
|
} |
|
|
|
// ---------------------------------------------------------- hook: swaps |
|
|
|
function beforeSwap(address sender, PoolKey calldata key, SwapParams calldata params, bytes calldata hookData) |
|
external |
|
onlyPoolManager |
|
returns (bytes4, BeforeSwapDelta, uint24) |
|
{ |
|
PoolId id = key.toId(); |
|
Curve storage c = curves[id]; |
|
if (address(c.token) == address(0)) revert UnknownCurve(); |
|
if (params.amountSpecified >= 0) revert ExactOutputNotSupported(); |
|
|
|
uint256 amtIn = uint256(-params.amountSpecified); |
|
if (amtIn > uint256(uint128(type(int128).max))) revert AmountOverflow(); |
|
address trader = hookData.length >= 32 ? abi.decode(hookData, (address)) : sender; |
|
|
|
uint256 amtOut = params.zeroForOne |
|
? _buy(c, key, amtIn, trader) |
|
: _sell(c, key, amtIn, trader); |
|
if (amtOut > uint256(uint128(type(int128).max))) revert AmountOverflow(); |
|
|
|
return ( |
|
IHooks.beforeSwap.selector, |
|
toBeforeSwapDelta(int128(uint128(amtIn)), -int128(uint128(amtOut))), |
|
0 |
|
); |
|
} |
|
|
|
function _buy(Curve storage c, PoolKey calldata key, uint256 ethIn, address trader) |
|
internal |
|
returns (uint256 minted) |
|
{ |
|
uint256 platformFee = ethIn * BUY_PLATFORM_BPS / BPS; |
|
uint256 backing = ethIn * (BPS - BUY_PLATFORM_BPS - BUY_VAULT_BPS) / BPS; |
|
uint256 vaultCredit = ethIn - platformFee; |
|
|
|
uint256 v = c.vault; |
|
uint256 s = c.supply; |
|
|
|
uint256 ratioWad = (v + backing).mulDiv(WAD, v); |
|
uint256 growWad = uint256(FixedPointMathLib.powWad(int256(ratioWad), int256(uint256(c.beta)))); |
|
uint256 newSupply = s.mulWad(growWad); |
|
if (newSupply > MAX_SUPPLY) revert SupplyCapExceeded(); |
|
minted = newSupply > s ? newSupply - s : 0; |
|
if (minted == 0) revert DustTrade(); |
|
if (minted * v > vaultCredit * s) revert FloorViolated(); |
|
|
|
uint256 newVault = v + vaultCredit; |
|
if (newVault > type(uint128).max) revert AmountOverflow(); |
|
c.vault = uint128(newVault); |
|
c.supply = uint128(s + minted); |
|
|
|
_routePlatformFee(platformFee, c.creator); |
|
|
|
key.currency0.take(poolManager, address(this), ethIn, true); |
|
|
|
uint256 fromReserve = c.tokenReserve >= minted ? minted : c.tokenReserve; |
|
if (fromReserve > 0) { |
|
c.tokenReserve = uint128(c.tokenReserve - fromReserve); |
|
key.currency1.settle(poolManager, address(this), fromReserve, true); |
|
} |
|
uint256 newMint = minted - fromReserve; |
|
if (newMint > 0) { |
|
c.token.mint(address(this), newMint); |
|
key.currency1.settle(poolManager, address(this), newMint, false); |
|
} |
|
|
|
emit TradeEvent( |
|
address(c.token), trader, true, ethIn, minted, newVault, s + minted, |
|
_navWad(newVault, s + minted), _priceWad(newVault, s + minted, c.beta), block.timestamp |
|
); |
|
} |
|
|
|
function _sell(Curve storage c, PoolKey calldata key, uint256 tokensIn, address trader) |
|
internal |
|
returns (uint256 payout) |
|
{ |
|
uint256 v = c.vault; |
|
uint256 s = c.supply; |
|
if (tokensIn >= s) revert SellExceedsSupply(); |
|
|
|
uint256 gross = tokensIn.mulDiv(v, s); |
|
uint256 creatorFee = gross * SELL_CREATOR_BPS / BPS; |
|
payout = gross * (BPS - SELL_CREATOR_BPS - SELL_VAULT_KEEP_BPS) / BPS; |
|
if (payout == 0) revert DustTrade(); |
|
|
|
uint256 newVault = v - payout - creatorFee; |
|
uint256 newSupply = s - tokensIn; |
|
if (newVault * s < v * newSupply) revert FloorViolated(); |
|
|
|
c.vault = uint128(newVault); |
|
c.supply = uint128(newSupply); |
|
c.tokenReserve = uint128(c.tokenReserve + tokensIn); |
|
creatorFees[c.creator] += creatorFee; |
|
|
|
key.currency1.take(poolManager, address(this), tokensIn, true); |
|
key.currency0.settle(poolManager, address(this), payout, true); |
|
|
|
emit TradeEvent( |
|
address(c.token), trader, false, payout, tokensIn, newVault, newSupply, |
|
_navWad(newVault, newSupply), _priceWad(newVault, newSupply, c.beta), block.timestamp |
|
); |
|
} |
|
|
|
function _routePlatformFee(uint256 platformFee, address creator) internal { |
|
uint256 burnShare = platformFee / 4; |
|
uint256 quarterA = platformFee / 4; |
|
uint256 quarterB = platformFee / 4; |
|
uint256 creatorShare = platformFee - burnShare - quarterA - quarterB; |
|
creatorFees[feePayeeA] += quarterA; |
|
creatorFees[feePayeeB] += quarterB; |
|
creatorFees[creator] += creatorShare; |
|
pendingBurnFees += burnShare; // buys & burns CLAUDE on flushBurn() |
|
} |
|
|
|
// ------------------------------------------------------- hook: guardrails |
|
|
|
function beforeInitialize(address sender, PoolKey calldata, uint160) external view onlyPoolManager returns (bytes4) { |
|
if (sender != address(this)) revert NotLaunchpad(); |
|
return IHooks.beforeInitialize.selector; |
|
} |
|
|
|
function beforeAddLiquidity(address, PoolKey calldata, ModifyLiquidityParams calldata, bytes calldata) |
|
external view onlyPoolManager returns (bytes4) |
|
{ |
|
revert LiquidityDisabled(); |
|
} |
|
|
|
function afterInitialize(address, PoolKey calldata, uint160, int24) external pure returns (bytes4) { revert HookNotCallable(); } |
|
function afterAddLiquidity(address, PoolKey calldata, ModifyLiquidityParams calldata, BalanceDelta, BalanceDelta, bytes calldata) external pure returns (bytes4, BalanceDelta) { revert HookNotCallable(); } |
|
function beforeRemoveLiquidity(address, PoolKey calldata, ModifyLiquidityParams calldata, bytes calldata) external pure returns (bytes4) { revert HookNotCallable(); } |
|
function afterRemoveLiquidity(address, PoolKey calldata, ModifyLiquidityParams calldata, BalanceDelta, BalanceDelta, bytes calldata) external pure returns (bytes4, BalanceDelta) { revert HookNotCallable(); } |
|
function afterSwap(address, PoolKey calldata, SwapParams calldata, BalanceDelta, bytes calldata) external pure returns (bytes4, int128) { revert HookNotCallable(); } |
|
function beforeDonate(address, PoolKey calldata, uint256, uint256, bytes calldata) external pure returns (bytes4) { revert HookNotCallable(); } |
|
function afterDonate(address, PoolKey calldata, uint256, uint256, bytes calldata) external pure returns (bytes4) { revert HookNotCallable(); } |
|
|
|
// ---------------------------------------------------------------- views |
|
|
|
function quoteBuy(PoolId id, uint256 ethIn) external view returns (uint256 tokensOut) { |
|
Curve storage c = curves[id]; |
|
if (address(c.token) == address(0)) revert UnknownCurve(); |
|
uint256 backing = ethIn * (BPS - BUY_PLATFORM_BPS - BUY_VAULT_BPS) / BPS; |
|
uint256 v = c.vault; |
|
uint256 s = c.supply; |
|
uint256 ratioWad = (v + backing).mulDiv(WAD, v); |
|
uint256 growWad = uint256(FixedPointMathLib.powWad(int256(ratioWad), int256(uint256(c.beta)))); |
|
uint256 newSupply = s.mulWad(growWad); |
|
tokensOut = newSupply > s ? newSupply - s : 0; |
|
} |
|
|
|
function quoteSell(PoolId id, uint256 tokensIn) external view returns (uint256 ethOut) { |
|
Curve storage c = curves[id]; |
|
if (address(c.token) == address(0)) revert UnknownCurve(); |
|
uint256 s = c.supply; |
|
if (tokensIn >= s) revert SellExceedsSupply(); |
|
uint256 gross = tokensIn.mulDiv(uint256(c.vault), s); |
|
ethOut = gross * (BPS - SELL_CREATOR_BPS - SELL_VAULT_KEEP_BPS) / BPS; |
|
} |
|
|
|
function curveState(PoolId id) |
|
external view |
|
returns (address token, address creator, uint256 vault, uint256 supply, uint256 beta, uint256 navWad, uint256 priceWad, uint256 maxLossBps) |
|
{ |
|
Curve storage c = curves[id]; |
|
if (address(c.token) == address(0)) revert UnknownCurve(); |
|
token = address(c.token); |
|
creator = c.creator; |
|
vault = c.vault; |
|
supply = c.supply; |
|
beta = c.beta; |
|
navWad = _navWad(vault, supply); |
|
priceWad = _priceWad(vault, supply, beta); |
|
maxLossBps = BPS - (uint256(9118) * beta / WAD); |
|
} |
|
|
|
function _navWad(uint256 v, uint256 s) internal pure returns (uint256) { |
|
return s == 0 ? 0 : v.mulDiv(WAD, s); |
|
} |
|
|
|
function _priceWad(uint256 v, uint256 s, uint256 beta) internal pure returns (uint256) { |
|
return s == 0 ? 0 : v.mulDiv(WAD, s).mulDiv(WAD, beta); |
|
} |
|
} |