Contract detail
PonsV2MemeHook
Live Robinhood Chain account facts for 0xe6da98fa…1ffba044 with at least 128 observed transactions. The history total is updating as indexing continues.
- Address type
- Smart contract
- Transactions
- 128+ observed
- First indexed block
- #90281400
- Latest indexed block
- #103996984
- History coverage
- Recent activity available; total is catching up
- Indexed token transfers
- 1201
- ERC-20 transfers
- 1201
- Emitted events
- 2239
- Verification
- Verified
- Compiler
- 0.8.35+commit.47b9dedd
- Verification match
- exact
- Creation transaction
- 0x3110dda2…b4e26611
- Created at block
- #90281400
- Language
- Solidity
- EVM version
- cancun
- License
- MIT
- Compiler optimization
- Enabled · 200 runs
- Deployed bytecode hash
- 0x2adbf0163c6a394de2f6c96f99184cf1935f3e38319a0333ce03d32a0cd7a7e7
Related activity
Transactions
Verified contract
Contract source code
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {Ownable2Step} from "@openzeppelin/contracts/access/Ownable2Step.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {IHooks} from "@uniswap/v4-core/src/interfaces/IHooks.sol";
import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol";
import {IUnlockCallback} from "@uniswap/v4-core/src/interfaces/callback/IUnlockCallback.sol";
import {Hooks} from "@uniswap/v4-core/src/libraries/Hooks.sol";
import {StateLibrary} from "@uniswap/v4-core/src/libraries/StateLibrary.sol";
import {TickMath} from "@uniswap/v4-core/src/libraries/TickMath.sol";
import {FullMath} from "@uniswap/v4-core/src/libraries/FullMath.sol";
import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol";
import {PoolId} from "@uniswap/v4-core/src/types/PoolId.sol";
import {Currency} from "@uniswap/v4-core/src/types/Currency.sol";
import {BalanceDelta} from "@uniswap/v4-core/src/types/BalanceDelta.sol";
import {SwapParams} from "@uniswap/v4-core/src/types/PoolOperation.sol";
import {BaseHook} from "@uniswap/v4-hooks-public/src/base/BaseHook.sol";
import {PonsV2BuybackVault} from "../PonsV2BuybackVault.sol";
import {FeePolicySnapshot, IPonsV2FeeEscrow, IPonsV2FeePolicy} from "../interfaces/ILaunchpadV2.sol";
/**
* @title PonsV2MemeHook
* @notice Singleton Uniswap V4 hook shared by every graduated pons v2 pool.
* Takes a fee cut on every swap via `afterSwap` (Flaunch-style Internal Swap
* Pool), and whenever that cut lands in the memecoin, converts it back to
* the pool's quote currency (ETH for the common native pairToken, or the
* launch's chosen ERC-20 pairToken otherwise) against the pool's own
* liquidity before it is ever distributed. The same protocol / creator /
* buyback-and-burn split that governs PonsV2BondingCurve's pre-graduation
* fee sweep lives here, read live by the curve through IPonsV2FeePolicy so
* both phases behave identically.
*/
contract PonsV2MemeHook is BaseHook, IUnlockCallback, IPonsV2FeePolicy, Ownable2Step, ReentrancyGuard {
using SafeERC20 for IERC20;
enum SwapDirection {
MemecoinToQuote,
QuoteToMemecoin
}
struct LaunchInfo {
bool registered;
bool memecoinIsCurrency0;
address memecoin;
address quoteToken; // address(0) denotes native ETH
address creator;
// Buyback locks retain this recipient even when future immediate
// creator payouts are transferred to a different address.
address buybackCreatorRecipient;
address protocolFeeRecipient;
// Creator-chosen at launch on PonsV2LaunchFactory, snapshotted here
// at registerPool time. Charged the same way hookFeeBps is, but paid
// entirely to the creator, bypassing the protocol/buyback split.
uint16 creatorTaxBps;
uint16 protocolFeeShareBps;
uint16 buybackBurnBps;
uint16 hookFeeBps;
uint16 maxInternalPriceImpactBps;
bool buybackEnabled;
}
uint256 private constant BASIS_POINTS = 10_000;
uint256 private constant MAX_PROTOCOL_FEE_SHARE_BPS = 5_000;
uint256 private constant MAX_HOOK_FEE_BPS = 1_000;
// Mirrors PonsV2BondingCurve's own ceiling, so a graduated pool can never
// charge more per trade than the curve it graduated from.
uint256 private constant MAX_TOTAL_TRADE_FEE_BPS = 2_000;
error NotFactory();
error AlreadySet();
error OwnershipCannotBeRenounced();
error ZeroAddress();
error InvalidBps();
error AlreadyRegistered();
error UnknownPool();
error InvalidPoolKey();
error NotFeeSweepOperator();
error InternalSwapRequiresOperator();
error SlippageExceeded(uint256 actual, uint256 minimum);
error MinimumOutputRequired();
error InexactQuoteTransfer(address token, uint256 expected, uint256 received);
error NothingToRescue();
event FactorySet(address factory);
event PoolRegistered(PoolId indexed poolId, address memecoin, address quoteToken, address creator);
event CreatorFeeRecipientUpdated(
PoolId indexed poolId, address indexed previousRecipient, address indexed newRecipient
);
// Reported separately because the two accrue to different ledgers: the
// fee splits across protocol, buyback and creator on sweep, while the tax
// is paid to the creator in full.
event HookFeeCollected(PoolId indexed poolId, address currency, uint256 feeAmount, uint256 taxAmount);
event PoolFeesSwept(
PoolId indexed poolId,
uint256 protocolAmount,
uint256 buybackAmount,
uint256 creatorAmount,
uint256 tokensLocked
);
event PoolFeesRescued(
PoolId indexed poolId, address indexed quoteToken, uint256 protocolAmount, uint256 creatorAmount
);
event PoolBuybackSkipped(PoolId indexed poolId, uint256 foldedBackQuote);
event PoolConversionSkipped(PoolId indexed poolId, uint256 retainedMemecoin);
event BuybackVaultSet(address vault);
event ProtocolFeeShareUpdated(uint256 bps);
event BuybackBurnBpsUpdated(uint256 bps);
event HookFeeBpsUpdated(uint256 bps);
event MaxInternalPriceImpactUpdated(uint256 bps);
event ProtocolFeeRecipientUpdated(address recipient);
event FeeSweepOperatorUpdated(address operator);
event BuybackEnabledUpdated(PoolId indexed poolId, bool enabled);
IPonsV2FeeEscrow public immutable feeEscrow;
address public factory;
PonsV2BuybackVault public buybackVault;
address public protocolFeeRecipient;
uint256 public protocolFeeShareBps;
uint256 public buybackBurnBps;
uint256 public hookFeeBps;
uint256 public maxInternalPriceImpactBps;
address public feeSweepOperator;
mapping(PoolId => LaunchInfo) public launches;
mapping(PoolId => PoolKey) private _poolKeys;
mapping(PoolId => mapping(address currency => uint256 amount)) public pendingFees;
// Tracked separately from pendingFees so the creator tax never enters
// the protocol/buyback split math; it is folded straight into the
// creator's payout at sweep time.
mapping(PoolId => mapping(address currency => uint256 amount)) public pendingCreatorTax;
// The slice of pendingFees already earmarked for buyback-and-lock, set
// aside as each swap's fee was charged under whatever the pool's buyback
// flag said at that moment. Bucketing at accrual rather than deriving the
// slice at sweep time keeps the flag forward-looking: toggling it decides
// how the next swap's fee is split, never how an already-charged one is.
// Held per currency and carried across the memecoin-to-quote conversion
// in proportion to the fee it rode in on, so an earmark accrued in the
// memecoin still reaches the vest.
mapping(PoolId => mapping(address currency => uint256 amount)) public pendingBuyback;
modifier onlyFactory() {
if (msg.sender != factory) revert NotFactory();
_;
}
/**
* @param poolManager_ The canonical Uniswap V4 pool manager.
* @param feeEscrow_ Shared claimable balance ledger, also used by every bonding curve.
* @param protocolFeeRecipient_ Escrow key credited with the protocol's share.
* @param initialOwner_ Protocol deployer; the only address that can ever change fee policy.
*/
constructor(
IPoolManager poolManager_,
IPonsV2FeeEscrow feeEscrow_,
address protocolFeeRecipient_,
address initialOwner_
) BaseHook(poolManager_) Ownable(initialOwner_) {
if (address(poolManager_) == address(0) || address(feeEscrow_) == address(0)) {
revert ZeroAddress();
}
if (protocolFeeRecipient_ == address(0)) revert ZeroAddress();
feeEscrow = feeEscrow_;
protocolFeeRecipient = protocolFeeRecipient_;
protocolFeeShareBps = 3_000;
buybackBurnBps = 5_000;
hookFeeBps = 100;
maxInternalPriceImpactBps = 300;
feeSweepOperator = initialOwner_;
}
/**
* @notice Only `afterSwap` is enabled: fee collection happens once per
* swap, after the pool's own core swap math has already run.
*/
function getHookPermissions() public pure override returns (Hooks.Permissions memory) {
return Hooks.Permissions({
beforeInitialize: true,
afterInitialize: false,
beforeAddLiquidity: false,
afterAddLiquidity: false,
beforeRemoveLiquidity: false,
afterRemoveLiquidity: false,
beforeSwap: false,
afterSwap: true,
beforeDonate: false,
afterDonate: false,
beforeSwapReturnDelta: false,
afterSwapReturnDelta: true,
afterAddLiquidityReturnDelta: false,
afterRemoveLiquidityReturnDelta: false
});
}
// ---------------------------------------------------------------------
// Owner-only configuration
// ---------------------------------------------------------------------
/**
* @notice One-time wiring of the v2 factory, set after both are deployed
* since the factory needs this hook's mined address to build pool keys.
*/
function setFactory(address factory_) external onlyOwner {
if (factory != address(0)) revert AlreadySet();
if (factory_ == address(0)) revert ZeroAddress();
factory = factory_;
emit FactorySet(factory_);
}
/**
* @notice One-time wiring of the shared five-year buyback vest, set
* after both are deployed, so `_distribute` can lock the buyback leg
* into it instead of burning it.
*/
function setBuybackVault(PonsV2BuybackVault buybackVault_) external onlyOwner {
if (address(buybackVault) != address(0)) revert AlreadySet();
if (address(buybackVault_) == address(0)) revert ZeroAddress();
buybackVault = buybackVault_;
emit BuybackVaultSet(address(buybackVault_));
}
/**
* @notice Permanently disabled. An ownerless hook could never rotate the
* fee sweep operator, so accrued fees on every graduated pool would stay
* stranded. Ownership can still be transferred to a new owner.
*/
function renounceOwnership() public pure override {
revert OwnershipCannotBeRenounced();
}
function setProtocolFeeShareBps(uint256 bps) external onlyOwner {
if (bps > MAX_PROTOCOL_FEE_SHARE_BPS) revert InvalidBps();
protocolFeeShareBps = bps;
emit ProtocolFeeShareUpdated(bps);
}
function setBuybackBurnBps(uint256 bps) external onlyOwner {
if (bps > BASIS_POINTS) revert InvalidBps();
buybackBurnBps = bps;
emit BuybackBurnBpsUpdated(bps);
}
function setHookFeeBps(uint256 bps) external onlyOwner {
if (bps > MAX_HOOK_FEE_BPS) revert InvalidBps();
hookFeeBps = bps;
emit HookFeeBpsUpdated(bps);
}
function setMaxInternalPriceImpactBps(uint256 bps) external onlyOwner {
if (bps == 0 || bps >= BASIS_POINTS) revert InvalidBps();
maxInternalPriceImpactBps = bps;
emit MaxInternalPriceImpactUpdated(bps);
}
function setProtocolFeeRecipient(address recipient) external onlyOwner {
if (recipient == address(0)) revert ZeroAddress();
protocolFeeRecipient = recipient;
emit ProtocolFeeRecipientUpdated(recipient);
}
/**
* @notice Sets the trusted operator that executes fee conversions with
* explicit minimum outputs, preventing arbitrary callers from triggering
* predictable swaps against a manipulated spot price.
*/
function setFeeSweepOperator(address operator) external onlyOwner {
if (operator == address(0)) revert ZeroAddress();
feeSweepOperator = operator;
emit FeeSweepOperatorUpdated(operator);
}
/**
* @notice Returns the policy terms new launches snapshot immutably.
*/
function currentFeePolicy() external view override returns (FeePolicySnapshot memory) {
return _currentFeePolicy();
}
function _currentFeePolicy() private view returns (FeePolicySnapshot memory) {
return FeePolicySnapshot({
protocolFeeRecipient: protocolFeeRecipient,
protocolFeeShareBps: uint16(protocolFeeShareBps),
buybackBurnBps: uint16(buybackBurnBps),
hookFeeBps: uint16(hookFeeBps),
maxInternalPriceImpactBps: uint16(maxInternalPriceImpactBps)
});
}
// ---------------------------------------------------------------------
// Factory wiring
// ---------------------------------------------------------------------
/**
* @notice Registers a pool with fee terms frozen at launch by the factory.
* @dev `buybackCreatorRecipient` is the creator recipient the curve was
* constructed with, passed so the vest this pool tops up lands in the
* same vault epoch as any tranche the curve locked pre-graduation.
*
* It is not immutable in effect. The vault only reads it to seed an empty
* slot, and the factory forwards every later creator-fee-recipient
* rotation into `updateCreatorRecipient`, so the live beneficiary follows
* the creator fee stream. That is deliberate: it is what lets a
* compromised creator key be recovered without orphaning the vest.
*/
function registerPool(
PoolKey calldata key,
address memecoin,
address creator,
address buybackCreatorRecipient,
uint16 creatorTaxBps,
bool buybackEnabled,
FeePolicySnapshot calldata policy
) external onlyFactory {
_registerPool(key, memecoin, creator, buybackCreatorRecipient, creatorTaxBps, buybackEnabled, policy);
}
function _registerPool(
PoolKey calldata key,
address memecoin,
address creator,
address buybackCreatorRecipient,
uint16 creatorTaxBps,
bool buybackEnabled,
FeePolicySnapshot memory policy
) private {
PoolId poolId = key.toId();
if (launches[poolId].registered) revert AlreadyRegistered();
if (creator == address(0) || buybackCreatorRecipient == address(0)) revert ZeroAddress();
// Terms frozen here govern the pool for life, so each is held to the
// same ceiling as the setter that produced it. protocolFeeShareBps
// against BASIS_POINTS rather than MAX_PROTOCOL_FEE_SHARE_BPS would
// let a pool be registered on terms the live policy could never
// reach, paying the creator nothing.
if (
policy.protocolFeeRecipient == address(0) || policy.protocolFeeShareBps > MAX_PROTOCOL_FEE_SHARE_BPS
|| policy.buybackBurnBps > BASIS_POINTS || policy.hookFeeBps > MAX_HOOK_FEE_BPS
|| policy.maxInternalPriceImpactBps == 0 || policy.maxInternalPriceImpactBps >= BASIS_POINTS
) {
revert InvalidBps();
}
// The curve bounds the same sum in its own constructor rather than
// inheriting it from the factory. A pool taking more than the whole
// unspecified leg would flip the swapper's output delta negative.
if (uint256(creatorTaxBps) + policy.hookFeeBps > MAX_TOTAL_TRADE_FEE_BPS) revert InvalidBps();
// The factory builds the key correctly today, but these two facts are
// what every later fee credit and swap direction is derived from. A
// memecoin that is neither currency would silently designate the wrong
// side as quote and route fees to a slot nothing ever reads.
if (address(key.hooks) != address(this)) revert InvalidPoolKey();
bool memecoinIsCurrency0 = Currency.unwrap(key.currency0) == memecoin;
if (!memecoinIsCurrency0 && Currency.unwrap(key.currency1) != memecoin) revert InvalidPoolKey();
address quoteToken = memecoinIsCurrency0 ? Currency.unwrap(key.currency1) : Currency.unwrap(key.currency0);
launches[poolId] = LaunchInfo({
registered: true,
memecoinIsCurrency0: memecoinIsCurrency0,
memecoin: memecoin,
quoteToken: quoteToken,
creator: creator,
buybackCreatorRecipient: buybackCreatorRecipient,
protocolFeeRecipient: policy.protocolFeeRecipient,
creatorTaxBps: creatorTaxBps,
protocolFeeShareBps: policy.protocolFeeShareBps,
buybackBurnBps: policy.buybackBurnBps,
hookFeeBps: policy.hookFeeBps,
maxInternalPriceImpactBps: policy.maxInternalPriceImpactBps,
buybackEnabled: buybackEnabled
});
_poolKeys[poolId] = key;
emit PoolRegistered(poolId, memecoin, quoteToken, creator);
}
/**
* @notice Updates who receives this pool's creator fee share. Restricted
* to the factory, which gates both self-service creator transfers and
* protocol-owner overrides before forwarding here, so this contract only
* needs to trust one caller.
*/
function setCreatorFeeRecipient(PoolId poolId, address newRecipient) external onlyFactory {
LaunchInfo storage info = launches[poolId];
if (!info.registered) revert UnknownPool();
if (newRecipient == address(0)) revert ZeroAddress();
emit CreatorFeeRecipientUpdated(poolId, info.creator, newRecipient);
info.creator = newRecipient;
}
/**
* @notice Updates whether a registered launch routes its configured fee
* share through buyback-and-lock.
* @dev Applies to fees charged from here on, not to fees already pending.
* Each swap earmarks its buyback slice as it is charged, so a toggle
* cannot reach back and reroute value that accrued under the opposite
* setting. Without that, a disable landing before a sweep would divert a
* buyback the creator had already earned into their own payout, and an
* enable would sweep fees earned under a plain split into the vest.
*/
function setBuybackEnabled(PoolId poolId, bool enabled) external onlyFactory {
LaunchInfo storage info = launches[poolId];
if (!info.registered) revert UnknownPool();
info.buybackEnabled = enabled;
emit BuybackEnabledUpdated(poolId, enabled);
}
// ---------------------------------------------------------------------
// IHooks: only beforeInitialize and afterSwap are enabled. BaseHook
// supplies the externally reachable callbacks, each already restricted to
// the pool manager, and reverts HookNotImplemented for every permission
// getHookPermissions() leaves off.
// ---------------------------------------------------------------------
/**
* @dev Registration is what binds a pool id to its memecoin, quote asset,
* and fee recipients, and every later fee credit is derived from that
* record. Restricting initialization to the factory keeps a pool bearing
* this hook from existing without one.
*/
function _beforeInitialize(address sender, PoolKey calldata, uint160) internal view override returns (bytes4) {
if (sender != factory) revert NotFactory();
return IHooks.beforeInitialize.selector;
}
/**
* @notice Takes `hookFeeBps` plus this pool's `creatorTaxBps` of the
* swap's unspecified currency straight out of the pool manager's
* flash-accounting ledger in a single `take`, crediting the two cuts to
* separate pending balances so the creator tax never mixes with the
* protocol/buyback-and-lock split. If either cut lands in the
* memecoin, it is left untouched here and only converted to the quote
* currency later, in a batched `sweepPoolFees` call, rather than on
* every single swap.
*/
function _afterSwap(address, PoolKey calldata key, SwapParams calldata params, BalanceDelta delta, bytes calldata)
internal
override
returns (bytes4, int128)
{
// The conversion and buyback legs swap against this same pool, but
// they are never taxed here: v4-core skips a pool's hooks when the
// hook itself is the caller (Hooks.afterSwap). Were that not so, the
// skim would come out of the buyback before it reached the vault.
PoolId poolId = key.toId();
LaunchInfo memory info = launches[poolId];
if (!info.registered) return (IHooks.afterSwap.selector, 0);
if (info.hookFeeBps == 0 && info.creatorTaxBps == 0) return (IHooks.afterSwap.selector, 0);
bool specifiedIsCurrency0 = (params.amountSpecified < 0) == params.zeroForOne;
(Currency feeCurrency, int128 unspecifiedAmount) =
specifiedIsCurrency0 ? (key.currency1, delta.amount1()) : (key.currency0, delta.amount0());
if (unspecifiedAmount < 0) unspecifiedAmount = -unspecifiedAmount;
if (unspecifiedAmount == 0) return (IHooks.afterSwap.selector, 0);
uint256 unspecified = uint256(uint128(unspecifiedAmount));
uint256 feeAmount = (unspecified * info.hookFeeBps) / BASIS_POINTS;
uint256 taxAmount = (unspecified * info.creatorTaxBps) / BASIS_POINTS;
uint256 totalAmount = feeAmount + taxAmount;
if (totalAmount == 0) return (IHooks.afterSwap.selector, 0);
address feeCurrencyAddr = Currency.unwrap(feeCurrency);
_takeExact(feeCurrency, feeCurrencyAddr, totalAmount);
if (feeAmount != 0) {
pendingFees[poolId][feeCurrencyAddr] += feeAmount;
if (info.buybackEnabled) {
// The buyback comes out of the creator's bucket alone, so it
// is measured against what remains after the protocol's share.
uint256 creatorSlice = feeAmount - (feeAmount * info.protocolFeeShareBps) / BASIS_POINTS;
pendingBuyback[poolId][feeCurrencyAddr] += (creatorSlice * info.buybackBurnBps) / BASIS_POINTS;
}
}
if (taxAmount != 0) pendingCreatorTax[poolId][feeCurrencyAddr] += taxAmount;
emit HookFeeCollected(poolId, feeCurrencyAddr, feeAmount, taxAmount);
return (IHooks.afterSwap.selector, int128(uint128(totalAmount)));
}
// ---------------------------------------------------------------------
// Fee sweep: ISP conversion, buyback-and-lock, and distribution
// ---------------------------------------------------------------------
/**
* @notice Converts any pending memecoin-denominated fee into the pool's
* quote currency against the pool's own liquidity, then splits the
* combined quote-currency total into protocol / buyback-and-lock /
* creator using the live policy, exactly mirroring the bonding curve's
* own sweep. The trusted sweep operator is required whenever the sweep
* would execute an internal conversion or buyback. The creator may still
* distribute already-quoted fees when no internal swap is needed.
*/
function sweepPoolFees(PoolId poolId, uint256 minConversionQuoteOut, uint256 minBuybackTokensOut)
external
nonReentrant
{
LaunchInfo memory info = launches[poolId];
if (!info.registered) revert UnknownPool();
bool isOperator = msg.sender == feeSweepOperator;
if (!isOperator && msg.sender != info.creator) revert NotFeeSweepOperator();
if (!isOperator && _requiresTrustedOperator(poolId, info)) revert InternalSwapRequiresOperator();
(uint256 convertedFeeQuote, uint256 convertedTaxQuote, uint256 convertedBuybackQuote, bool converted) =
_convertPendingMemecoin(poolId, info, minConversionQuoteOut);
// Only a conversion that actually executed is subject to the caller's
// minimum. Enforcing it when there was nothing to convert, or when the
// swap filled nothing and the pending amount was restored for a later
// retry, would block the quote-denominated legs of the sweep over a
// conversion that never happened.
uint256 conversionQuoteOut = convertedFeeQuote + convertedTaxQuote;
if (converted && conversionQuoteOut < minConversionQuoteOut) {
revert SlippageExceeded(conversionQuoteOut, minConversionQuoteOut);
}
uint256 totalQuote = pendingFees[poolId][info.quoteToken] + convertedFeeQuote;
uint256 taxQuote = pendingCreatorTax[poolId][info.quoteToken] + convertedTaxQuote;
uint256 buybackQuote = pendingBuyback[poolId][info.quoteToken] + convertedBuybackQuote;
if (totalQuote == 0 && taxQuote == 0) return;
pendingFees[poolId][info.quoteToken] = 0;
pendingCreatorTax[poolId][info.quoteToken] = 0;
pendingBuyback[poolId][info.quoteToken] = 0;
_distribute(poolId, info, totalQuote, taxQuote, buybackQuote, minBuybackTokensOut);
}
/**
* @notice Owner-only escape hatch for a pool's pending quote-token fees
* when they can no longer reach the protocol and creator through the
* normal exact-delivery path, for example an approved pair token that
* later turns out to be fee-on-transfer, rebasing, or otherwise unable
* to move its exact nominal amount. `sweepPoolFees` would revert
* indefinitely in that case, since `_payOut` enforces exact delivery
* into the fee escrow and there is no way to satisfy it. This bypasses
* the escrow and the buyback conversion entirely (the buyback swap
* would fail for the same underlying reason) and pays the protocol and
* creator their regular split with a direct transfer instead, so the
* quote-token balance the hook already holds is never permanently
* stuck. A native quote leg is skipped rather than rejected: ETH cannot
* fail an exact-delivery transfer, so it has nothing to rescue.
*
* The pool's memecoin-denominated fees are rescued regardless of what the
* quote currency is, because their only ordinary exit is the conversion
* swap. Gating the whole function on an ERC-20 quote would leave a native
* pool's memecoin fees with no recovery at all, and would also strand its
* ETH fees behind them, since sweepPoolFees converts before it
* distributes and reverts as a unit.
*/
function rescuePoolFees(PoolId poolId)
external
onlyOwner
nonReentrant
returns (uint256 protocolAmount, uint256 creatorAmount)
{
LaunchInfo memory info = launches[poolId];
if (!info.registered) revert UnknownPool();
address quoteToken = info.quoteToken;
if (quoteToken != address(0)) {
(protocolAmount, creatorAmount) = _rescueCurrency(poolId, info, quoteToken);
}
(uint256 memecoinProtocol, uint256 memecoinCreator) = _rescueCurrency(poolId, info, info.memecoin);
if (protocolAmount == 0 && creatorAmount == 0 && memecoinProtocol == 0 && memecoinCreator == 0) {
revert NothingToRescue();
}
}
/**
* @dev Zeroes one currency's pending buckets for a pool and pays the
* protocol and creator their regular split directly, bypassing the
* escrow. Returns zero for both legs when there is nothing pending, so
* the caller can tell whether any currency had a balance to rescue.
*/
function _rescueCurrency(PoolId poolId, LaunchInfo memory info, address currency)
private
returns (uint256 protocolAmount, uint256 creatorAmount)
{
uint256 total = pendingFees[poolId][currency];
uint256 tax = pendingCreatorTax[poolId][currency];
if (total == 0 && tax == 0) return (0, 0);
pendingFees[poolId][currency] = 0;
pendingCreatorTax[poolId][currency] = 0;
// The rescue pays the creator their whole bucket rather than running
// a buyback, since the swap would fail for the same reason the escrow
// path did. Clearing the earmark keeps it from surviving as a claim
// on fees this call has already paid out.
pendingBuyback[poolId][currency] = 0;
protocolAmount = (total * info.protocolFeeShareBps) / BASIS_POINTS;
creatorAmount = total - protocolAmount + tax;
if (protocolAmount != 0) IERC20(currency).safeTransfer(info.protocolFeeRecipient, protocolAmount);
if (creatorAmount != 0) IERC20(currency).safeTransfer(info.creator, creatorAmount);
emit PoolFeesRescued(poolId, currency, protocolAmount, creatorAmount);
}
/**
* @dev Limits price-sensitive pool interactions to the protocol's sweep
* operator. A creator can distribute direct quote balances when buyback is
* disabled, but cannot select a permissive minimum around a manipulable
* spot price for inventory shared with the protocol.
*/
function _requiresTrustedOperator(PoolId poolId, LaunchInfo memory info) private view returns (bool) {
if (pendingFees[poolId][info.memecoin] != 0 || pendingCreatorTax[poolId][info.memecoin] != 0) {
return true;
}
return pendingBuyback[poolId][info.quoteToken] != 0;
}
/**
* @dev Converts fee and creator-tax memecoin inventory in one swap so the
* sweep receives one aggregate price boundary. Partial input and output
* are allocated proportionally back to their separate accounting buckets.
* The buyback earmark rides along inside the fee bucket, converting in
* the same proportion so a partial fill leaves the unconverted remainder
* carrying the share of the earmark it still owes.
*/
function _convertPendingMemecoin(PoolId poolId, LaunchInfo memory info, uint256 minConversionQuoteOut)
private
returns (uint256 feeQuoteOut, uint256 taxQuoteOut, uint256 buybackQuoteOut, bool converted)
{
uint256 feePending = pendingFees[poolId][info.memecoin];
uint256 taxPending = pendingCreatorTax[poolId][info.memecoin];
uint256 buybackPending = pendingBuyback[poolId][info.memecoin];
uint256 totalPending = feePending + taxPending;
if (totalPending == 0) return (0, 0, 0, false);
if (minConversionQuoteOut == 0) revert MinimumOutputRequired();
pendingFees[poolId][info.memecoin] = 0;
pendingCreatorTax[poolId][info.memecoin] = 0;
pendingBuyback[poolId][info.memecoin] = 0;
(uint256 consumed, uint256 quoteOut) = _executeInternalSwap(poolId, SwapDirection.MemecoinToQuote, totalPending);
if (consumed == 0) {
pendingFees[poolId][info.memecoin] += feePending;
pendingCreatorTax[poolId][info.memecoin] += taxPending;
pendingBuyback[poolId][info.memecoin] += buybackPending;
emit PoolConversionSkipped(poolId, totalPending);
return (0, 0, 0, false);
}
converted = true;
uint256 feeConsumed = FullMath.mulDiv(consumed, feePending, totalPending);
uint256 taxConsumed = consumed - feeConsumed;
feeQuoteOut = FullMath.mulDiv(quoteOut, feeConsumed, consumed);
taxQuoteOut = quoteOut - feeQuoteOut;
// The earmark is a marker on part of the fee bucket, so it converts
// at the fee bucket's own fill ratio and then at the rate that bucket
// actually realised. feePending is non-zero whenever the earmark is,
// since the earmark was accrued as a fraction of it.
if (buybackPending != 0) {
uint256 buybackConsumed = FullMath.mulDiv(buybackPending, feeConsumed, feePending);
buybackQuoteOut = feeConsumed == 0 ? 0 : FullMath.mulDiv(feeQuoteOut, buybackConsumed, feeConsumed);
pendingBuyback[poolId][info.memecoin] += buybackPending - buybackConsumed;
}
pendingFees[poolId][info.memecoin] += feePending - feeConsumed;
pendingCreatorTax[poolId][info.memecoin] += taxPending - taxConsumed;
}
/**
* @dev Splits `totalQuote` into protocol / buyback-and-lock / creator,
* running the buyback leg as a real swap of the quote currency back
* into the memecoin before locking the result into the shared
* five-year vest instead of burning it. `taxQuote` bypasses the split
* entirely and is added straight to the creator's payout.
* @param buybackQuote The slice of `totalQuote` earmarked for buyback as
* its fees were charged. Passed in rather than derived here so a toggle
* of the pool's buyback flag cannot reroute value that already accrued.
*/
function _distribute(
PoolId poolId,
LaunchInfo memory info,
uint256 totalQuote,
uint256 taxQuote,
uint256 buybackQuote,
uint256 minBuybackTokensOut
) private {
uint256 protocolAmount = (totalQuote * info.protocolFeeShareBps) / BASIS_POINTS;
uint256 creatorBucket = totalQuote - protocolAmount;
// The earmark was summed per swap, so its rounding can land a wei or
// two above the bucket recomputed here on the aggregate. Clamping
// keeps the subtraction below sound at a full buyback share, where
// the two would otherwise be equal.
uint256 requestedBuyback = buybackQuote < creatorBucket ? buybackQuote : creatorBucket;
uint256 creatorAmount = creatorBucket - requestedBuyback + taxQuote;
uint256 buybackSpent;
uint256 tokensLocked;
if (requestedBuyback != 0) {
if (minBuybackTokensOut == 0) revert MinimumOutputRequired();
(buybackSpent, tokensLocked) = _executeInternalSwap(poolId, SwapDirection.QuoteToMemecoin, requestedBuyback);
// Return the unfilled quote amount to the creator bucket. The
// price limit bounds execution without silently retaining value
// that has already been removed from the creator's accounting.
creatorAmount += requestedBuyback - buybackSpent;
if (tokensLocked != 0) {
IERC20(info.memecoin).forceApprove(address(buybackVault), tokensLocked);
buybackVault.lock(
info.memecoin,
tokensLocked,
info.buybackCreatorRecipient,
info.protocolFeeRecipient,
info.protocolFeeShareBps
);
if (tokensLocked < minBuybackTokensOut) {
revert SlippageExceeded(tokensLocked, minBuybackTokensOut);
}
} else if (buybackSpent == 0) {
// Same rule the curve applies: only a buyback that actually
// executes is subject to the caller's minimum. Enforcing it on
// a buyback that filled nothing would block the creator and
// protocol legs too, stranding the whole distribution over a
// leg that has already been folded back above.
emit PoolBuybackSkipped(poolId, requestedBuyback);
} else {
// Input was consumed but rounded to no output at all. That
// quote is gone into the pool with nothing locked against it,
// so it can be neither folded back nor treated as a skip.
revert SlippageExceeded(0, minBuybackTokensOut);
}
}
_payOut(info.creator, info.quoteToken, creatorAmount);
_payOut(info.protocolFeeRecipient, info.quoteToken, protocolAmount);
emit PoolFeesSwept(poolId, protocolAmount, buybackSpent, creatorAmount, tokensLocked);
}
function _payOut(address recipient, address quoteToken, uint256 amount) private {
if (amount == 0) return;
if (quoteToken == address(0)) {
feeEscrow.credit{value: amount}(recipient);
} else {
uint256 balanceBefore = IERC20(quoteToken).balanceOf(address(feeEscrow));
IERC20(quoteToken).forceApprove(address(feeEscrow), amount);
feeEscrow.creditToken(recipient, quoteToken, amount);
uint256 received = IERC20(quoteToken).balanceOf(address(feeEscrow)) - balanceBefore;
if (received != amount) revert InexactQuoteTransfer(quoteToken, amount, received);
}
}
/**
* @dev Opens a standalone unlock context to run one exact-input internal
* swap, bounded by a price-impact ceiling measured against the pool's
* live price.
*
* That ceiling limits how far this swap moves the price, not where the
* price started. A front-run that depresses spot first shifts the whole
* band down with it, so the bound is slippage control rather than
* manipulation resistance. What actually caps the loss on a sandwich is
* the caller's `minConversionQuoteOut` / `minBuybackTokensOut`, which is
* why every price-sensitive sweep is gated to the sweep operator by
* `_requiresTrustedOperator`. Treat those minimums as the real defense
* and size them off an independent price.
*/
function _executeInternalSwap(PoolId poolId, SwapDirection direction, uint256 amountIn)
private
returns (uint256 amountInConsumed, uint256 amountOut)
{
bytes memory result = poolManager.unlock(abi.encode(poolId, direction, amountIn));
(amountInConsumed, amountOut) = abi.decode(result, (uint256, uint256));
}
function unlockCallback(bytes calldata data) external returns (bytes memory) {
if (msg.sender != address(poolManager)) revert NotPoolManager();
(PoolId poolId, SwapDirection direction, uint256 amountIn) = abi.decode(data, (PoolId, SwapDirection, uint256));
LaunchInfo memory info = launches[poolId];
PoolKey memory key = _poolKeys[poolId];
bool zeroForOne =
direction == SwapDirection.MemecoinToQuote ? info.memecoinIsCurrency0 : !info.memecoinIsCurrency0;
(uint160 sqrtPriceX96,,,) = StateLibrary.getSlot0(poolManager, poolId);
uint160 sqrtPriceLimitX96 = _priceLimit(sqrtPriceX96, zeroForOne, info.maxInternalPriceImpactBps);
BalanceDelta delta = poolManager.swap(
key,
SwapParams({
zeroForOne: zeroForOne,
amountSpecified: -SafeCast.toInt256(amountIn),
sqrtPriceLimitX96: sqrtPriceLimitX96
}),
""
);
_settleCurrency(key.currency0, delta.amount0());
_settleCurrency(key.currency1, delta.amount1());
int128 inputDelta = zeroForOne ? delta.amount0() : delta.amount1();
int128 outputDelta = zeroForOne ? delta.amount1() : delta.amount0();
uint256 amountInConsumed = inputDelta < 0 ? SafeCast.toUint256(-int256(inputDelta)) : 0;
uint256 amountOut = outputDelta > 0 ? SafeCast.toUint256(int256(outputDelta)) : 0;
return abi.encode(amountInConsumed, amountOut);
}
function _settleCurrency(Currency currency, int128 amount) private {
if (amount < 0) {
uint256 owed = uint256(uint128(-amount));
if (currency.isAddressZero()) {
// PoolManager.sync carries no unlock modifier, so the synced-
// currency slot is transient state anyone can leave set for
// the rest of a transaction. Settling native against a stale
// non-zero slot takes the ERC-20 branch and reverts
// NonzeroNativeValue, which v4-core calls out as a DoS vector.
poolManager.sync(currency);
poolManager.settle{value: owed}();
} else {
address token = Currency.unwrap(currency);
uint256 balanceBefore = IERC20(token).balanceOf(address(poolManager));
poolManager.sync(currency);
IERC20(token).safeTransfer(address(poolManager), owed);
uint256 received = IERC20(token).balanceOf(address(poolManager)) - balanceBefore;
if (received != owed) revert InexactQuoteTransfer(token, owed, received);
poolManager.settle();
}
} else if (amount > 0) {
_takeExact(currency, Currency.unwrap(currency), uint256(uint128(amount)));
}
}
/**
* @dev Records only assets that reached the hook in full. Uniswap V4's
* flash accounting requires exact ERC-20 transfers, so transfer-tax quote
* assets fail atomically instead of creating an underfunded fee balance.
*
* On an exact-output swap the fee is charged on the swapper's input leg,
* which the PoolManager has not been paid yet, so this take draws from
* the shared pot and reverts if the fee alone exceeds the PoolManager's
* whole balance of that currency. Reaching that needs an input on the
* order of fifty times the PoolManager's holdings, and the revert falls
* on the swap that caused it. No other pool can lose value to it either,
* since v4's unlock invariant makes the swapper settle the enlarged debt
* before the transaction ends.
*/
function _takeExact(Currency currency, address token, uint256 amount) private {
if (currency.isAddressZero()) {
poolManager.take(currency, address(this), amount);
return;
}
uint256 balanceBefore = IERC20(token).balanceOf(address(this));
poolManager.take(currency, address(this), amount);
uint256 received = IERC20(token).balanceOf(address(this)) - balanceBefore;
if (received != amount) revert InexactQuoteTransfer(token, amount, received);
}
/**
* @dev Bounds the pool's sqrt-price movement for batched internal swaps.
* For a constant-product pool this matches the bonding curve's
* `amountIn / (reserve + amountIn)` reserve-movement bound. The resulting
* spot-price percentage is larger because spot price is proportional to
* sqrtPriceX96 squared; `maxInternalPriceImpactBps` is retained as the
* public policy name for compatibility across both phases.
*/
function _priceLimit(uint160 sqrtPriceX96, bool zeroForOne, uint256 maxPriceImpactBps)
private
pure
returns (uint160)
{
uint256 factor = BASIS_POINTS - maxPriceImpactBps;
if (zeroForOne) {
uint256 limit = (uint256(sqrtPriceX96) * factor) / BASIS_POINTS;
// forge-lint: disable-next-line(unsafe-typecast)
return limit <= TickMath.MIN_SQRT_PRICE ? TickMath.MIN_SQRT_PRICE + 1 : uint160(limit);
} else {
uint256 limit = (uint256(sqrtPriceX96) * BASIS_POINTS) / factor;
// casting to uint160 is safe because this branch only runs when limit < TickMath.MAX_SQRT_PRICE, which is itself < type(uint160).max
// forge-lint: disable-next-line(unsafe-typecast)
return limit >= TickMath.MAX_SQRT_PRICE ? TickMath.MAX_SQRT_PRICE - 1 : uint160(limit);
}
}
/**
* @notice Accepts native ETH pulled out of the pool manager via `take`.
*/
receive() external payable {}
}