Skip to content

Instantly share code, notes, and snippets.

@staccDOTsol
Created July 17, 2026 20:47
Show Gist options
  • Select an option

  • Save staccDOTsol/158ddea681e451862d9323ac400979ad to your computer and use it in GitHub Desktop.

Select an option

Save staccDOTsol/158ddea681e451862d9323ac400979ad to your computer and use it in GitHub Desktop.
wwrd.app x Sushi: route protected-curve v4 pools on Robinhood Chain through the already-deployed RedSnwapper — spec + executor shim + 3/3 passing mainnet-fork test

Routing wwrd.app protected-curve pools through Sushi — integration spec

TL;DR: this is a backend-only integration. Every contract needed is already deployed on Robinhood Chain — including Sushi's own RedSnwapper, which we've proven executes our swaps as-is (mainnet-fork test included, 3/3 passing). Your solver needs three things: discover pools (one event stream), quote them (one eth_call), and encode the route (your existing snwap pattern with our executor). No new Sushi contracts, no audits, no protocol changes.

Why bother: wwrd.app is a memecoin launchpad on Robinhood Chain (chainId 4663) where every coin trades on a protected bonding curve (Uniswap v4 NoOp hook — max round-trip loss capped at ~18% on-chain, floor only rises). Uniswap's frontend policy-blocks unvetted hooks, so this entire flow is unroutable there. Sushi can be the only aggregator that routes it.


1. Deployed contracts (Robinhood Chain, 4663)

contract address who deployed
RedSnwapper 0x8E6fD69A77e88ee20Ba4B4fBd59DfCDA3EC0E98A Sushi (already live)
Uniswap v4 PoolManager 0x8366a39CC670B4001A1121B8F6A443A643e40951 Uniswap (singleton)
ProtectedCurveHookV2 0x82Abbde617e76f0714EdE5fB59c3667c9826A888 us
ProtectedCurveHookV3 0xE8085C8D59e1eAd8cDf24A7f9d30Ee48380a6888 us
CurveRouterV2 (shared by both hooks) 0xA08278b06F6ED56D834DF94F5505497bC2a899A1 us
V4Quoter (stock Uniswap lens) 0xf14bd289ee0f41b1e00366252af8e6ae5c9cc5fc us
CurveSnwapExecutor (the shim) 0xAda04a884356Be0650fa5AC0d5f194f850104562 us

Recommended RPC: Alchemy (robinhood-mainnet.g.alchemy.com) — the default public RPC rejects multi-address log filters.

2. Pool discovery (one event stream)

Every coin is a native-ETH / token v4 pool. The complete pool list = the CreateEvent logs from the two hook addresses:

event CreateEvent(address indexed mint, bytes32 indexed poolId, address indexed user,
                  string name, string symbol, string uri,
                  uint256 beta, uint256 vault, uint256 supply, uint256 timestamp)

For each event, the pool is fully described by:

PoolKey { currency0: 0x0000000000000000000000000000000000000000,  // native ETH
          currency1: <mint>, fee: 0, tickSpacing: 60, hooks: <emitting hook> }

Tokens are standard 18-dec ERC-20s (transferable, permit-enabled). There is no pool retirement — curves are perpetual. hook.poolOf(token) -> poolId exists as a convenience lookup.

3. Quoting (one eth_call, no simulation infra needed)

Two equivalent options, both view calls:

a) stock V4Quoter (drop-in if you already quote v4 anywhere):

V4Quoter(0xf14b...c5fc).quoteExactInputSingle((poolKey, zeroForOne, exactAmount, ""))
  -> (amountOut, gasEstimate)          // zeroForOne = true for ETH->token

b) the hook's own quote functions (cheaper):

hook.quoteBuy(poolId, ethIn)    -> tokensOut
hook.quoteSell(poolId, tokensIn) -> ethOut

These agree exactly — verified live on mainnet: quoteExactInputSingle(0.01 ETH -> WWRD) = hook.quoteBuy = 6901263652944117660000. Quotes are deterministic (pure curve math over vault/supply state) — no ticks, no liquidity walk. Exact-in only; exact-out is not supported.

Price/impact model, if you want closed-form instead of calls: supply follows S = k·V^0.9; a buy of e ETH mints S·((V+0.97e)/V)^0.9 − S; a sell of t tokens pays 0.94·t·V/S. (3% buy fee, 6% sell fee, built into the numbers above.)

4. Execution — your existing snwap pattern, our executor

CurveSnwapExecutor (0xAda0...4562) is a stateless, ownerless shim that adapts RedSnwapper's executor convention (funds placed on the executor, call from SafeExecutor, min-out enforced by RedSnwapper on the recipient's balance delta) to our router. Encoding:

Buy (ETH -> coin):

redSnwapper.snwap{value: amountIn}(
    NATIVE,                    // 0xEeee...EEeE
    amountIn,
    recipient,
    coin,                      // tokenOut
    minOut,                    // slippage — enforced by RedSnwapper
    0xAda04a884356Be0650fa5AC0d5f194f850104562,
    abi.encodeCall(CurveSnwapExecutor.buyCurve, (ROUTER, poolKey, recipient))
)

Sell (coin -> ETH): identical shape with tokenIn = coin, tokenOut = NATIVE, and sellCurve calldata. (User approves RedSnwapper as usual.)

That's it. No approvals to seed, no state, nothing to operate.

5. Proof — runnable against your deployed contract

test/SushiRedSnwapper.t.sol in this repo forks Robinhood mainnet and swaps a live coin through your deployed RedSnwapper (buy, buy+sell round-trip with the <18% floor assertion, and a min-out revert case):

forge test --match-contract SushiRedSnwapper \
  --fork-url https://rpc.mainnet.chain.robinhood.com -vv
# 3 passed, 0 failed

6. Notes / honest caveats

  • Hook + router are unaudited (~400 lines each; fork-tested, live on mainnet with real volume). The executor shim is ~50 lines and stateless.
  • The curve pays out sells at NAV (the floor), so instant round-trip loss is capped at ~18% — that cap is asserted in the test.
  • Two hook generations run side by side (v2/v3 — the difference is only which network token the platform fee buy-&-burns). Same router, same PoolKey shape, same quoting for both.
  • Platform stats (~2.5 days live): 11+ coins, 650+ trades, 65+ unique wallets, ~11 ETH cumulative volume.

Contact: jarettrsdunn@gmail.com · @STACCoverflow · wwrd.app

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Currency} from "v4-core/src/types/Currency.sol";
import {IERC20Minimal} from "v4-core/src/interfaces/external/IERC20Minimal.sol";
import {IPoolManager} from "v4-core/src/interfaces/IPoolManager.sol";
/// @notice Library used to interact with PoolManager.sol to settle any open deltas.
/// To settle a positive delta (a credit to the user), a user may take or mint.
/// To settle a negative delta (a debt on the user), a user make transfer or burn to pay off a debt.
/// @dev Note that sync() is called before any erc-20 transfer in `settle`.
library CurrencySettler {
/// @notice Settle (pay) a currency to the PoolManager
/// @param currency Currency to settle
/// @param manager IPoolManager to settle to
/// @param payer Address of the payer, the token sender
/// @param amount Amount to send
/// @param burn If true, burn the ERC-6909 token, otherwise ERC20-transfer to the PoolManager
function settle(Currency currency, IPoolManager manager, address payer, uint256 amount, bool burn) internal {
// for native currencies or burns, calling sync is not required
// short circuit for ERC-6909 burns to support ERC-6909-wrapped native tokens
if (burn) {
manager.burn(payer, currency.toId(), amount);
} else if (currency.isAddressZero()) {
manager.settle{value: amount}();
} else {
manager.sync(currency);
if (payer != address(this)) {
IERC20Minimal(Currency.unwrap(currency)).transferFrom(payer, address(manager), amount);
} else {
IERC20Minimal(Currency.unwrap(currency)).transfer(address(manager), amount);
}
manager.settle();
}
}
/// @notice Take (receive) a currency from the PoolManager
/// @param currency Currency to take
/// @param manager IPoolManager to take from
/// @param recipient Address of the recipient, the token receiver
/// @param amount Amount to receive
/// @param claims If true, mint the ERC-6909 token, otherwise ERC20-transfer from the PoolManager to recipient
function take(Currency currency, IPoolManager manager, address recipient, uint256 amount, bool claims) internal {
claims ? manager.mint(recipient, currency.toId(), amount) : manager.take(currency, recipient, amount);
}
}
// 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 {PoolKey} from "v4-core/src/types/PoolKey.sol";
import {Currency} from "v4-core/src/types/Currency.sol";
import {BalanceDelta} from "v4-core/src/types/BalanceDelta.sol";
import {SwapParams} from "v4-core/src/types/PoolOperation.sol";
import {TickMath} from "v4-core/src/libraries/TickMath.sol";
import {CurrencySettler} from "./CurrencySettler.sol";
import {CurveToken} from "./CurveToken.sol";
/// @notice Standard settle-AFTER swap entry for the v2 protected curve. Because
/// v2 uses normal v4 claims accounting, this is an ordinary router — no
/// settle-before hack — and any standard v4 router (incl. the Universal Router)
/// would work identically. Exact-in only: buy with ETH, sell a token amount.
contract CurveRouterV2 is IUnlockCallback {
using CurrencySettler for Currency;
IPoolManager public immutable poolManager;
error OnlyPoolManager();
error SlippageExceeded();
error ZeroAmount();
struct SwapData {
PoolKey key;
bool isBuy;
uint256 amountIn;
uint256 minOut;
address user;
}
constructor(IPoolManager _pm) {
poolManager = _pm;
}
receive() external payable {}
function buy(PoolKey calldata key, uint256 minOut) external payable returns (uint256 out) {
if (msg.value == 0) revert ZeroAmount();
out = abi.decode(
poolManager.unlock(abi.encode(SwapData(key, true, msg.value, minOut, msg.sender))), (uint256)
);
}
function sell(PoolKey calldata key, uint256 amountIn, uint256 minOut) external returns (uint256 out) {
if (amountIn == 0) revert ZeroAmount();
CurveToken(Currency.unwrap(key.currency1)).transferFrom(msg.sender, address(this), amountIn);
out = abi.decode(
poolManager.unlock(abi.encode(SwapData(key, false, amountIn, minOut, msg.sender))), (uint256)
);
}
function unlockCallback(bytes calldata raw) external returns (bytes memory) {
if (msg.sender != address(poolManager)) revert OnlyPoolManager();
SwapData memory d = abi.decode(raw, (SwapData));
// Standard v4 order: swap first, then resolve deltas.
BalanceDelta delta = poolManager.swap(
d.key,
SwapParams({
zeroForOne: d.isBuy,
amountSpecified: -int256(d.amountIn),
sqrtPriceLimitX96: d.isBuy ? TickMath.MIN_SQRT_PRICE + 1 : TickMath.MAX_SQRT_PRICE - 1
}),
abi.encode(d.user)
);
uint256 out;
if (d.isBuy) {
// pay ETH input, take token output to the user
d.key.currency0.settle(poolManager, address(this), d.amountIn, false);
out = uint256(uint128(delta.amount1()));
if (out < d.minOut) revert SlippageExceeded();
d.key.currency1.take(poolManager, d.user, out, false);
} else {
// pay token input, take ETH output to the user
d.key.currency1.settle(poolManager, address(this), d.amountIn, false);
out = uint256(uint128(delta.amount0()));
if (out < d.minOut) revert SlippageExceeded();
d.key.currency0.take(poolManager, d.user, out, false);
}
return abi.encode(out);
}
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
import {PoolKey} from "v4-core/src/types/PoolKey.sol";
import {Currency} from "v4-core/src/types/Currency.sol";
import {CurveToken} from "./CurveToken.sol";
interface ICurveRouter {
function buy(PoolKey calldata key, uint256 minOut) external payable returns (uint256 out);
function sell(PoolKey calldata key, uint256 amountIn, uint256 minOut) external returns (uint256 out);
}
/// @title CurveSnwapExecutor
/// @notice Stateless executor shim that makes protected-curve pools tradable
/// through Sushi's deployed RedSnwapper (0x8E6fD69A77e88ee20Ba4B4fBd59DfCDA3EC0E98A
/// on Robinhood Chain) with ZERO changes on Sushi's side.
///
/// RedSnwapper's flow: input funds are placed on the executor address, the call
/// is made from its SafeExecutor, and min-out is enforced by RedSnwapper itself
/// by checking the recipient's balance delta. Our CurveRouterV2 pays output to
/// msg.sender — so this shim simply swaps as itself and forwards everything to
/// the recipient. It holds nothing between transactions and has no owner.
///
/// Sushi backend encoding:
/// buy : snwap(NATIVE, amountIn, recipient, token, minOut, THIS, abi.encodeCall(buyCurve, (router, key, recipient)))
/// sell: snwap(token, amountIn, recipient, NATIVE, minOut, THIS, abi.encodeCall(sellCurve, (router, key, recipient)))
/// where router = the CurveRouterV2 shared by the v2/v3 hooks and key is the
/// coin's PoolKey {0x0, token, 0, 60, hook}.
contract CurveSnwapExecutor {
error EthSendFailed();
receive() external payable {}
/// ETH (msg.value) -> curve tokens, forwarded to `recipient`.
function buyCurve(ICurveRouter router, PoolKey calldata key, address recipient) external payable {
uint256 out = router.buy{value: msg.value}(key, 0); // RedSnwapper enforces min-out
CurveToken(Currency.unwrap(key.currency1)).transfer(recipient, out);
}
/// Curve tokens (our full balance, placed here by RedSnwapper) -> ETH to `recipient`.
function sellCurve(ICurveRouter router, PoolKey calldata key, address recipient) external {
CurveToken token = CurveToken(Currency.unwrap(key.currency1));
uint256 bal = token.balanceOf(address(this));
token.approve(address(router), bal);
uint256 out = router.sell(key, bal, 0); // RedSnwapper enforces min-out
(bool ok,) = recipient.call{value: out}("");
if (!ok) revert EthSendFailed();
}
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
import {ERC20} from "solady/tokens/ERC20.sol";
/// @notice Per-launch curve token. Supply is fully elastic: only the hook
/// (its deployer) can mint on buys and burn on sells. There is no premine.
contract CurveToken is ERC20 {
address public immutable hook;
string private _name;
string private _symbol;
/// @notice Off-chain metadata (image, description, socials) — pump.fun-style uri.
string public uri;
error OnlyHook();
constructor(string memory name_, string memory symbol_, string memory uri_) {
hook = msg.sender;
_name = name_;
_symbol = symbol_;
uri = uri_;
}
modifier onlyHook() {
if (msg.sender != hook) revert OnlyHook();
_;
}
function name() public view override returns (string memory) {
return _name;
}
function symbol() public view override returns (string memory) {
return _symbol;
}
function mint(address to, uint256 amount) external onlyHook {
_mint(to, amount);
}
function burn(address from, uint256 amount) external onlyHook {
_burn(from, amount);
}
}
// 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";
/// @title ProtectedCurveHookV2
/// @notice The protected curve, re-architected for STANDARD v4 settle-after
/// accounting so a stock `V4Quoter` (and any standard router) can simulate and
/// route it — the prerequisite for app.uniswap.org / aggregator listing.
///
/// The curve math, fees, and invariants are identical to v1. The ONLY change is
/// custody: instead of taking real ETH into the hook mid-swap (which needed a
/// settle-before-swap router and made the pool un-quotable), the vault ETH lives
/// as ERC-6909 CLAIMS inside the PoolManager, and every `beforeSwap` moves value
/// purely through claims + deltas — no real balance is required during a quote.
///
/// - vault ETH -> 6909 claim of currency0 held by this hook
/// - token side -> 6909 claim reserve (buyback inventory) + elastic mint
/// - fees (ETH) -> pull ledger; withdrawn by redeeming ETH claims via unlock
contract ProtectedCurveHookV2 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;
mapping(PoolId => Curve) public curves;
mapping(address => PoolId) public poolOf;
PoolId public networkPoolId;
bool public networkSet;
uint256 public pendingBurnFees;
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 NetworkPoolSet(PoolId indexed poolId, uint256 sweptFees);
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 NetworkAlreadySet();
error SupplyCapExceeded();
constructor(IPoolManager _pm, address _owner, address _a, address _b) {
poolManager = _pm;
owner = _owner;
feePayeeA = _a;
feePayeeB = _b;
}
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
);
}
function setNetworkPool(PoolId poolId) external onlyOwner {
if (networkSet) revert NetworkAlreadySet();
if (address(curves[poolId].token) == address(0)) revert UnknownCurve();
networkPoolId = poolId;
networkSet = true;
uint256 pending = pendingBurnFees;
pendingBurnFees = 0;
if (pending > 0) _buyAndBurn(pending);
emit NetworkPoolSet(poolId, pending);
}
/// @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) {
// pay `amount` real ETH into the PM and receive an equal 6909 claim
eth.settle(poolManager, address(this), amount, false);
eth.take(poolManager, address(this), amount, true);
} else if (action == ACT_WITHDRAW) {
// burn `amount` of our ETH 6909 claim and send real ETH to `who`
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();
// specified = +amtIn (hook consumed the input), unspecified = -amtOut (hook owes output)
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);
// Input ETH -> 6909 claim to the hook (the vault). Simulatable.
key.currency0.take(poolManager, address(this), ethIn, true);
// Deliver `minted` tokens: from the 6909 buyback reserve first, mint the rest.
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); // burn 6909 to pay
}
uint256 newMint = minted - fromReserve;
if (newMint > 0) {
c.token.mint(address(this), newMint);
key.currency1.settle(poolManager, address(this), newMint, false); // transfer real to pay
}
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;
// Input tokens -> 6909 claim reserve. Output ETH -> burn our 6909 ETH claim to pay.
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;
if (burnShare > 0) {
if (networkSet) _buyAndBurn(burnShare);
else pendingBurnFees += burnShare;
}
}
function _buyAndBurn(uint256 ethFee) internal {
Curve storage n = curves[networkPoolId];
uint256 v = n.vault;
uint256 s = n.supply;
uint256 ratioWad = (v + ethFee).mulDiv(WAD, v);
uint256 growWad = uint256(FixedPointMathLib.powWad(int256(ratioWad), int256(uint256(n.beta))));
uint256 notional = s.mulWad(growWad);
notional = notional > s ? notional - s : 0;
uint256 newVault = v + ethFee;
if (newVault > type(uint128).max) revert AmountOverflow();
n.vault = uint128(newVault);
emit BuyAndBurnEvent(ethFee, notional, newVault, s, block.timestamp);
}
// ------------------------------------------------------- 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);
}
}
// 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);
}
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
import {Test} from "forge-std/Test.sol";
import {PoolKey} from "v4-core/src/types/PoolKey.sol";
import {Currency} from "v4-core/src/types/Currency.sol";
import {IHooks} from "v4-core/src/interfaces/IHooks.sol";
import {CurveSnwapExecutor, ICurveRouter} from "../src/CurveSnwapExecutor.sol";
import {CurveToken} from "../src/CurveToken.sol";
interface IRedSnwapper {
function snwap(
address tokenIn,
uint256 amountIn,
address recipient,
address tokenOut,
uint256 amountOutMin,
address executor,
bytes calldata executorData
) external payable returns (uint256 amountOut);
}
/// Fork test: an end-to-end swap of the CLAUDE protected-curve coin through
/// Sushi's REAL deployed RedSnwapper on Robinhood Chain — zero Sushi-side
/// changes, only our stateless executor shim in between.
/// Run: forge test --match-contract SushiRedSnwapper --fork-url $ROBINHOOD_RPC -vv
contract SushiRedSnwapperTest is Test {
// All REAL mainnet contracts (chain 4663)
IRedSnwapper constant SNWAPPER = IRedSnwapper(0x8E6fD69A77e88ee20Ba4B4fBd59DfCDA3EC0E98A);
address constant ROUTER = 0xA08278b06F6ED56D834DF94F5505497bC2a899A1; // CurveRouterV2
address constant HOOK_V2 = 0x82Abbde617e76f0714EdE5fB59c3667c9826A888;
address constant CLAUDE = 0x5fc255270400834E5242808d7800E9d00C31a87E;
address constant NATIVE = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE;
CurveSnwapExecutor executor;
address alice = makeAddr("alice");
PoolKey key;
function setUp() public {
executor = new CurveSnwapExecutor();
key = PoolKey({
currency0: Currency.wrap(address(0)),
currency1: Currency.wrap(CLAUDE),
fee: 0,
tickSpacing: 60,
hooks: IHooks(HOOK_V2)
});
vm.deal(alice, 10 ether);
}
function test_buy_through_real_redSnwapper() public {
uint256 ethIn = 0.05 ether;
uint256 before = CurveToken(CLAUDE).balanceOf(alice);
vm.prank(alice);
uint256 out = SNWAPPER.snwap{value: ethIn}(
NATIVE,
ethIn,
alice,
CLAUDE,
1, // min-out enforced by RedSnwapper itself
address(executor),
abi.encodeCall(CurveSnwapExecutor.buyCurve, (ICurveRouter(ROUTER), key, alice))
);
assertGt(out, 0, "no CLAUDE out");
assertEq(CurveToken(CLAUDE).balanceOf(alice) - before, out, "recipient balance mismatch");
assertEq(CurveToken(CLAUDE).balanceOf(address(executor)), 0, "executor retained tokens");
}
function test_roundtrip_buy_then_sell_through_real_redSnwapper() public {
// buy first
vm.prank(alice);
uint256 bought = SNWAPPER.snwap{value: 0.05 ether}(
NATIVE, 0.05 ether, alice, CLAUDE, 1,
address(executor),
abi.encodeCall(CurveSnwapExecutor.buyCurve, (ICurveRouter(ROUTER), key, alice))
);
// sell it all back through RedSnwapper (it transferFroms tokens to the executor)
vm.startPrank(alice);
CurveToken(CLAUDE).approve(address(SNWAPPER), bought);
uint256 ethBefore = alice.balance;
uint256 out = SNWAPPER.snwap(
CLAUDE, bought, alice, NATIVE, 1,
address(executor),
abi.encodeCall(CurveSnwapExecutor.sellCurve, (ICurveRouter(ROUTER), key, alice))
);
vm.stopPrank();
assertGt(out, 0, "no ETH out");
assertEq(alice.balance - ethBefore, out, "recipient ETH mismatch");
// protected floor: instant round-trip loss must stay under ~18%
uint256 loss = 0.05 ether - out;
assertLt(loss * 10_000 / 0.05 ether, 1800, "round-trip loss exceeded 18%");
}
function test_minOut_enforced_by_redSnwapper() public {
vm.prank(alice);
vm.expectRevert(); // MinimalOutputBalanceViolation
SNWAPPER.snwap{value: 0.01 ether}(
NATIVE, 0.01 ether, alice, CLAUDE,
type(uint128).max, // impossible min-out
address(executor),
abi.encodeCall(CurveSnwapExecutor.buyCurve, (ICurveRouter(ROUTER), key, alice))
);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment