// SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {JBPermissioned} from "@bananapus/core-v6/src/abstract/JBPermissioned.sol"; import {IJBController} from "@bananapus/core-v6/src/interfaces/IJBController.sol"; import {IJBCashOutHook} from "@bananapus/core-v6/src/interfaces/IJBCashOutHook.sol"; import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol"; import {IJBMultiTerminal} from "@bananapus/core-v6/src/interfaces/IJBMultiTerminal.sol"; import {IJBPayHook} from "@bananapus/core-v6/src/interfaces/IJBPayHook.sol"; import {IJBPermissioned} from "@bananapus/core-v6/src/interfaces/IJBPermissioned.sol"; import {IJBPermissions} from "@bananapus/core-v6/src/interfaces/IJBPermissions.sol"; import {IJBPrices} from "@bananapus/core-v6/src/interfaces/IJBPrices.sol"; import {IJBProjects} from "@bananapus/core-v6/src/interfaces/IJBProjects.sol"; import {IJBRulesetDataHook} from "@bananapus/core-v6/src/interfaces/IJBRulesetDataHook.sol"; import {IJBTerminal} from "@bananapus/core-v6/src/interfaces/IJBTerminal.sol"; import {IJBTokens} from "@bananapus/core-v6/src/interfaces/IJBTokens.sol"; import {JBCashOuts} from "@bananapus/core-v6/src/libraries/JBCashOuts.sol"; import {JBConstants} from "@bananapus/core-v6/src/libraries/JBConstants.sol"; import {JBFees} from "@bananapus/core-v6/src/libraries/JBFees.sol"; import {JBMetadataResolver} from "@bananapus/core-v6/src/libraries/JBMetadataResolver.sol"; import {JBRulesetMetadataResolver} from "@bananapus/core-v6/src/libraries/JBRulesetMetadataResolver.sol"; import {JBAfterCashOutRecordedContext} from "@bananapus/core-v6/src/structs/JBAfterCashOutRecordedContext.sol"; import {JBAfterPayRecordedContext} from "@bananapus/core-v6/src/structs/JBAfterPayRecordedContext.sol"; import {JBBeforeCashOutRecordedContext} from "@bananapus/core-v6/src/structs/JBBeforeCashOutRecordedContext.sol"; import {JBBeforePayRecordedContext} from "@bananapus/core-v6/src/structs/JBBeforePayRecordedContext.sol"; import {JBCashOutHookSpecification} from "@bananapus/core-v6/src/structs/JBCashOutHookSpecification.sol"; import {JBPayHookSpecification} from "@bananapus/core-v6/src/structs/JBPayHookSpecification.sol"; import {JBRuleset} from "@bananapus/core-v6/src/structs/JBRuleset.sol"; import {JBPermissionIds} from "@bananapus/permission-ids-v6/src/JBPermissionIds.sol"; import {IERC20} from "@openzeppelin/contracts/interfaces/IERC20.sol"; import {ERC2771Context} from "@openzeppelin/contracts/metatx/ERC2771Context.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {Address} from "@openzeppelin/contracts/utils/Address.sol"; import {Context} from "@openzeppelin/contracts/utils/Context.sol"; import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; import {mulDiv} from "@prb/math/src/Common.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 {StateLibrary} from "@uniswap/v4-core/src/libraries/StateLibrary.sol"; import {TickMath} from "@uniswap/v4-core/src/libraries/TickMath.sol"; import {BalanceDelta} from "@uniswap/v4-core/src/types/BalanceDelta.sol"; import {Currency, CurrencyLibrary} from "@uniswap/v4-core/src/types/Currency.sol"; import {PoolId, PoolIdLibrary} from "@uniswap/v4-core/src/types/PoolId.sol"; import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; import {SwapParams} from "@uniswap/v4-core/src/types/PoolOperation.sol"; import {IJBBuybackHook} from "./interfaces/IJBBuybackHook.sol"; import {JBSwapLib} from "./libraries/JBSwapLib.sol"; import {SwapCallbackData} from "./structs/SwapCallbackData.sol"; /// @custom:benediction DEVS BENEDICAT ET PROTEGAT CONTRACTVS MEAM /// @notice Automatically buys project tokens from a Uniswap V4 pool when the pool price is better than minting, and /// sells project tokens into the pool during cash-outs when the pool offers more than the bonding curve reclaim. The /// project's reserved rate is applied regardless of which route wins, unless the payer opts the swapped tokens out of /// it with the `skipSplits` metadata word. /// @dev Acts as both a pay hook (buy-side) and cash-out hook (sell-side). Uses a TWAP oracle for manipulation /// resistance and falls back to minting/direct-reclaim when the pool is unavailable or offers worse rates. contract JBBuybackHook is JBPermissioned, ERC2771Context, IUnlockCallback, IJBBuybackHook { // A library that parses the packed ruleset metadata into a friendlier format. using JBRulesetMetadataResolver for JBRuleset; // A library that adds default safety checks to ERC20 functionality. using SafeERC20 for IERC20; using StateLibrary for IPoolManager; using PoolIdLibrary for PoolKey; using CurrencyLibrary for Currency; //*********************************************************************// // --------------------------- custom errors ------------------------- // //*********************************************************************// /// @notice Thrown when setting the chain-specific constants after they have already been configured. error JBBuybackHook_AlreadyConfigured(); /// @notice Thrown when the unlock callback is called by an address other than the Uniswap V4 PoolManager. error JBBuybackHook_CallerNotPoolManager(address caller); /// @notice Thrown when the hook is triggered by an address that is not one of the project's payment terminals. error JBBuybackHook_CallerNotTerminal(address caller); /// @notice Thrown inside the unlock callback when a swap fills below an oracle-derived floor. Always caught by /// `_swap`'s try/catch, which unwinds the swap and lets the payment fall back to minting — it never surfaces to /// the payer. error JBBuybackHook_DerivedFloorNotMet(uint256 amount, uint256 minimum); /// @notice Thrown when the amount to swap with exceeds the total amount paid in. error JBBuybackHook_InsufficientPayAmount(uint256 swapAmount, uint256 totalPaid); /// @notice Thrown when the specified TWAP window is outside the allowed minimum and maximum bounds. error JBBuybackHook_InvalidTwapWindow(uint256 value, uint256 min, uint256 max); /// @notice Thrown when attempting to set a pool for a project/terminal token pair that already has one. error JBBuybackHook_PoolAlreadySet(PoolId poolId); /// @notice Thrown when the pool is initialized at a price other than the one the caller expected. error JBBuybackHook_PoolInitializedAtWrongPrice(uint160 actualSqrtPriceX96, uint160 expectedSqrtPriceX96); /// @notice Thrown when the provided PoolKey does not contain the project token and terminal token pair. error JBBuybackHook_PoolKeyCurrenciesMismatch(); /// @notice Thrown when the pool has not been initialized in the Uniswap V4 PoolManager. error JBBuybackHook_PoolNotInitialized(PoolId poolId); /// @notice Thrown when no pool has been configured for the given project and terminal token pair. error JBBuybackHook_PoolNotSet(uint256 projectId, address terminalToken); /// @notice Thrown when the swap output is less than the specified minimum amount out. error JBBuybackHook_SpecifiedSlippageExceeded(uint256 amount, uint256 minimum); /// @notice Thrown when the terminal token is the same as the project token. error JBBuybackHook_TerminalTokenIsProjectToken(address terminalToken, address projectToken); /// @notice Thrown when the caller is not authorized to perform the action. error JBBuybackHook_Unauthorized(address caller); /// @notice Thrown when the project has not issued a token. error JBBuybackHook_ZeroProjectToken(uint256 projectId); //*********************************************************************// // ------------------------- public constants ------------------------- // //*********************************************************************// /// @notice Projects cannot specify a TWAP window longer than this constant. uint256 public constant override MAX_TWAP_WINDOW = 2 days; /// @notice Projects cannot specify a TWAP window shorter than this constant. uint256 public constant override MIN_TWAP_WINDOW = 5 minutes; /// @notice The denominator used when calculating TWAP slippage percent values. uint256 public constant override TWAP_SLIPPAGE_DENOMINATOR = 10_000; //*********************************************************************// // ----------------------- internal constants ------------------------ // //*********************************************************************// /// @notice Fixed haircut for cold-start bootstrap quotes. uint256 internal constant _COLD_START_SPOT_SLIPPAGE = 300; /// @notice The TWAP window stored when a pool is REGISTERED with a window of exactly `MAX_TWAP_WINDOW`. /// @dev Immutable deployers (e.g. REVDeployer) bake `MAX_TWAP_WINDOW` into every pool registration as a /// default rather than a tuning choice, and a max-length TWAP floor systematically lags trending pools — /// routing every no-quote pay to the mint fallback during exactly the growth phase where buybacks matter. /// The remap applies only at registration time (`initializePoolFor`/`setPoolFor`); `setTwapWindowOf` is an /// explicit operator action and always stores the requested window, including `MAX_TWAP_WINDOW` itself. /// @dev Internal (no getter) to stay under the EIP-170 size limit; the stored window is always readable via /// `twapWindowOf`. uint256 internal constant _DEFAULT_TWAP_WINDOW = 30 minutes; /// @notice Cold-start spot quotes are capped to 5% estimated impact against live in-range liquidity. uint256 internal constant _MAX_COLD_START_SPOT_IMPACT = 50e15; /// @notice The maximum estimated price impact accepted for an oracle-derived route. `JBSwapLib` scales impact by /// 1e18, so this rejects quotes whose input is at least a full-liquidity impact against the effective pool depth. uint256 internal constant _MAX_TWAP_IMPACT = 1e18; /// @notice The maximum slippage tolerance returned by `JBSwapLib`. Hitting this means the pool is too thin to /// quote safely. uint256 internal constant _MAX_TWAP_SLIPPAGE = 8800; //*********************************************************************// // ----------------- public immutable stored properties --------------- // //*********************************************************************// /// @notice The directory of terminals and controllers. IJBDirectory public immutable override DIRECTORY; /// @notice The contract that exposes price feeds. IJBPrices public immutable override PRICES; /// @notice The project registry. IJBProjects public immutable override PROJECTS; /// @notice The token registry. IJBTokens public immutable override TOKENS; //*********************************************************************// // -------------- internal immutable stored properties -------------- // //*********************************************************************// /// @notice Pre-computed metadata ID for the "cashOut" purpose (the cash-out reclaim/skip directive). /// @dev Hoisted to an immutable so the string is hashed once at deployment instead of on every /// `beforeCashOutRecordedWith` call. Keyed to `address(this)` (the value `getId("cashOut")` resolves to), which is /// deterministic under CREATE2 — so this immutable is byte-identical on every chain. bytes4 internal immutable _CASH_OUT_ID; /// @notice The deployer authorized to set the chain-specific Uniswap V4 PoolManager and oracle hook. /// @dev Held as immutable so the constructor inputs are byte-identical on every chain. The chain-specific /// Uniswap V4 PoolManager (and the matching oracle hook deployed against it) are set by this /// deployer in a one-shot call to `setChainSpecificConstants`. This mirrors the /// `JBOptimismSuckerDeployer.setChainSpecificConstants` pattern in nana-suckers-v6, and makes this /// contract's CREATE2 address identical across chains. address internal immutable _DEPLOYER; /// @notice Pre-computed metadata ID for the "pay" purpose (the pay-phase swap quote). /// @dev Hoisted to an immutable so the string is hashed once at deployment instead of on every /// `beforePayRecordedWith` call. Keyed to `address(this)` (the value `getId("pay")` resolves to), which is /// deterministic under CREATE2 — so /// this immutable is byte-identical on every chain and does not break the unified address. bytes4 internal immutable _PAY_ID; //*********************************************************************// // --------------------- public stored properties -------------------- // //*********************************************************************// /// @notice The Uniswap V4 PoolManager singleton. Set once by `_DEPLOYER` after construction via /// `setChainSpecificConstants` and never changed thereafter. IPoolManager public override poolManager; /// @notice The oracle hook used for configured Uniswap V4 pools. Set once by `_DEPLOYER` /// after construction via `setChainSpecificConstants` and never changed thereafter. IHooks public oracleHook; /// @notice The PoolKey for a given project's token and terminal token pair. /// @custom:param projectId The ID of the project whose token is traded in the pool. /// @custom:param terminalToken The address of the terminal token (normalized to address(0) for native). mapping(uint256 projectId => mapping(address terminalToken => PoolKey)) internal _poolKeyOf; /// @notice The address of each project's token. /// @custom:param projectId The ID of the project the token belongs to. mapping(uint256 projectId => address) public override projectTokenOf; /// @notice The TWAP window for a given project and terminal token pair. /// @custom:param projectId The ID of the project to get the TWAP window for. /// @custom:param terminalToken The terminal token address (normalized to address(0) for native). mapping(uint256 projectId => mapping(address terminalToken => uint256)) public override twapWindowOf; //*********************************************************************// // --------------------- private stored properties ------------------- // //*********************************************************************// /// @notice Tracks whether a pool has been set for a project/terminal token pair. /// @custom:param projectId The ID of the project the pool belongs to. /// @custom:param terminalToken The terminal token paired with the project's token in the pool. mapping(uint256 projectId => mapping(address terminalToken => bool)) private _poolIsSet; //*********************************************************************// // ---------------------------- constructor -------------------------- // //*********************************************************************// /// @param directory The directory of terminals and controllers. /// @param permissions The permissions contract. /// @param prices The contract that exposes price feeds. /// @param projects The project registry. /// @param tokens The token registry. /// @param deployer The address authorized to call `setChainSpecificConstants` exactly once. Held immutable /// so the constructor inputs are byte-identical across chains and the CREATE2 address is unified. /// @param trustedForwarder A trusted forwarder of transactions to this contract. constructor( IJBDirectory directory, IJBPermissions permissions, IJBPrices prices, IJBProjects projects, IJBTokens tokens, address deployer, address trustedForwarder ) JBPermissioned(permissions) ERC2771Context(trustedForwarder) { DIRECTORY = directory; TOKENS = tokens; PROJECTS = projects; PRICES = prices; _DEPLOYER = deployer; // Pre-compute the metadata IDs so the purpose strings are hashed once here rather than on every hook call. _CASH_OUT_ID = JBMetadataResolver.getId("cashOut"); _PAY_ID = JBMetadataResolver.getId("pay"); } //*********************************************************************// // ---------------------- external transactions ---------------------- // //*********************************************************************// /// @notice Sells project tokens into the configured Uniswap V4 pool and sends the proceeds to the beneficiary. /// Called by the terminal after `beforeCashOutRecordedWith` determined that the pool route beats direct reclaim. /// @dev Called after the data-hook path chooses pool execution over protocol cash out. /// @dev The terminal has already burned the holder's project tokens by the time this callback runs, so this hook /// remints tokens to itself before executing the sell. The count to remint/sell comes from `hookMetadata` /// (set during `beforeCashOutRecordedWith`), NOT from `context.cashOutCount`. This distinction matters when a /// data hook wrapper (e.g. REVDeployer) splits the cash-out into fee and non-fee tranches — the terminal /// always passes the original full count in `context.cashOutCount`, but only the non-fee portion should be sold. /// @param context Standard Juicebox cash-out hook context. `hookMetadata` encodes (minimumSwapAmountOut, /// cashOutCountToSell) chosen during `beforeCashOutRecordedWith`, followed by informational sell-side routing /// metadata. function afterCashOutRecordedWith(JBAfterCashOutRecordedContext calldata context) external payable override { // Make sure only payment terminals of the project can trigger the sell-side hook. if (!DIRECTORY.isTerminalOf({projectId: context.projectId, terminal: IJBTerminal(msg.sender)})) { revert JBBuybackHook_CallerNotTerminal({caller: msg.sender}); } // Normalize the native token address so it matches how pool keys are stored. address terminalToken = context.reclaimedAmount.token == JBConstants.NATIVE_TOKEN ? address(0) : context.reclaimedAmount.token; // Load the configured pool and project token for this project's cash-out token pair. PoolKey memory key = _poolKeyOf[context.projectId][terminalToken]; address projectToken = projectTokenOf[context.projectId]; // Decode the sell-side slippage floor and the token count to sell, both chosen during route selection. // The cashOutCount from metadata is the count the data hook intended for this swap — it may differ from // context.cashOutCount when a wrapper (e.g. REVDeployer) splits tokens into fee and non-fee tranches. uint256 minimumSwapAmountOut; uint256 cashOutCountToSell = context.cashOutCount; bool shouldEnforceMinimumSwapAmountOut; if (context.hookMetadata.length != 0) { if (context.hookMetadata.length >= 256) { (minimumSwapAmountOut, cashOutCountToSell,,,,,, shouldEnforceMinimumSwapAmountOut) = abi.decode( context.hookMetadata, (uint256, uint256, uint256, int24, uint128, PoolId, uint256, bool) ); } else { (minimumSwapAmountOut, cashOutCountToSell) = abi.decode(context.hookMetadata, (uint256, uint256)); shouldEnforceMinimumSwapAmountOut = minimumSwapAmountOut != 0; } } // Wrappers can pass a smaller sell count through metadata, but they must never inflate it above // what the terminal actually burned in `context.cashOutCount`. if (cashOutCountToSell > context.cashOutCount) cashOutCountToSell = context.cashOutCount; // Remint project tokens to this hook so they can be sold into the pool. // Uses the metadata-provided count, not context.cashOutCount, to avoid selling fee-portion tokens. IJBController controller = IJBController(address(DIRECTORY.controllerOf(context.projectId))); // Snapshot balance before minting to handle fee-on-transfer tokens. uint256 preMintBalance = IERC20(projectToken).balanceOf(address(this)); _mint({ controller: controller, projectId: context.projectId, tokenCount: cashOutCountToSell, beneficiary: address(this), useReservedPercent: false }); // Measure actual tokens received (handles fee-on-transfer tokens). uint256 actualReceived = IERC20(projectToken).balanceOf(address(this)) - preMintBalance; // Sell the reminted project tokens for the terminal token using the configured pool direction. (uint256 amountSpent, uint256 amountReceived, bool swapFailed) = _swapExactInput({ key: key, amountIn: actualReceived, minimumSwapAmountOut: minimumSwapAmountOut, zeroForOne: projectToken < terminalToken, derivedFloorAmountOut: 0 }); // If the pool reverted, return the reminted project tokens to the holder instead of // blocking the cash-out entirely. The holder keeps their tokens and can sell manually or retry. // BUT if the caller explicitly demanded a non-zero terminal-token minimum, returning project // tokens to the holder cannot satisfy that minimum — revert so the user is not silently // settled in the wrong token at less than they asked for. if (swapFailed) { if (shouldEnforceMinimumSwapAmountOut) _requireMinimum({amount: 0, minimum: minimumSwapAmountOut}); IERC20(projectToken).safeTransfer({to: context.holder, value: actualReceived}); emit SellSwapReverted({ projectId: context.projectId, holder: context.holder, amount: actualReceived, caller: msg.sender }); return; } // Only a caller-specified minimum hard-reverts on an underfill. A derived floor (the oracle/quote-derived // value that made this route preferable to the direct cash-out path) soft-lands a successful-but-partial fill // the same way the swap-failed branch above soft-lands by returning project tokens: the partial proceeds are // forwarded to the beneficiary and the unsold reminted residue is returned to the holder below. if (shouldEnforceMinimumSwapAmountOut) { _requireMinimum({amount: amountReceived, minimum: minimumSwapAmountOut}); } // Return only the reminted residue left unsold by THIS execution. The terminal already burned the holder's // project tokens before this hook ran, so returning the unsold remint restores the portion that the pool did // not actually buy. Pre-existing project-token balances on the hook must not be swept into this transfer. uint256 unsoldProjectTokenCount = actualReceived > amountSpent ? actualReceived - amountSpent : 0; if (unsoldProjectTokenCount != 0) { IERC20(projectToken).safeTransfer({to: context.holder, value: unsoldProjectTokenCount}); } // Forward the swap proceeds to the beneficiary in the same token they would have reclaimed natively. if (context.reclaimedAmount.token == JBConstants.NATIVE_TOKEN) { Address.sendValue({recipient: context.beneficiary, amount: amountReceived}); } else { // Measure the actual delivery to guard against fee-on-transfer tokens. uint256 balBefore = IERC20(context.reclaimedAmount.token).balanceOf(context.beneficiary); IERC20(context.reclaimedAmount.token).safeTransfer({to: context.beneficiary, value: amountReceived}); uint256 delivered = IERC20(context.reclaimedAmount.token).balanceOf(context.beneficiary) - balBefore; // As with the underfill check above, only a caller-specified minimum hard-reverts when a fee-on-transfer // token delivers less than the floor. A derived floor soft-lands the partial delivery instead. if (shouldEnforceMinimumSwapAmountOut) _requireMinimum({amount: delivered, minimum: minimumSwapAmountOut}); } // Emit the executed sell-side cash-out details for offchain indexers and analytics. emit CashOutSwap({ projectId: context.projectId, cashOutCount: cashOutCountToSell, poolId: key.toId(), amountReceived: amountReceived, caller: msg.sender }); } /// @notice Buys project tokens from the Uniswap V4 pool, then mints any leftover at the issuance rate. The swap /// fills only while the pool price beats the mint rate — leftover input is returned to the project's terminal. /// @dev The swap uses the issuance rate as its price limit: it fills while the pool offers a better rate than /// minting, and any unswapped tokens are minted at the issuance rate. An explicit caller-provided minimum is a /// settlement guarantee: if the combined output (swap + leftover mint) falls short, the transaction reverts. /// An oracle-derived routing minimum never reverts the payment: it is enforced inside the swap attempt, and a /// miss unwinds the swap so the full payment falls back to minting at the issuance rate. This keeps no-quote /// (programmatic) payments — protocol fees, split pays, project payers — alive when the TWAP floor is stale, /// while a manipulated fill can never do better than the protocol's own mint path. If the swap reverts entirely /// (due to insufficient liquidity or something else), all tokens are likewise minted as a fallback, against /// which explicit minimums still apply. Offchain observers can detect the fallback as a swap-routed payment /// that emits `Mint` without a `Swap`. /// @param context The pay context passed in by the terminal. function afterPayRecordedWith(JBAfterPayRecordedContext calldata context) external payable override { // Make sure only the project's payment terminals can access this function. if (!DIRECTORY.isTerminalOf({projectId: context.projectId, terminal: IJBTerminal(msg.sender)})) { revert JBBuybackHook_Unauthorized({caller: msg.sender}); } // Parse the metadata forwarded from the data hook. // `minimumSwapAmountOut` is the payer's minimum acceptable total token output (swap + leftover mint). // `tokenCountWithoutHook` is the number of project tokens a direct payment (no swap) would have minted // for the swap portion. It sets the swap's price limit: the pool must offer a better rate than minting, // otherwise the remaining input is minted instead. // `weightRatio` is the currency conversion factor computed in `beforePayRecordedWith`, passed through // metadata to avoid a redundant `currentRulesetOf` + price lookup in this function. // `quotedAmountToSwapWith` anchors same-terminal split normalization. `skipSplits` is the payer's opt-out of // the reserved split on swapped tokens, and `reservedPercent` is the ruleset's split so a skipping payer's // minimum can be settled against the beneficiary's receipt. The remaining metadata fields are preview-only // diagnostics. ( bool projectTokenIs0, uint256 amountToMintWith, uint256 minimumSwapAmountOut, bool hasExplicitMinimumSwapAmountOut, IJBController controller, uint256 tokenCountWithoutHook, uint256 weightRatio, uint256 quotedAmountToSwapWith,,,,,,,, bool skipSplits, uint256 reservedPercent ) = abi.decode( context.hookMetadata, ( bool, uint256, uint256, bool, IJBController, uint256, uint256, uint256, int24, uint128, PoolId, uint256, uint256, uint256, bool, bool, uint256 ) ); // Same-terminal split pays can quote the destination payment gross, then forward the hook only the post-fee // net amount. Derived TWAP floors and issuance-rate price limits must scale down with that forwarded amount; // explicit user minima remain hard guarantees and are not scaled. if (quotedAmountToSwapWith != 0 && context.forwardedAmount.value < quotedAmountToSwapWith) { // The issuance-rate price limit is tied to the swap input. If core forwards less than was quoted, shrink // the limit to the actual input so the AMM route still fills only while it beats direct minting. tokenCountWithoutHook = mulDiv({ x: tokenCountWithoutHook, y: context.forwardedAmount.value, denominator: quotedAmountToSwapWith }); // Caller-specified minimums are settlement guarantees. Only oracle-derived floors are routing hints and // can safely shrink with a same-terminal split fee. if (!hasExplicitMinimumSwapAmountOut) { minimumSwapAmountOut = mulDiv({ x: minimumSwapAmountOut, y: context.forwardedAmount.value, denominator: quotedAmountToSwapWith }); } } // Cache the native token check to avoid repeated comparison (~50-100 gas). bool isNativeToken = context.forwardedAmount.token == JBConstants.NATIVE_TOKEN; // Record the terminal token balance BEFORE pulling payment tokens so we can compute leftover as a delta. // For native ETH, `msg.value` is already included in `address(this).balance` at this point, // so we subtract it. For ERC-20, we capture BEFORE safeTransferFrom. // This prevents both pre-existing balances AND the payment itself from inflating leftovers. uint256 balanceBefore = _terminalTokenBalance(context.forwardedAmount.token); if (isNativeToken) { balanceBefore -= msg.value; } // If the token paid in isn't the native token, pull the amount to swap from the terminal. if (!isNativeToken) { IERC20(context.forwardedAmount.token) .safeTransferFrom({from: msg.sender, to: address(this), value: context.forwardedAmount.value}); } // Get a reference to the number of project tokens that was swapped for. // The price limit is set to the issuance rate (tokenCountWithoutHook / amountIn), so the swap // fills only while the pool offers a better rate than minting. Any unconsumed input tokens // remain in this contract and are minted at the issuance rate below. // Oracle-derived floors are enforced inside the swap attempt: a miss unwinds the swap and the full // payment falls back to minting. Only explicit caller minima are enforced on the combined output below. (uint256 exactSwapAmountOut,) = _swap({ context: context, projectTokenIs0: projectTokenIs0, minimumSwapAmountOut: tokenCountWithoutHook, derivedFloorAmountOut: hasExplicitMinimumSwapAmountOut ? 0 : minimumSwapAmountOut }); // Compute leftover terminal tokens as a delta (balanceAfter - balanceBefore). uint256 leftoverAmountInThisContract = _terminalTokenBalance(context.forwardedAmount.token) - balanceBefore; // Mint a corresponding number of project tokens using any terminal tokens left over. uint256 partialMintTokenCount; if (leftoverAmountInThisContract != 0) { uint256 terminalBalanceBeforeAdd; // If the token paid in wasn't the native token, grant the terminal permission to pull them back. if (!isNativeToken) { terminalBalanceBeforeAdd = IERC20(context.forwardedAmount.token).balanceOf(msg.sender); IERC20(context.forwardedAmount.token) .forceApprove({spender: msg.sender, value: leftoverAmountInThisContract}); } uint256 payValue = isNativeToken ? leftoverAmountInThisContract : 0; // Snapshot balance before `addToBalanceOf` so we can measure the actual amount transferred. uint256 balanceBeforeAdd = _terminalTokenBalance(context.forwardedAmount.token); // Add the paid amount back to the project's balance in the terminal. // Note: `leftoverAmountInThisContract` is already a measured balance delta, so it // reflects the real tokens held. The terminal's `_acceptFundsFor` independently measures // its own balance delta, so fee-on-transfer tokens are correctly accounted for on both sides. IJBMultiTerminal(msg.sender).addToBalanceOf{value: payValue}({ projectId: context.projectId, token: context.forwardedAmount.token, amount: leftoverAmountInThisContract, shouldReturnHeldFees: false, memo: "", metadata: bytes("") }); // Reset the approval to 0 to avoid leaving a residual allowance for the terminal. if (!isNativeToken) { IERC20(context.forwardedAmount.token).forceApprove({spender: msg.sender, value: 0}); } // Measure how much value the terminal actually reacquired. For fee-on-transfer tokens, the hook-side // debit can exceed the terminal's credited amount on the return hop. Native ETH cannot tax the transfer, // so the hook-side debit and terminal credit are identical there. uint256 amountActuallySent = isNativeToken ? balanceBeforeAdd - _terminalTokenBalance(context.forwardedAmount.token) : IERC20(context.forwardedAmount.token).balanceOf(msg.sender) - terminalBalanceBeforeAdd; partialMintTokenCount = mulDiv({x: amountActuallySent, y: context.weight, denominator: weightRatio}); emit Mint({ projectId: context.projectId, leftoverAmount: amountActuallySent, tokenCount: partialMintTokenCount, caller: msg.sender }); } // Only explicit caller minima are settlement guarantees over the combined output (swap + leftover mint). // Oracle-derived minima are routing hints enforced inside the swap attempt, where a miss unwinds the swap // to the mint fallback instead of reverting the payment. if (hasExplicitMinimumSwapAmountOut) { // The minimum is quoted in the payer's own terms: the full swap-route issuance when the swap output is // reminted through the reserved split, or the beneficiary's actual receipt when the payer skips it. The // leftover mint always goes through the split, so its beneficiary share is what a skipping payer sees. uint256 settledAmount = exactSwapAmountOut + (skipSplits ? _beneficiaryShareOf({tokenCount: partialMintTokenCount, reservedPercent: reservedPercent}) : partialMintTokenCount); _requireMinimum({amount: settledAmount, minimum: minimumSwapAmountOut}); } // Add the amount to mint to the leftover mint amount. partialMintTokenCount += mulDiv({x: amountToMintWith, y: context.weight, denominator: weightRatio}); // Settle the swap output. By default it is burned and reminted through the controller below so the reserved // split applies to the market route exactly as it does to issuance. A payer who opted out with `skipSplits` // takes the swapped tokens as-is: they already exist, so nothing is issued and nothing is reserved. uint256 totalTokensToMint = partialMintTokenCount; if (exactSwapAmountOut != 0) { if (skipSplits) { IERC20(projectTokenOf[context.projectId]) .safeTransfer({to: context.beneficiary, value: exactSwapAmountOut}); } else { controller.burnTokensOf({ holder: address(this), projectId: context.projectId, tokenCount: exactSwapAmountOut, memo: "" }); totalTokensToMint += exactSwapAmountOut; } } // Mint the calculated amount of tokens for the beneficiary, including any leftover amount. // Skip if there are no tokens to mint (e.g. weight=0 and swap failed). if (totalTokensToMint != 0) { _mint({ controller: controller, projectId: context.projectId, tokenCount: totalTokensToMint, beneficiary: context.beneficiary, useReservedPercent: true }); } } /// @notice Initialize a Uniswap V4 pool in the PoolManager and configure it as the buyback pool for a project. /// @dev Atomically initializes the pool (if not already initialized) and calls `_setPoolFor`. /// @dev Operator-only. Configuring the pool turns on the floor + ceiling arbitrage routing above /// for this `(projectId, terminalToken)` pair. Without a configured pool, the hook is a passthrough. /// @param projectId The ID of the project to set the pool for. /// @param fee The Uniswap V4 pool fee tier. /// @param tickSpacing The Uniswap V4 pool tick spacing. /// @param twapWindow The period of time over which the TWAP is computed. Exactly `MAX_TWAP_WINDOW` is stored /// as the 30-minute default (an immutable deployer's baked-in value, not a tuning choice); use /// `setTwapWindowOf` for a deliberate max-length window. /// @param terminalToken The address of the terminal token that payments to the project are made in. /// @param sqrtPriceX96 The initial sqrtPriceX96 for the pool (if not already initialized). function initializePoolFor( uint256 projectId, uint24 fee, int24 tickSpacing, uint256 twapWindow, address terminalToken, uint160 sqrtPriceX96 ) external override { // Enforce permissions. // The buyback hook registry (or any other contract) can call these functions on behalf of project owners // by having SET_BUYBACK_POOL permission granted through JBPermissions — no special-casing needed here. _requirePermissionFrom({ account: PROJECTS.ownerOf(projectId), projectId: projectId, permissionId: JBPermissionIds.SET_BUYBACK_POOL }); (PoolKey memory poolKey, address normalizedTerminalToken, address projectToken) = _buildPoolKey({projectId: projectId, fee: fee, tickSpacing: tickSpacing, terminalToken: terminalToken}); // Initialize pool in PoolManager if not already initialized. try poolManager.initialize({key: poolKey, sqrtPriceX96: sqrtPriceX96}) {} catch {} // The project token address can be predicted before deployment (CREATE2 with project-supplied salt), so an // attacker can front-run the buyback configuration by initializing the V4 pool at an arbitrary // `sqrtPriceX96`. Without this check, `_setPoolFor` would lock the poisoned price in for all future // buyback routing. Read the on-chain price after the initialize attempt and reject any mismatch — callers // pass the price they expect, so honest reinitialization is unaffected. (uint160 actualSqrtPriceX96,,,) = poolManager.getSlot0(poolKey.toId()); if (actualSqrtPriceX96 != sqrtPriceX96) { revert JBBuybackHook_PoolInitializedAtWrongPrice({ actualSqrtPriceX96: actualSqrtPriceX96, expectedSqrtPriceX96: sqrtPriceX96 }); } _setPoolFor({ projectId: projectId, poolKey: poolKey, twapWindow: twapWindow, normalizedTerminalToken: normalizedTerminalToken, projectToken: projectToken }); } /// @notice One-shot setter for the chain-specific Uniswap V4 PoolManager and oracle hook. /// @dev Callable only by `_DEPLOYER` and only once (when `poolManager` is still `address(0)`). After this /// call both values are effectively immutable for the contract's lifetime. Mirrors the /// `JBOptimismSuckerDeployer.setChainSpecificConstants` pattern so the contract's CREATE2 inputs stay /// byte-identical across chains and its deployed address is unified. /// @param newPoolManager The Uniswap V4 PoolManager singleton on the current chain. /// @param newOracleHook The Uniswap V4 oracle hook deployed against `newPoolManager` on the current chain. function setChainSpecificConstants(IPoolManager newPoolManager, IHooks newOracleHook) external override { if (msg.sender != _DEPLOYER) revert JBBuybackHook_Unauthorized({caller: msg.sender}); if (address(poolManager) != address(0)) revert JBBuybackHook_AlreadyConfigured(); poolManager = newPoolManager; oracleHook = newOracleHook; } /// @notice Set the V4 pool to use for a given project and terminal token pair. /// @dev Pool keys are intentionally immutable once set. This prevents manipulation of swap routing /// after a project's buyback hook is configured. /// @dev WARNING: The `poolKey.hooks` field is NOT validated. A malicious or buggy V4 hook address in the pool key /// could interfere with swaps (e.g. manipulate pricing, revert, or extract value). Because this function is /// permissioned to the project owner (or a delegate with `SET_BUYBACK_POOL`), this is a self-harming-only risk. /// Callers MUST ensure the pool key — including its hooks field — references a trusted pool. /// @dev Operator-only. Configuring the pool turns on the floor + ceiling arbitrage routing above /// for this `(projectId, terminalToken)` pair. Without a configured pool, the hook is a passthrough. /// @param projectId The ID of the project to set the pool for. /// @param poolKey The V4 PoolKey identifying the pool. /// @param twapWindow The period of time over which the TWAP is computed. Exactly `MAX_TWAP_WINDOW` is stored /// as the 30-minute default (an immutable deployer's baked-in value, not a tuning choice); use /// `setTwapWindowOf` for a deliberate max-length window. /// @param terminalToken The address of the terminal token that payments to the project are made in. function setPoolFor( uint256 projectId, PoolKey calldata poolKey, uint256 twapWindow, address terminalToken ) external override { // Enforce permissions. // The buyback hook registry (or any other contract) can call these functions on behalf of project owners // by having SET_BUYBACK_POOL permission granted through JBPermissions — no special-casing needed here. _requirePermissionFrom({ account: PROJECTS.ownerOf(projectId), projectId: projectId, permissionId: JBPermissionIds.SET_BUYBACK_POOL }); // Normalize the terminal token — use address(0) for native. address normalizedTerminalToken = terminalToken == JBConstants.NATIVE_TOKEN ? address(0) : terminalToken; // Get the project's token. address projectToken = address(TOKENS.tokenOf(projectId)); _setPoolFor({ projectId: projectId, poolKey: poolKey, twapWindow: twapWindow, normalizedTerminalToken: normalizedTerminalToken, projectToken: projectToken }); } /// @notice Set the V4 pool to use for a given project and terminal token pair, constructing the PoolKey internally. /// @dev Uses address(0) for the hooks field. The hook sorts the project token and terminal token into the correct /// currency order. Pool keys are intentionally immutable once set. /// @dev Operator-only. Configuring the pool turns on the floor + ceiling arbitrage routing above /// for this `(projectId, terminalToken)` pair. Without a configured pool, the hook is a passthrough. /// @param projectId The ID of the project to set the pool for. /// @param fee The Uniswap V4 pool fee tier. /// @param tickSpacing The Uniswap V4 pool tick spacing. /// @param twapWindow The period of time over which the TWAP is computed. Exactly `MAX_TWAP_WINDOW` is stored /// as the 30-minute default (an immutable deployer's baked-in value, not a tuning choice); use /// `setTwapWindowOf` for a deliberate max-length window. /// @param terminalToken The address of the terminal token that payments to the project are made in. function setPoolFor( uint256 projectId, uint24 fee, int24 tickSpacing, uint256 twapWindow, address terminalToken ) external override { // Enforce permissions. // The buyback hook registry (or any other contract) can call these functions on behalf of project owners // by having SET_BUYBACK_POOL permission granted through JBPermissions — no special-casing needed here. _requirePermissionFrom({ account: PROJECTS.ownerOf(projectId), projectId: projectId, permissionId: JBPermissionIds.SET_BUYBACK_POOL }); (PoolKey memory poolKey, address normalizedTerminalToken, address projectToken) = _buildPoolKey({projectId: projectId, fee: fee, tickSpacing: tickSpacing, terminalToken: terminalToken}); _setPoolFor({ projectId: projectId, poolKey: poolKey, twapWindow: twapWindow, normalizedTerminalToken: normalizedTerminalToken, projectToken: projectToken }); } /// @notice Change the TWAP window for a project's terminal token. /// @param projectId The ID of the project to set the TWAP window of. /// @param terminalToken The terminal token address (use JBConstants.NATIVE_TOKEN for native ETH). /// @param newWindow The new TWAP window. function setTwapWindowOf(uint256 projectId, address terminalToken, uint256 newWindow) external override { // Enforce permissions. _requirePermissionFrom({ account: PROJECTS.ownerOf(projectId), projectId: projectId, permissionId: JBPermissionIds.SET_BUYBACK_TWAP }); _requireValidTwapWindow(newWindow); // Normalize the terminal token — use address(0) for native. address normalizedTerminalToken = terminalToken == JBConstants.NATIVE_TOKEN ? address(0) : terminalToken; // Make sure a pool has been configured for this project/terminal token pair. if (!_poolIsSet[projectId][normalizedTerminalToken]) { revert JBBuybackHook_PoolNotSet({projectId: projectId, terminalToken: normalizedTerminalToken}); } uint256 oldWindow = twapWindowOf[projectId][normalizedTerminalToken]; twapWindowOf[projectId][normalizedTerminalToken] = newWindow; emit TwapWindowChanged({ projectId: projectId, terminalToken: normalizedTerminalToken, oldWindow: oldWindow, newWindow: newWindow, caller: _msgSender() }); } /// @notice The V4 PoolManager unlock callback. Executes the swap and settles/takes tokens. /// @dev ONLY callable by the PoolManager singleton. /// @param data ABI-encoded SwapCallbackData. /// @return result ABI-encoded swap input consumed and output received. function unlockCallback(bytes calldata data) external override returns (bytes memory) { // Only the PoolManager can call this. if (msg.sender != address(poolManager)) revert JBBuybackHook_CallerNotPoolManager(msg.sender); SwapCallbackData memory params = abi.decode(data, (SwapCallbackData)); // Compute a price limit that stops the swap if the rate is worse than the minimum acceptable output. uint160 sqrtPriceLimit = JBSwapLib.sqrtPriceLimitFromAmounts({ amountIn: params.amountIn, minimumAmountOut: params.minimumSwapAmountOut, zeroForOne: params.zeroForOne }); BalanceDelta delta = poolManager.swap({ key: params.key, params: SwapParams({ zeroForOne: params.zeroForOne, amountSpecified: -int256(params.amountIn), // Negative = exact input sqrtPriceLimitX96: sqrtPriceLimit }), hookData: abi.encode(uint256(0)) }); // Determine the input and output amounts from the delta. // V4 convention: negative delta = caller spent (input), positive delta = caller received (output). int128 delta0 = delta.amount0(); int128 delta1 = delta.amount1(); // Identify input/output currencies. Currency inputCurrency; Currency outputCurrency; uint256 inputAmount; uint256 outputAmount; if (params.zeroForOne) { inputCurrency = params.key.currency0; outputCurrency = params.key.currency1; // forge-lint: disable-next-line(unsafe-typecast) inputAmount = uint256(uint128(-delta0)); // forge-lint: disable-next-line(unsafe-typecast) outputAmount = uint256(uint128(delta1)); } else { inputCurrency = params.key.currency1; outputCurrency = params.key.currency0; // forge-lint: disable-next-line(unsafe-typecast) inputAmount = uint256(uint128(-delta1)); // forge-lint: disable-next-line(unsafe-typecast) outputAmount = uint256(uint128(delta0)); } // Settle the input (we owe the PoolManager). if (inputCurrency.isAddressZero()) { // Native ETH: settle with value. poolManager.settle{value: inputAmount}(); } else { // ERC-20: sync → transfer → settle. poolManager.sync(inputCurrency); IERC20(Currency.unwrap(inputCurrency)).safeTransfer({to: address(poolManager), value: inputAmount}); poolManager.settle(); } // Take the output (PoolManager owes us). // Use balance-delta accounting to handle fee-on-transfer tokens that deliver less than `outputAmount`. uint256 balanceBeforeTake = _currencyBalanceOf(outputCurrency); poolManager.take({currency: outputCurrency, to: address(this), amount: outputAmount}); uint256 balanceAfterTake = _currencyBalanceOf(outputCurrency); // The actual received amount accounts for any fee-on-transfer deduction. uint256 actualOutputAmount = balanceAfterTake - balanceBeforeTake; // Enforce any oracle-derived floor inside the unlock so a miss unwinds the swap itself: the revert is caught // by `_swap`'s try/catch and the payment falls back to minting at the issuance rate. The floor is pro-rated // by consumed input because the issuance-rate price limit can partial-fill — the unconsumed remainder mints // at the issuance rate, which needs no oracle protection. if (params.derivedFloorAmountOut != 0) { uint256 proRatedFloor = mulDiv({x: params.derivedFloorAmountOut, y: inputAmount, denominator: params.amountIn}); if (actualOutputAmount < proRatedFloor) { revert JBBuybackHook_DerivedFloorNotMet({amount: actualOutputAmount, minimum: proRatedFloor}); } } return abi.encode(inputAmount, actualOutputAmount); } /// @notice Receive native tokens. Required for V4 native take() and wrapped token unwrap. receive() external payable {} //*********************************************************************// // ------------------------- external views -------------------------- // //*********************************************************************// /// @notice Data-hook callback consulted by the terminal during `cashOutTokensOf()`. Compares the expected output /// from selling project tokens in the pool against the bonding-curve reclaim amount, and if the pool offers more /// (net of fees) routes the cash-out through the pool via `afterCashOutRecordedWith`. /// @dev Returns the protocol cash-out values unchanged unless selling the burned project tokens into the /// configured pool is expected to return more of the terminal token than the direct reclaim path. /// @dev If the sell path wins, the hook returns an active cash-out hook specification (`noop = false`) and maxes /// the tax rate so the terminal does not reclaim surplus itself. The sell executes in `afterCashOutRecordedWith`. /// @dev This function auto-applies the "floor arbitrage" path described in ARBITRAGE.md (Path 2, /// in revnet-core-v6). When the AMM bid is HIGHER than the bonding-curve reclaim, the hook routes /// the holder's burn through the pool — they get more ETH. When the AMM bid is LOWER, the hook /// passes through to the terminal — the bonding-curve floor is what they receive. Either way, the /// holder receives at least the bonding-curve reclaim. The terminal path only wins if the selected terminal can /// locally settle the gross reclaim; aggregate surplus may price a cash-out, but it cannot fund settlement from a /// terminal whose own local surplus is insufficient. /// /// **Why this matters for protocol health:** when external actors arbitrage between the AMM and the /// terminal cash-out path directly (buying tokens cheap on the AMM, then cashing out via terminal /// at the higher floor), the cash-out tax (configured per ruleset, e.g. 10%) is retained in the /// project surplus — boosting per-token backing for remaining holders. The AMM price corrects /// upward toward fair value. This hook auto-captures the same incentive for ordinary users. /// /// **Diagnostic metadata in returned hook specs:** when the hook chooses to noop (passthrough), /// the spec carries pool state (twapTick, liquidity, poolId, etc.) so off-chain clients can preview /// what the routing decision would be. Do not optimize this away — it's the protocol's public /// preview API for the buyback cash-out decision surface. /// /// **Caller-provided sell-side controls (via the `cashOut` metadata entry, /// encoded as `(uint256 minimumSwapAmountOut, bool skip)`):** /// - `minimumSwapAmountOut`: a hard slippage floor on the net terminal-token output. It is a protection /// value, NOT a venue selector. /// - `skip` (default false): when true, forces the cash-out through the protocol bonding-curve/terminal /// path and skips the pool entirely — even when the pool would pay more. The `minimumSwapAmountOut` floor /// is still enforced against the direct reclaim, so an unmeetable floor reverts rather than silently /// routing to the AMM. /// @param context Standard Juicebox cash-out data-hook context. /// @return cashOutTaxRate The tax rate the terminal should use for the cash out. /// @return cashOutCount The number of project tokens to cash out. /// @return totalSupply The total project token supply to use in reclaim math. /// @return effectiveSurplusValue The surplus to use for reclaim calculation. /// @return hookSpecifications Any cash-out hook specifications to fulfill after the terminal records the cash out. function beforeCashOutRecordedWith(JBBeforeCashOutRecordedContext calldata context) external view override returns ( uint256 cashOutTaxRate, uint256 cashOutCount, uint256 totalSupply, uint256 effectiveSurplusValue, JBCashOutHookSpecification[] memory hookSpecifications ) { // Normalize the native token address so it matches the pool-key storage convention. address terminalToken = context.surplus.token == JBConstants.NATIVE_TOKEN ? address(0) : context.surplus.token; // Load the project token that would be sold if the pool route is chosen. address projectToken = projectTokenOf[context.projectId]; // Read the caller-provided sell-side controls from the `cashOut` metadata entry. The entry // packs two values: `(uint256 minimumSwapAmountOut, bool skip)`. // - `minimumSwapAmountOut` is a hard slippage floor on the net terminal-token output — a protection // value, NOT a venue selector. A non-zero value is honored even on the no-pool fallback below. // - `skip` (default false) forces the cash-out through the protocol bonding-curve/terminal path and // skips the pool entirely, even if selling into the pool would return more of the terminal token. uint256 minimumSwapAmountOut; bool hasUserSpecifiedMinimumSwapAmountOut; bool skip; (bool exists, bytes memory metadata) = JBMetadataResolver.getDataFor({id: _CASH_OUT_ID, metadata: context.metadata}); if (exists) { (minimumSwapAmountOut, skip) = abi.decode(metadata, (uint256, bool)); // Only honor user minimum when they specify an explicit value. // minimumSwapAmountOut=0 (programmatic orders) falls through to TWAP oracle. hasUserSpecifiedMinimumSwapAmountOut = minimumSwapAmountOut != 0; } // Fall back to the protocol cash-out path if the caller forced it via `skip`, if no pool is // configured, if no project token is known, or if no project tokens are being cashed out. When the // user supplied an explicit minimum, the fallback must still satisfy it — otherwise revert so the // user does not silently receive less than they asked for. This holds for the `skip` case too: // forcing the terminal path never waives the slippage floor. if ( skip || !_poolIsSet[context.projectId][terminalToken] || projectToken == address(0) || context.cashOutCount == 0 ) { if (hasUserSpecifiedMinimumSwapAmountOut) { uint256 fallbackReclaim = JBCashOuts.cashOutFrom({ surplus: context.surplus.value, cashOutCount: context.cashOutCount, totalSupply: context.totalSupply, cashOutTaxRate: context.cashOutTaxRate }); // Enforce the user's explicit minimum against the exact net the terminal would pay out. uint256 netFallbackReclaim = _netAfterTerminalFee({amount: fallbackReclaim, context: context}); _requireMinimum({amount: netFallbackReclaim, minimum: minimumSwapAmountOut}); } return ( context.cashOutTaxRate, context.cashOutCount, context.totalSupply, context.surplus.value, hookSpecifications ); } // Compute the direct protocol reclaim amount for this cash-out request. uint256 directCashOutAmount = JBCashOuts.cashOutFrom({ surplus: context.surplus.value, cashOutCount: context.cashOutCount, totalSupply: context.totalSupply, cashOutTaxRate: context.cashOutTaxRate }); // Keep references to pool diagnostics. Explicit minimums skip the TWAP lookup, so diagnostics remain zeroed. uint256 rawSwapQuote; int24 twapTick; uint128 twapLiquidity; PoolId poolId; bool poolHasLiquidity = true; if (hasUserSpecifiedMinimumSwapAmountOut) { // Only compute poolId from storage when skipping _getQuote (which would return it). poolId = _poolKeyOf[context.projectId][terminalToken].toId(); poolHasLiquidity = poolManager.getLiquidity(poolId) != 0; } else { (rawSwapQuote, minimumSwapAmountOut, twapTick, twapLiquidity, poolId,) = _getQuote({ projectId: context.projectId, amountIn: context.cashOutCount, baseToken: projectToken, quoteToken: terminalToken, terminalToken: terminalToken, allowColdStartSpotFallback: false }); } // Compute the executable net direct reclaim under the active fee semantics. The terminal's // effective payout depends on the cash-out tax rate, whether the beneficiary is feeless, and — // in the zero-tax branch — the terminal's `feeFreeSurplusOf` counter (a fee applies only up // to that surplus). Reading it from the selected terminal gives an exact net, so the // noop-vs-AMM comparison and any user-specified floor can both rely on the same number. uint256 netDirectCashOutAmount = _netAfterTerminalFee({amount: directCashOutAmount, context: context}); // With no live pool liquidity, the direct terminal path is the only executable settlement route. If the user // explicitly required more than that path can net out, revert instead of silently ignoring their floor. if (!poolHasLiquidity && hasUserSpecifiedMinimumSwapAmountOut) { _requireMinimum({amount: netDirectCashOutAmount, minimum: minimumSwapAmountOut}); } // The market route can only be selected when the pool has live liquidity and a non-zero quote/minimum. A zero // market minimum means there is no executable AMM floor, so routing must stay on the terminal path. bool marketCanSettle = poolHasLiquidity && minimumSwapAmountOut != 0; // Treat the direct path as settleable by default. The local-surplus read is only needed when direct would // otherwise beat a live AMM route. bool directPathCanSettle = true; // When the AMM quote is less than or equal to direct reclaim, direct only wins if this selected terminal can // locally pay the gross reclaim. Aggregate surplus can price the reclaim, but cannot fund this terminal. if (marketCanSettle && minimumSwapAmountOut <= netDirectCashOutAmount) { directPathCanSettle = _directCashOutCanSettle({grossDirectCashOutAmount: directCashOutAmount, context: context}); } // A noop leaves execution with the terminal. The hook only noops when no market route is executable, or when // direct reclaim is both at least as good as the AMM route and locally settleable by the selected terminal. bool noop = !marketCanSettle || (directPathCanSettle && minimumSwapAmountOut <= netDirectCashOutAmount); // Return sell-side routing metadata in both cases so preview clients can inspect the route comparison without // forcing the terminal to execute `afterCashOutRecordedWith`. hookSpecifications = new JBCashOutHookSpecification[](1); hookSpecifications[0] = JBCashOutHookSpecification({ hook: IJBCashOutHook(address(this)), noop: noop, amount: 0, metadata: abi.encode( minimumSwapAmountOut, context.cashOutCount, netDirectCashOutAmount, twapTick, twapLiquidity, poolId, rawSwapQuote, hasUserSpecifiedMinimumSwapAmountOut ) }); if (noop) { return ( context.cashOutTaxRate, context.cashOutCount, context.totalSupply, context.surplus.value, hookSpecifications ); } // Max the tax rate so the terminal does not reclaim surplus directly before the hook executes the sell. return (JBConstants.MAX_CASH_OUT_TAX_RATE, context.cashOutCount, context.totalSupply, 0, hookSpecifications); } /// @notice Data-hook callback consulted by the terminal during `pay()`. Compares the pool swap rate against the /// project's mint rate and chooses the better one. If the pool wins, returns weight=0 so all tokens come from the /// swap executed in `afterPayRecordedWith`. /// /// @dev This function auto-applies the "ceiling arbitrage" path described in ARBITRAGE.md (Path 3, /// in revnet-core-v6). When the AMM ask is HIGHER than the terminal's pay rate (`weight × ETH`), /// the hook returns `noop=true` and the terminal mints at the bonding-curve rate — saving the user /// money. When the AMM ask is LOWER, the hook routes through the pool — the user gets more tokens /// per ETH. Either way, the user receives at least the bonding-curve mint amount. /// /// The `noop` spec is intentionally returned with diagnostic metadata (twapTick, liquidity, poolId, /// weight ratio, raw swap quote) so off-chain clients (frontends, arbitrage bots, indexers) can /// preview what the routing decision would be without executing a pay. Do not optimize this away — /// it's the protocol's public preview API for the buyback decision surface. /// /// **Why this matters for protocol health:** when external actors arbitrage between the terminal /// pay path and the AMM directly (paying terminal at low weight, then selling on AMM at higher /// price), the AMM price converges downward toward the issuance rate. Their payment lands in /// project surplus, growing the protocol. This hook auto-captures the same incentive for ordinary /// users transacting through the terminal — they never need to know whether to mint or swap. /// @param context Payment context passed to the data hook by `terminalStore.recordPaymentFrom(...)`. /// `context.metadata` can specify a Uniswap quote and specify how much of the payment should be used to swap. /// If `context.metadata` does not specify a quote, one will be calculated based on the TWAP. /// If `context.metadata` does not specify how much of the payment should be used, the hook uses the full amount /// paid in. /// @return weight The weight to use for minting. 0 if all tokens come from the swap. /// @return hookSpecifications Specifications containing pay hooks, as well as the amount and metadata to send to /// them. Empty if only minting. function beforePayRecordedWith(JBBeforePayRecordedContext calldata context) external view override returns (uint256 weight, JBPayHookSpecification[] memory hookSpecifications) { // Keep a reference to the amount paid in. uint256 totalPaid = context.amount.value; // Keep a reference to the weight. weight = context.weight; // Keep a reference to the minimum number of tokens expected from the swap. uint256 minimumSwapAmountOut; bool hasUserSpecifiedQuote; // Keep a reference to the amount to be used to swap (out of `totalPaid`). uint256 amountToSwapWith; // A payer can opt the swap output out of the reserved split and take the swapped tokens directly. // Programmatic payments (split pays, project payers, fee routing) never set this, so they keep honoring the // project's splits by default. bool skipSplits; // Unpack the quote specified by the payer/client (typically from the pool). (bool quoteExists, bytes memory metadata) = JBMetadataResolver.getDataFor({id: _PAY_ID, metadata: context.metadata}); if (quoteExists) { (amountToSwapWith, minimumSwapAmountOut, skipSplits) = abi.decode(metadata, (uint256, uint256, bool)); // Only honor user quote when they specify an explicit minimum. // minimumSwapAmountOut=0 (programmatic orders) falls through to TWAP oracle. hasUserSpecifiedQuote = minimumSwapAmountOut != 0; } // If the amount to swap with is greater than the actual amount paid in, revert. if (amountToSwapWith > totalPaid) revert JBBuybackHook_InsufficientPayAmount(amountToSwapWith, totalPaid); // If the payer/client did not specify an amount to use towards the swap, use the `totalPaid`. if (amountToSwapWith == 0) amountToSwapWith = totalPaid; // Get a reference to the controller. IJBController controller = IJBController(address(DIRECTORY.controllerOf(context.projectId))); // Get a reference to the ruleset. (JBRuleset memory ruleset,) = controller.currentRulesetOf(context.projectId); // If the hook should base its weight on a currency other than the terminal's currency, determine the factor. uint256 weightRatio = context.amount.currency == ruleset.baseCurrency() ? 10 ** context.amount.decimals : PRICES.pricePerUnitOf({ projectId: context.projectId, pricingCurrency: context.amount.currency, unitCurrency: ruleset.baseCurrency(), decimals: context.amount.decimals }); // Calculate how many tokens a direct payment would put in the beneficiary's hands for the swap portion. This // is the bar the pool has to beat and the swap's issuance-rate price limit. Swapped tokens normally pass // through the reserved split just like issuance, so the full mint count is the right comparison; when the // payer skips the split, only the beneficiary's share of a direct mint is. uint256 tokenCountWithoutHook = mulDiv({x: amountToSwapWith, y: weight, denominator: weightRatio}); if (skipSplits) { tokenCountWithoutHook = _beneficiaryShareOf({tokenCount: tokenCountWithoutHook, reservedPercent: context.reservedPercent}); } // Keep a reference to the project's token. address projectToken = projectTokenOf[context.projectId]; // Keep a reference to the token being used by the terminal. Use address(0) for native ETH. address terminalToken = context.amount.token == JBConstants.NATIVE_TOKEN ? address(0) : context.amount.token; // Keep references to pool diagnostics. If the user provided a quote, honor it directly and skip the TWAP. uint256 rawSwapQuote; int24 twapTick; uint128 twapLiquidity; PoolId poolId; bool oracleUnseeded; bool poolHasLiquidity = true; if (hasUserSpecifiedQuote) { // Only compute poolId from storage when skipping _getQuote (which would return it). poolId = _poolKeyOf[context.projectId][terminalToken].toId(); poolHasLiquidity = _poolIsSet[context.projectId][terminalToken] && poolManager.getLiquidity(poolId) != 0; if (poolHasLiquidity) { (rawSwapQuote,, twapTick, twapLiquidity,, oracleUnseeded) = _getQuote({ projectId: context.projectId, amountIn: amountToSwapWith, baseToken: terminalToken, quoteToken: projectToken, terminalToken: terminalToken, allowColdStartSpotFallback: false }); } } else { (rawSwapQuote, minimumSwapAmountOut, twapTick, twapLiquidity, poolId, oracleUnseeded) = _getQuote({ projectId: context.projectId, amountIn: amountToSwapWith, baseToken: terminalToken, quoteToken: projectToken, terminalToken: terminalToken, allowColdStartSpotFallback: true }); } uint256 minimumBeneficiaryTokenCount; uint256 minimumReservedTokenCount; if (_poolIsSet[context.projectId][terminalToken]) { // The PoolKey sorts currencies numerically, so the swap direction depends on whether the project token is // currency0 in the configured pool. bool projectTokenIs0 = address(projectToken) < terminalToken; // Any part of the payment not routed through the pool must still mint through the terminal path. uint256 amountToMintWith = totalPaid == amountToSwapWith ? 0 : totalPaid - amountToSwapWith; // With no live pool liquidity, minting is the only executable route. If the user supplied a hard quote // floor above what direct minting can produce, revert instead of silently minting below their floor. if (!poolHasLiquidity && hasUserSpecifiedQuote) { _requireMinimum({amount: tokenCountWithoutHook, minimum: minimumSwapAmountOut}); } // A noop leaves the payment on the mint path whenever the market route is unavailable or direct minting // already meets/exceeds the user's quote or the TWAP-derived minimum. bool noop = !poolHasLiquidity || tokenCountWithoutHook >= minimumSwapAmountOut; if (oracleUnseeded && !hasUserSpecifiedQuote && minimumSwapAmountOut != 0) { // The cold-start quote is spot-dependent, so use it only for route selection. Execution still enforces // the issuance-rate floor that the swap price limit is built from. minimumSwapAmountOut = tokenCountWithoutHook; } // Use the controller's preview path so the metadata mirrors the actual beneficiary/reserved split logic. if (minimumSwapAmountOut != 0) { (minimumBeneficiaryTokenCount, minimumReservedTokenCount) = controller.previewMintOf({ projectId: context.projectId, tokenCount: minimumSwapAmountOut, useReservedPercent: true }); } hookSpecifications = new JBPayHookSpecification[](1); hookSpecifications[0] = JBPayHookSpecification({ hook: IJBPayHook(this), noop: noop, amount: noop ? 0 : amountToSwapWith, metadata: abi.encode( projectTokenIs0, amountToMintWith, minimumSwapAmountOut, hasUserSpecifiedQuote, controller, tokenCountWithoutHook, weightRatio, amountToSwapWith, twapTick, twapLiquidity, poolId, minimumBeneficiaryTokenCount, minimumReservedTokenCount, rawSwapQuote, oracleUnseeded, skipSplits, context.reservedPercent ) }); // All the minting will be done in `afterPayRecordedWith`. Return a weight of 0. if (!noop) return (0, hookSpecifications); } else { // User supplied an explicit pool quote with a non-zero minimum, but no pool is configured — the direct // mint alone must satisfy the requested minimum. Revert so the user does not silently receive fewer // tokens than they asked for. if (hasUserSpecifiedQuote) _requireMinimum({amount: tokenCountWithoutHook, minimum: minimumSwapAmountOut}); } } /// @notice Always returns false — the buyback hook never claims mint permission directly. Minting is handled /// via the controller in `afterPayRecordedWith`/`afterCashOutRecordedWith` using permission from the registry. function hasMintPermissionFor(uint256, JBRuleset memory, address) external pure override returns (bool) { return false; } //*********************************************************************// // -------------------------- public views --------------------------- // //*********************************************************************// /// @notice Returns the PoolKey for a given project and terminal token pair. /// @param projectId The ID of the project. /// @param terminalToken The terminal token address (normalized to address(0) for native). /// @return key The V4 PoolKey. function poolKeyOf(uint256 projectId, address terminalToken) public view override returns (PoolKey memory key) { return _poolKeyOf[projectId][terminalToken]; } /// @notice Indicates which interfaces this contract implements (ERC-165). function supportsInterface(bytes4 interfaceId) public pure override returns (bool) { return interfaceId == type(IJBRulesetDataHook).interfaceId || interfaceId == type(IJBPayHook).interfaceId || interfaceId == type(IJBCashOutHook).interfaceId || interfaceId == type(IJBBuybackHook).interfaceId || interfaceId == type(IJBPermissioned).interfaceId || interfaceId == type(IERC165).interfaceId; } //*********************************************************************// // ---------------------- internal transactions ---------------------- // //*********************************************************************// /// @notice Mints project tokens through the controller with an empty memo. /// @dev One call site for both the buy-side settlement and the sell-side remint keeps the ABI-encoding code /// out of the bytecode twice. /// @param controller The project's controller. /// @param projectId The ID of the project whose tokens are minted. /// @param tokenCount The number of tokens to mint, before any reserved split. /// @param beneficiary The address that receives the minted tokens. /// @param useReservedPercent Whether the ruleset's reserved percent is applied to the mint. function _mint( IJBController controller, uint256 projectId, uint256 tokenCount, address beneficiary, bool useReservedPercent ) internal { controller.mintTokensOf({ projectId: projectId, tokenCount: tokenCount, beneficiary: beneficiary, memo: "", useReservedPercent: useReservedPercent }); } /// @notice Revert if a TWAP window is outside the allowed bounds. /// @param window The TWAP window to validate. function _requireValidTwapWindow(uint256 window) internal pure { if (window < MIN_TWAP_WINDOW || window > MAX_TWAP_WINDOW) { revert JBBuybackHook_InvalidTwapWindow({value: window, min: MIN_TWAP_WINDOW, max: MAX_TWAP_WINDOW}); } } /// @notice Shared internal logic for setting a pool. Both `setPoolFor` overloads delegate here after permission /// checks and token normalization. /// @param projectId The ID of the project. /// @param poolKey The V4 PoolKey identifying the pool. /// @param twapWindow The period of time over which the TWAP is computed. Exactly `MAX_TWAP_WINDOW` is stored /// as the 30-minute default (an immutable deployer's baked-in value, not a tuning choice); use /// `setTwapWindowOf` for a deliberate max-length window. /// @param normalizedTerminalToken The terminal token address (already normalized: address(0) for native). /// @param projectToken The project's ERC-20 token address. function _setPoolFor( uint256 projectId, PoolKey memory poolKey, uint256 twapWindow, address normalizedTerminalToken, address projectToken ) internal { // Make sure this pool hasn't already been set for this project/token pair. if (_poolIsSet[projectId][normalizedTerminalToken]) { revert JBBuybackHook_PoolAlreadySet({poolId: _poolKeyOf[projectId][normalizedTerminalToken].toId()}); } // A registration at exactly the max window is an immutable deployer's baked-in default, not a tuning // choice — store the default window instead. `setTwapWindowOf` can still set any window, max included. if (twapWindow == MAX_TWAP_WINDOW) twapWindow = _DEFAULT_TWAP_WINDOW; _requireValidTwapWindow(twapWindow); // Make sure the project has issued a token. if (projectToken == address(0)) revert JBBuybackHook_ZeroProjectToken(projectId); // Make sure the terminal token is not the project token. if (normalizedTerminalToken == projectToken) { revert JBBuybackHook_TerminalTokenIsProjectToken({ terminalToken: normalizedTerminalToken, projectToken: projectToken }); } // Validate the pool is initialized in the PoolManager. PoolId poolId = poolKey.toId(); (uint160 sqrtPriceX96,,,) = poolManager.getSlot0(poolId); if (sqrtPriceX96 == 0) revert JBBuybackHook_PoolNotInitialized(poolId); // Validate the PoolKey currencies match the project token and terminal token. address currency0 = Currency.unwrap(poolKey.currency0); address currency1 = Currency.unwrap(poolKey.currency1); bool validPair = (currency0 == projectToken && currency1 == normalizedTerminalToken) || (currency0 == normalizedTerminalToken && currency1 == projectToken); if (!validPair) revert JBBuybackHook_PoolKeyCurrenciesMismatch(); // Store the pool key and mark it as set. _poolKeyOf[projectId][normalizedTerminalToken] = poolKey; _poolIsSet[projectId][normalizedTerminalToken] = true; // Read the current TWAP window before overwriting (for accurate event emission). uint256 oldWindow = twapWindowOf[projectId][normalizedTerminalToken]; // Store the TWAP window and project token. twapWindowOf[projectId][normalizedTerminalToken] = twapWindow; projectTokenOf[projectId] = projectToken; address caller = _msgSender(); emit TwapWindowChanged({ projectId: projectId, terminalToken: normalizedTerminalToken, oldWindow: oldWindow, newWindow: twapWindow, caller: caller }); emit PoolAdded({projectId: projectId, terminalToken: normalizedTerminalToken, poolId: poolId, caller: caller}); } /// @notice Executes the buy-side swap: exchanges terminal tokens for project tokens through the V4 pool. The /// caller settles the received tokens (burn-and-remint through the reserved split, or a direct transfer). /// @param context The `afterPayRecordedContext` passed in by the terminal. /// @param projectTokenIs0 Whether the project token is currency0 in the pool. /// @param minimumSwapAmountOut The token count used to derive the swap's price limit via /// `JBSwapLib.sqrtPriceLimitFromAmounts`. When set to the issuance-rate equivalent (`tokenCountWithoutHook`), /// the swap fills only while the pool offers a better rate than minting. /// @param derivedFloorAmountOut The oracle-derived floor enforced inside the unlock, pro-rated by consumed /// input. A miss unwinds the swap (caught below) so the payment falls back to minting. 0 when the caller /// supplied an explicit minimum, which is enforced on the combined output in `afterPayRecordedWith` instead. /// @return amountReceived The amount of project tokens received from the swap. /// @return swapFailed True if the swap reverted and was caught by try/catch (triggers mint fallback). function _swap( JBAfterPayRecordedContext calldata context, bool projectTokenIs0, uint256 minimumSwapAmountOut, uint256 derivedFloorAmountOut ) internal returns (uint256 amountReceived, bool swapFailed) { uint256 amountToSwapWith = context.forwardedAmount.value; // Get the terminal token, normalized to address(0) for native ETH. address normalizedTerminalToken = context.forwardedAmount.token == JBConstants.NATIVE_TOKEN ? address(0) : context.forwardedAmount.token; // Get the pool key for this project/token pair. PoolKey memory key = _poolKeyOf[context.projectId][normalizedTerminalToken]; // Try the V4 unlock/callback swap. On failure, fall back to minting. (, amountReceived, swapFailed) = _swapExactInput({ key: key, amountIn: amountToSwapWith, minimumSwapAmountOut: minimumSwapAmountOut, zeroForOne: !projectTokenIs0, derivedFloorAmountOut: derivedFloorAmountOut }); if (swapFailed) return (0, true); emit Swap({ projectId: context.projectId, amountToSwapWith: amountToSwapWith, poolId: key.toId(), amountReceived: amountReceived, caller: msg.sender }); } /// @notice Swap an exact amount of the input token through the configured V4 pool. /// @dev Encodes the swap parameters into callback data, hands control to the PoolManager through `unlock(...)`, /// and decodes the amount received from the callback result. If the pool reverts, returns `swapFailed = true` /// instead of propagating the revert, allowing the caller to implement a fallback path. /// @param key The V4 pool key to swap against. /// @param amountIn The exact amount of input tokens to sell. /// @param minimumSwapAmountOut The minimum acceptable amount of output tokens. /// @param zeroForOne Whether the swap should move from `currency0` to `currency1`. /// @param derivedFloorAmountOut The oracle-derived floor enforced inside the unlock, pro-rated by consumed input. /// 0 when no in-unlock floor applies (explicit caller minima, and sell-side swaps whose derived floors soft-land /// through the caller's `shouldEnforceMinimumSwapAmountOut` flag). /// @return amountSpent The amount of input tokens actually consumed by the swap. /// @return amountReceived The amount of output tokens received from the swap. /// @return swapFailed True if the swap reverted and was caught by try/catch. function _swapExactInput( PoolKey memory key, uint256 amountIn, uint256 minimumSwapAmountOut, bool zeroForOne, uint256 derivedFloorAmountOut ) internal returns (uint256 amountSpent, uint256 amountReceived, bool swapFailed) { // Encode the swap parameters so `unlockCallback(...)` can execute the swap after the PoolManager unlocks. bytes memory callbackData = abi.encode( SwapCallbackData({ key: key, zeroForOne: zeroForOne, amountIn: amountIn, minimumSwapAmountOut: minimumSwapAmountOut, derivedFloorAmountOut: derivedFloorAmountOut }) ); // Try the V4 unlock/callback swap. On failure, signal the caller to handle the fallback. try poolManager.unlock(callbackData) returns (bytes memory result) { (amountSpent, amountReceived) = abi.decode(result, (uint256, uint256)); } catch { return (0, 0, true); } } //*********************************************************************// // -------------------------- internal views ------------------------- // //*********************************************************************// /// @notice The share of a mint that lands with the beneficiary once the reserved percent is taken out. /// @dev Mirrors `JBController`'s split rounding exactly so a payer's minimum settles against the same number the /// controller mints. /// @param tokenCount The full mint count. /// @param reservedPercent The ruleset's reserved percent, out of `JBConstants.MAX_RESERVED_PERCENT`. /// @return beneficiaryTokenCount The beneficiary's share of `tokenCount`. function _beneficiaryShareOf( uint256 tokenCount, uint256 reservedPercent ) internal pure returns (uint256 beneficiaryTokenCount) { return mulDiv({ x: tokenCount, y: JBConstants.MAX_RESERVED_PERCENT - reservedPercent, denominator: JBConstants.MAX_RESERVED_PERCENT }); } /// @notice Normalize the terminal token, look up the project token, and construct the PoolKey. /// @param projectId The ID of the project. /// @param fee The Uniswap V4 pool fee tier. /// @param tickSpacing The Uniswap V4 pool tick spacing. /// @param terminalToken The terminal token address (may be NATIVE_TOKEN). /// @return poolKey The constructed PoolKey. /// @return normalizedTerminalToken The terminal token normalized to address(0) for native. /// @return projectToken The project's ERC-20 token address. function _buildPoolKey( uint256 projectId, uint24 fee, int24 tickSpacing, address terminalToken ) internal view returns (PoolKey memory poolKey, address normalizedTerminalToken, address projectToken) { // Normalize the terminal token — use address(0) for native. normalizedTerminalToken = terminalToken == JBConstants.NATIVE_TOKEN ? address(0) : terminalToken; // Get the project's token. projectToken = address(TOKENS.tokenOf(projectId)); // Sort currencies numerically (lower address = currency0). (Currency currency0, Currency currency1) = normalizedTerminalToken < projectToken ? (Currency.wrap(normalizedTerminalToken), Currency.wrap(projectToken)) : (Currency.wrap(projectToken), Currency.wrap(normalizedTerminalToken)); // Construct the pool key with the oracle hook. poolKey = PoolKey({ currency0: currency0, currency1: currency1, fee: fee, tickSpacing: tickSpacing, hooks: oracleHook }); } /// @dev `ERC-2771` specifies the context as being a single address (20 bytes). function _contextSuffixLength() internal view override(ERC2771Context, Context) returns (uint256) { return super._contextSuffixLength(); } /// @notice This contract's balance of a V4 currency (native or ERC-20). /// @param currency The V4 currency to read the balance of. /// @return The balance held by this contract. function _currencyBalanceOf(Currency currency) internal view returns (uint256) { return currency.isAddressZero() ? address(this).balance : IERC20(Currency.unwrap(currency)).balanceOf(address(this)); } /// @notice Whether the selected terminal can settle the direct protocol reclaim used in route comparison. /// @dev Cash-out pricing can use aggregate surplus, but the selected terminal can only pay from its local surplus. /// If the direct path cannot settle locally, a live AMM route must be allowed to win even when the aggregate /// direct reclaim is numerically higher. /// @param grossDirectCashOutAmount The gross terminal-token reclaim before terminal fees. /// @param context The before-cashout context the data hook is processing. /// @return True if the selected terminal has enough local surplus to settle the direct reclaim. function _directCashOutCanSettle( uint256 grossDirectCashOutAmount, JBBeforeCashOutRecordedContext calldata context ) internal view returns (bool) { // `currentSurplusOf(...)` accepts a token list because terminals can price multiple accounting tokens; this // route comparison only needs the token used by the selected cash-out terminal. address[] memory tokens = new address[](1); // Use the same token, decimals, and currency that core used to price `context.surplus`, so the local // settlement check is denominated exactly like the direct reclaim. tokens[0] = context.surplus.token; // Bind the selected terminal from the context so the surplus read cannot accidentally consult another // terminal's accounting context. IJBTerminal terminal = IJBTerminal(context.terminal); // Read the selected terminal's local surplus. Aggregate or remote surplus can affect the reclaim quote, but // this terminal can only pay what its own accounting context currently holds. uint256 localSurplus = terminal.currentSurplusOf({ projectId: context.projectId, tokens: tokens, decimals: context.surplus.decimals, currency: context.surplus.currency }); // Direct cash-out settlement needs the gross reclaim available locally; terminal fees are charged from the // payout, but the terminal still has to source the gross amount before fee accounting completes. return grossDirectCashOutAmount <= localSurplus; } /// @notice Queries the TWAP oracle for a price quote and applies sigmoid-based slippage to produce the minimum /// acceptable swap output. Returns 0 if the oracle is unavailable or liquidity is insufficient. /// @dev A TWAP can remain warm after all in-range liquidity has been removed. Live PoolManager liquidity is /// checked separately so an initialized-but-empty pool cannot activate a route that would immediately fail. /// @param projectId The ID of the project. /// @param amountIn The number of input tokens to swap. /// @param baseToken The token to swap in. /// @param quoteToken The token to swap out. /// @param terminalToken The terminal token address (already normalized: address(0) for native). Passed by the /// caller to avoid a redundant `projectTokenOf` SLOAD that would otherwise be needed to derive it. /// @param allowColdStartSpotFallback Whether to allow the buy-side, bounded spot fallback when no TWAP exists. /// @return rawAmountOut The raw oracle quote before slippage adjustment. /// @return amountOut The minimum number of tokens to receive after slippage adjustment. /// @return twapTick The arithmetic mean tick from the TWAP oracle. /// @return twapLiquidity The harmonic mean liquidity from the TWAP oracle. /// @return poolId The V4 pool identifier used for the quote. /// @return oracleUnseeded True when the pool has live liquidity but no usable TWAP liquidity. function _getQuote( uint256 projectId, uint256 amountIn, address baseToken, address quoteToken, address terminalToken, bool allowColdStartSpotFallback ) internal view returns ( uint256 rawAmountOut, uint256 amountOut, int24 twapTick, uint128 twapLiquidity, PoolId poolId, bool oracleUnseeded ) { // Check pool existence before loading the full PoolKey struct (~500 gas saved on early exit). if (!_poolIsSet[projectId][terminalToken]) return (0, 0, 0, 0, PoolId.wrap(0), false); // Get the pool key for this project/terminal token pair. PoolKey memory key = _poolKeyOf[projectId][terminalToken]; // Get the TWAP window for this project/terminal token pair. uint256 twapWindow = twapWindowOf[projectId][terminalToken]; // Keep a reference to the pool ID. poolId = key.toId(); // If there is no current in-range liquidity, the next swap cannot execute against this pool even if the // oracle still has historical observations. uint128 currentLiquidity = poolManager.getLiquidity(poolId); if (currentLiquidity == 0) return (0, 0, 0, 0, poolId, false); // Query the oracle hook (or spot if twapWindow is 0). (amountOut, twapTick, twapLiquidity) = JBSwapLib.getQuoteFromOracle({ poolManager: poolManager, key: key, // Safe: twapWindow is validated <= MAX_TWAP_WINDOW (2 days = 172800), fits in uint32. // forge-lint: disable-next-line(unsafe-typecast) twapWindow: uint32(twapWindow), // Safe: amountIn is a token payment amount, bounded by realistic token supplies well within uint128. // forge-lint: disable-next-line(unsafe-typecast) amountIn: uint128(amountIn), baseToken: baseToken, quoteToken: quoteToken }); bool zeroForOne = baseToken < quoteToken; // If there's no TWAP liquidity data, the buy side may use a tightly bounded bootstrap quote. Prefer a valid // oracle tick when one exists; raw slot0 is only used for the configured oracle hook when no oracle quote is // available. Sell-side quotes stay TWAP-only. if (twapLiquidity == 0) { // Surface the unseeded-oracle state even when guardrails reject routing, so preview clients can explain why // the hook minted instead of swapping. oracleUnseeded = true; // Cash-outs and explicit diagnostic lookups must not use spot pricing, because spot is manipulable inside // one block and is only acceptable for the bounded buy-side bootstrap path. if (!allowColdStartSpotFallback) return (0, 0, twapTick, 0, poolId, oracleUnseeded); // Raw slot0 is only trusted for pools using this hook's configured oracle hook; an arbitrary pool hook can // keep TWAP liquidity at zero forever and would otherwise make spot routing a permanent mode. if (address(key.hooks) != address(oracleHook)) return (0, 0, twapTick, 0, poolId, oracleUnseeded); // Slot0 supplies the fallback spot tick and the live LP fee. The LP fee is dynamic for some V4 pools, so // the pool key's fee field is not sufficient for this guardrail. (uint160 spotSqrtP, int24 spotTick,, uint24 spotLpFee) = poolManager.getSlot0(poolId); // When the oracle produced no quote, report the spot tick used for the bootstrap quote. If the oracle did // produce a mean tick, keep that tick in the diagnostics and avoid raw spot for pricing. if (amountOut == 0) twapTick = spotTick; // Keep the sqrt price paired with the tick that produced the quote, so the impact estimate and raw quote // use the same price source. uint160 quoteSqrtP; if (amountOut == 0) { // A zero sqrt price means slot0 cannot describe an initialized price, so there is no safe spot // fallback. if (spotSqrtP == 0) return (0, 0, twapTick, 0, poolId, oracleUnseeded); // Use live slot0 only when the oracle cannot quote at all. quoteSqrtP = spotSqrtP; // Build the gross spot quote. The fee and impact haircut is applied below before route comparison. rawAmountOut = JBSwapLib.getQuoteAtTick({ tick: spotTick, // Safe: amountIn is a token payment amount, bounded by realistic token supplies well within // uint128. // forge-lint: disable-next-line(unsafe-typecast) baseAmount: uint128(amountIn), baseToken: baseToken, quoteToken: quoteToken }); } else { // The oracle can return a valid mean tick even when harmonic liquidity is zero. Use that mean tick for // pricing because it is harder to manipulate than raw slot0. quoteSqrtP = TickMath.getSqrtPriceAtTick(twapTick); // Preserve the oracle's gross quote so diagnostics show the quote source used for route selection. rawAmountOut = amountOut; } // Bound the first trade by live in-range liquidity so a tiny seed cannot route a large spot-priced payment. uint256 spotImpact = JBSwapLib.calculateImpact({ amountIn: amountIn, liquidity: currentLiquidity, sqrtP: quoteSqrtP, zeroForOne: zeroForOne }); // Reject quotes whose estimated impact exceeds the cold-start cap; returning zero keeps issuance routing. if (spotImpact > _MAX_COLD_START_SPOT_IMPACT) return (0, 0, twapTick, 0, poolId, oracleUnseeded); // Discount the gross quote by the fixed haircut, the live LP fee, and rounded-up impact so the route // comparison has buffer for the bootstrap swap's execution cost. V4 fees are in hundredths of a bip. uint256 coldStartDiscount = _COLD_START_SPOT_SLIPPAGE + uint256(spotLpFee) / 100 + spotImpact / 1e14 + 1; // If the live fee and guardrail discount consume the full quote, there is no executable AMM route. if (coldStartDiscount >= TWAP_SLIPPAGE_DENOMINATOR) { return (0, 0, twapTick, 0, poolId, oracleUnseeded); } // Return both the raw quote for diagnostics and the discounted quote for the routing comparison. amountOut = (rawAmountOut * (TWAP_SLIPPAGE_DENOMINATOR - coldStartDiscount)) / TWAP_SLIPPAGE_DENOMINATOR; return (rawAmountOut, amountOut, twapTick, 0, poolId, oracleUnseeded); } // If oracle returned 0, no quote available — trigger mint fallback. if (amountOut == 0) return (0, 0, twapTick, twapLiquidity, poolId, false); // Calculate price impact. uint160 sqrtP = TickMath.getSqrtPriceAtTick(twapTick); // Bound quote confidence by the weaker liquidity source. Historical TWAP liquidity can overstate a drained // pool, while live in-range liquidity alone does not prove the oracle window was deep. uint128 effectiveLiquidity = currentLiquidity < twapLiquidity ? currentLiquidity : twapLiquidity; // Estimate the order's impact against the conservative liquidity bound so thin current or historical liquidity // can disable routing before execution. uint256 impact = JBSwapLib.calculateImpact({ amountIn: amountIn, liquidity: effectiveLiquidity, sqrtP: sqrtP, zeroForOne: zeroForOne }); // Treat a max-impact quote as unusable. Returning zero keeps route selection on the protocol path. if (impact >= _MAX_TWAP_IMPACT) return (0, 0, twapTick, twapLiquidity, poolId, false); // Get the actual LP fee from slot0 (key.fee may differ for dynamic-fee pools). // V4 fees are in hundredths of a bip, so divide by 100 to get basis points. (,,, uint24 lpFee) = poolManager.getSlot0(poolId); uint256 poolFeeBps = uint256(lpFee) / 100; // Calculate continuous sigmoid slippage tolerance. uint256 slippageTolerance = JBSwapLib.getSlippageTolerance({impact: impact, poolFeeBps: poolFeeBps}); // If the slippage tolerance is the maximum, return 0 to trigger mint. if (slippageTolerance >= _MAX_TWAP_SLIPPAGE) return (0, 0, twapTick, twapLiquidity, poolId, false); // Save the raw oracle quote before slippage adjustment. rawAmountOut = amountOut; // Apply slippage to the oracle quote. amountOut -= (amountOut * slippageTolerance) / TWAP_SLIPPAGE_DENOMINATOR; } /// @notice The calldata. Preferred to use over `msg.data`. function _msgData() internal view override(ERC2771Context, Context) returns (bytes calldata) { return ERC2771Context._msgData(); } /// @notice The message's sender. Preferred to use over `msg.sender`. /// @return sender The address which sent this call. function _msgSender() internal view override(ERC2771Context, Context) returns (address sender) { return ERC2771Context._msgSender(); } /// @notice The exact net the terminal will settle to the beneficiary after applying its fee policy. /// @dev Mirrors `JBMultiTerminal._cashOutTokensOf` fee math: feeless beneficiaries pay nothing; non-zero /// tax rate charges the full standard fee on the gross; zero tax rate charges only up to /// `feeFreeSurplusOf` (the per-(project, token) round-trip exposure counter the terminal exposes /// publicly). The hook reads the counter via the terminal address already present in the context. /// @param amount The gross reclaim amount before any terminal fee. /// @param context The before-cashout context the data hook is processing. /// @return The net the beneficiary will receive (gross minus any applicable terminal fee). function _netAfterTerminalFee( uint256 amount, JBBeforeCashOutRecordedContext calldata context ) internal view returns (uint256) { if (context.beneficiaryIsFeeless) return amount; uint256 feeBase; if (context.cashOutTaxRate != 0) { // Non-zero tax: terminal charges the full standard fee on the gross. feeBase = amount; } else { // Zero tax: terminal charges only up to its fee-free surplus for this (project, token). uint256 feeFreeSurplus = IJBMultiTerminal(context.terminal) .feeFreeSurplusOf({projectId: context.projectId, token: context.surplus.token}); feeBase = amount < feeFreeSurplus ? amount : feeFreeSurplus; } return amount - JBFees.standardFeeAmountFrom(feeBase); } /// @notice Reverts when a settled amount falls short of a caller-specified minimum. /// @dev A zero minimum is "no floor". One revert site for every explicit-minimum check keeps the error encoding /// out of the bytecode eight times. /// @param amount The amount actually settled. /// @param minimum The caller's minimum. function _requireMinimum(uint256 amount, uint256 minimum) internal pure { if (minimum != 0 && amount < minimum) { revert JBBuybackHook_SpecifiedSlippageExceeded({amount: amount, minimum: minimum}); } } /// @notice Returns this contract's balance of the given terminal token. /// @param token The terminal token address (NATIVE_TOKEN for ETH). /// @return balance The current balance held by this contract. function _terminalTokenBalance(address token) internal view returns (uint256 balance) { return token == JBConstants.NATIVE_TOKEN ? address(this).balance : IERC20(token).balanceOf(address(this)); } }