StockFunLauncher

0x3ebcc8c52d05277e8f2a5a3282e847ca0936f23d

Verification
Verified
v0.8.26+commit.8a97fa7a
Type
Contract
16,314 bytes
ABI entries
33
14 read · 8 write
License
none

Contract information

Address
0x3ebcc8c52d05277e8f2a5a3282e847ca0936f23d
Chain
Robinhood Chain (4663)
Compiler
v0.8.26+commit.8a97fa7a
Optimization
Enabled
Creation tx
0xa63d79f2c5…9b36ccd6b8

Token

Not a token

This contract does not expose ERC-20 metadata.

Read contract (14)

BPS()uint16
FEE_TIER()uint24
TICK_SPACING()int24
TOTAL_SUPPLY()uint256
TRANCHES()uint256
feeRecipient()address
getLaunch(uint256)tuple
launchFee()uint256
launchesCount()uint256
owner()address
permit2()address
poolManager()address
positionManager()address
rescueEnabled()bool

Events (7)

FeeRecipientSetFeesCollectedLaunchFeeSetLaunchedOwnershipTransferredRescueDisabledRescued

ABI

[
  {
    "inputs": [
      {
        "internalType": "contract IPoolManager",
        "name": "poolManager_",
        "type": "address"
      },
      {
        "internalType": "contract IPositionManager",
        "name": "positionManager_",
        "type": "address"
      },
      {
        "internalType": "contract IAllowanceTransfer",
        "name": "permit2_",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "owner_",
        "type": "address"
      },
      {
        "internalType": "uint256",
        "name": "launchFee_",
        "type": "uint256"
      }
    ],
    "stateMutability": "nonpayable",
    "type": "constructor"
  },
  {
    "inputs": [],
    "name": "InvalidLaunchFee",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "InvalidRange",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "RescueIsDisabled",
    "type": "error"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": false,
        "internalType": "address",
        "name": "recipient",
        "type": "address"
      }
    ],
    "name": "FeeRecipientSet",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "uint256",
        "name": "launchId",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "amount0",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "amount1",
        "type": "uint256"
      }
    ],
    "name": "FeesCollected",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "fee",
        "type": "uint256"
      }
    ],
    "name": "LaunchFeeSet",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "uint256",
        "name": "launchId",
        "type": "uint256"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "token",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "stockToken",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "address",
        "name": "creator",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "firstPositionTokenId",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint24",
        "name": "fee",
        "type": "uint24"
      }
    ],
    "name": "Launched",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "user",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "newOwner",
        "type": "address"
      }
    ],
    "name": "OwnershipTransferred",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [],
    "name": "RescueDisabled",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "uint256",
        "name": "launchId",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "positionTokenId",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "address",
        "name": "to",
        "type": "address"
      }
    ],
    "name": "Rescued",
    "type": "event"
  },
  {
    "inputs": [],
    "name": "BPS",
    "outputs": [
      {
        "internalType": "uint16",
        "name": "",
        "type": "uint16"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "FEE_TIER",
    "outputs": [
      {
        "internalType": "uint24",
        "name": "",
        "type": "uint24"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "TICK_SPACING",
    "outputs": [
      {
        "internalType": "int24",
        "name": "",
        "type": "int24"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "TOTAL_SUPPLY",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "TRANCHES",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "launchId",
        "type": "uint256"
      }
    ],
    "name": "collectFees",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "amount0",
        "type": "uint256"
      },
      {
        "internalType": "uint256",
        "name": "amount1",
        "type": "uint256"
      }
    ],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "disableRescue",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "feeRecipient",
    "outputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "launchId",
        "type": "uint256"
      }
    ],
    "name": "getLaunch",
    "outputs": [
      {
        "components": [
          {
            "internalType": "address",
            "name": "token",
            "type": "address"
          },
          {
            "internalType": "address",
            "name": "stockToken",
            "type": "address"
          },
          {
            "internalType": "address",
            "name": "creator",
            "type": "address"
          },
          {
            "internalType": "uint256",
            "name": "firstPositionTokenId",
            "type": "uint256"
          },
          {
            "components": [
              {
                "internalType": "Currency",
                "name": "currency0",
                "type": "address"
              },
              {
                "internalType": "Currency",
                "name": "currency1",
                "type": "address"
              },
              {
                "internalType": "uint24",
                "name": "fee",
                "type": "uint24"
              },
              {
                "internalType": "int24",
                "name": "tickSpacing",
                "type": "int24"
              },
              {
                "internalType": "contract IHooks",
                "name": "hooks",
                "type": "address"
              }
            ],
            "internalType": "struct PoolKey",
            "name": "poolKey",
            "type": "tuple"
          },
          {
            "internalType": "int24[5]",
            "name": "tickBounds",
            "type": "int24[5]"
          }
        ],
        "internalType": "struct StockFunLauncher.Launch",
        "name": "",
        "type": "tuple"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "components": [
          {
            "internalType": "string",
            "name": "name",
            "type": "string"
          },
          {
            "internalType": "string",
            "name": "symbol",
            "type": "string"
          },
          {
            "internalType": "address",
            "name": "stockToken",
            "type": "address"
          },
          {
            "internalType": "int24",
            "name": "startingTick",
            "type": "int24"
          }
        ],
        "internalType": "struct StockFunLauncher.LaunchParams",
        "name": "p",
        "type": "tuple"
      }
    ],
    "name": "launch",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "launchId",
        "type": "uint256"
      },
      {
        "internalType": "address",
        "name": "token",
        "type": "address"
      }
    ],
    "stateMutability": "payable",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "launchFee",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "launchesCount",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "owner",
    "outputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "permit2",
    "outputs": [
      {
        "internalType": "contract IAllowanceTransfer",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "poolManager",
    "outputs": [
      {
        "internalType": "contract IPoolManager",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "positionManager",
    "outputs": [
      {
        "internalType": "contract IPositionManager",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "launchId",
        "type": "uint256"
      },
      {
        "internalType": "address",
        "name": "to",
        "type": "address"
      }
    ],
    "name": "rescue",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "rescueEnabled",
    "outputs": [
      {
        "internalType": "bool",
        "name": "",
        "type": "bool"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "recipient",
        "type": "address"
      }
    ],
    "name": "setFeeRecipient",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "fee",
        "type": "uint256"
      }
    ],
    "name": "setLaunchFee",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "newOwner",
        "type": "address"
      }
    ],
    "name": "transferOwnership",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "to",
        "type": "address"
      }
    ],
    "name": "withdrawETH",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  }
]

Source code

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;

import {Owned} from "solmate/src/auth/Owned.sol";
import {ERC20} from "solmate/src/tokens/ERC20.sol";
import {IPoolManager} from "v4-core/interfaces/IPoolManager.sol";
import {IHooks} from "v4-core/interfaces/IHooks.sol";
import {PoolKey} from "v4-core/types/PoolKey.sol";
import {Currency} from "v4-core/types/Currency.sol";
import {TickMath} from "v4-core/libraries/TickMath.sol";
import {IPositionManager} from "v4-periphery/src/interfaces/IPositionManager.sol";
import {Actions} from "v4-periphery/src/libraries/Actions.sol";
import {LiquidityAmounts} from "v4-periphery/src/libraries/LiquidityAmounts.sol";
import {IAllowanceTransfer} from "permit2/src/interfaces/IAllowanceTransfer.sol";
import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol";

import {StockFunToken} from "./StockFunToken.sol";

/// @notice stock.fun launcher — launches a fixed-supply token one-sided into a
/// hookless Uniswap v4 pool paired against a Robinhood Chain stock token.
///
/// Liquidity ladder: the full supply is placed as FOUR one-sided ranges above
/// the starting price so the market-cap curve follows the designed checkpoints
/// (calibrated for a ~$7k starting mcap: ~$9k of net buys per checkpoint
/// segment → $50k / $150k / $250k, with a deep tail above).
///
///   tranche   price multiple of start   supply
///   1         1x    → ~7.10x            47.65%
///   2         7.10x → ~21.3x            10.32%
///   3         21.3x → ~35.9x             4.59%
///   4 (tail)  35.9x → max tick          37.44%
///
/// LP custody: all four positions are PositionManager ERC-721s held by this
/// contract. No public path removes liquidity. Owner-only `rescue` is a
/// testing-phase escape hatch; `disableRescue()` burns it permanently.
/// No creator fees: LP fees are collectable only to the protocol `feeRecipient`.
contract StockFunLauncher is Owned {
    uint256 public constant TOTAL_SUPPLY = 1_000_000_000e18;
    uint256 public constant TRANCHES = 4;
    uint16 public constant BPS = 10_000;

    /// @dev Supply share per tranche, bps. Sums to 10_000.
    uint16 internal constant BPS_T1 = 4_765;
    uint16 internal constant BPS_T2 = 1_032;
    uint16 internal constant BPS_T3 = 459;
    uint16 internal constant BPS_T4 = 3_744;

    /// @dev Tranche upper bounds as tick offsets from the aligned start tick
    /// (multiples of the 1% tier's 200 spacing; ≈7.10x / 21.3x / 35.9x price).
    int24 internal constant OFFSET_T1 = 19_600;
    int24 internal constant OFFSET_T2 = 30_600;
    int24 internal constant OFFSET_T3 = 35_800;

    uint24 public constant FEE_TIER = 10_000; // 1%
    int24 public constant TICK_SPACING = 200;

    IPoolManager public immutable poolManager;
    IPositionManager public immutable positionManager;
    IAllowanceTransfer public immutable permit2;

    /// @notice Flat ETH fee charged per launch, retained in this contract until withdrawn.
    uint256 public launchFee;
    /// @notice Receiver of collected LP fees.
    address public feeRecipient;
    /// @notice Testing-phase escape hatch switch; irreversibly disabled via disableRescue().
    bool public rescueEnabled = true;

    struct Launch {
        address token;
        address stockToken;
        address creator;
        /// First of TRANCHES consecutive PositionManager token ids.
        uint256 firstPositionTokenId;
        PoolKey poolKey;
        /// Tranche boundary ticks, start-side first (ascending when the token
        /// is currency0, descending when it is currency1).
        int24[5] tickBounds;
    }

    Launch[] internal _launches;

    struct LaunchParams {
        string name;
        string symbol;
        address stockToken;
        /// Starting price expressed as a tick of stock-per-token: price = 1.0001^startingTick.
        /// Orientation is handled internally regardless of currency sort order.
        int24 startingTick;
    }

    event Launched(
        uint256 indexed launchId,
        address indexed token,
        address indexed stockToken,
        address creator,
        uint256 firstPositionTokenId,
        uint24 fee
    );
    event FeesCollected(uint256 indexed launchId, uint256 amount0, uint256 amount1);
    event Rescued(uint256 indexed launchId, uint256 positionTokenId, address to);
    event RescueDisabled();
    event LaunchFeeSet(uint256 fee);
    event FeeRecipientSet(address recipient);

    error InvalidLaunchFee();
    error InvalidRange();
    error RescueIsDisabled();

    constructor(
        IPoolManager poolManager_,
        IPositionManager positionManager_,
        IAllowanceTransfer permit2_,
        address owner_,
        uint256 launchFee_
    ) Owned(owner_) {
        poolManager = poolManager_;
        positionManager = positionManager_;
        permit2 = permit2_;
        launchFee = launchFee_;
        feeRecipient = owner_;
    }

    // ---------------------------------------------------------------- launch

    function launch(LaunchParams calldata p) external payable returns (uint256 launchId, address token) {
        if (msg.value != launchFee) revert InvalidLaunchFee();

        StockFunToken t = new StockFunToken(p.name, p.symbol, TOTAL_SUPPLY, address(this));
        token = address(t);

        bool tokenIs0 = token < p.stockToken;
        PoolKey memory key = PoolKey({
            currency0: Currency.wrap(tokenIs0 ? token : p.stockToken),
            currency1: Currency.wrap(tokenIs0 ? p.stockToken : token),
            fee: FEE_TIER,
            tickSpacing: TICK_SPACING,
            hooks: IHooks(address(0))
        });

        // Pool tick is stock-per-token when the token is currency0, inverted otherwise.
        int24 poolTick = tokenIs0 ? p.startingTick : -p.startingTick;
        poolManager.initialize(key, TickMath.getSqrtPriceAtTick(poolTick));

        int24[5] memory bounds = _trancheBounds(poolTick, tokenIs0);
        uint256 firstPositionTokenId = _mintTranches(t, key, bounds, tokenIs0);

        // Rounding dust from the bps split stays out of the pool; send it to the creator.
        uint256 dust = t.balanceOf(address(this));
        if (dust > 0) t.transfer(msg.sender, dust);

        launchId = _launches.length;
        _launches.push(
            Launch({
                token: token,
                stockToken: p.stockToken,
                creator: msg.sender,
                firstPositionTokenId: firstPositionTokenId,
                poolKey: key,
                tickBounds: bounds
            })
        );
        emit Launched(launchId, token, p.stockToken, msg.sender, firstPositionTokenId, FEE_TIER);
    }

    /// @dev Boundary ticks for the four ranges, walking away from the start
    /// price in the direction that is "up" for the launch token.
    function _trancheBounds(int24 poolTick, bool tokenIs0) internal pure returns (int24[5] memory b) {
        if (tokenIs0) {
            b[0] = _alignUp(poolTick, TICK_SPACING);
            b[1] = b[0] + OFFSET_T1;
            b[2] = b[0] + OFFSET_T2;
            b[3] = b[0] + OFFSET_T3;
            b[4] = (TickMath.MAX_TICK / TICK_SPACING) * TICK_SPACING;
            if (b[3] >= b[4]) revert InvalidRange();
        } else {
            b[0] = _alignDown(poolTick, TICK_SPACING);
            b[1] = b[0] - OFFSET_T1;
            b[2] = b[0] - OFFSET_T2;
            b[3] = b[0] - OFFSET_T3;
            b[4] = (TickMath.MIN_TICK / TICK_SPACING) * TICK_SPACING;
            if (b[3] <= b[4]) revert InvalidRange();
        }
    }

    function _trancheAmounts() internal pure returns (uint256[4] memory a) {
        a[0] = (TOTAL_SUPPLY * BPS_T1) / BPS;
        a[1] = (TOTAL_SUPPLY * BPS_T2) / BPS;
        a[2] = (TOTAL_SUPPLY * BPS_T3) / BPS;
        a[3] = (TOTAL_SUPPLY * BPS_T4) / BPS;
    }

    function _mintTranches(StockFunToken t, PoolKey memory key, int24[5] memory b, bool tokenIs0)
        internal
        returns (uint256 firstPositionTokenId)
    {
        // PositionManager settles through Permit2.
        t.approve(address(permit2), TOTAL_SUPPLY);
        permit2.approve(address(t), address(positionManager), uint160(TOTAL_SUPPLY), type(uint48).max);

        firstPositionTokenId = positionManager.nextTokenId();
        uint256[4] memory amounts = _trancheAmounts();

        bytes memory actions;
        bytes[] memory params = new bytes[](TRANCHES + 1);
        for (uint256 k = 0; k < TRANCHES; k++) {
            (int24 lower, int24 upper) = b[k] < b[k + 1] ? (b[k], b[k + 1]) : (b[k + 1], b[k]);
            uint128 liquidity = tokenIs0
                ? LiquidityAmounts.getLiquidityForAmount0(
                    TickMath.getSqrtPriceAtTick(lower), TickMath.getSqrtPriceAtTick(upper), amounts[k]
                )
                : LiquidityAmounts.getLiquidityForAmount1(
                    TickMath.getSqrtPriceAtTick(lower), TickMath.getSqrtPriceAtTick(upper), amounts[k]
                );
            actions = abi.encodePacked(actions, uint8(Actions.MINT_POSITION));
            // A zero max on the stock side enforces one-sidedness: any stock owed reverts the mint.
            params[k] = abi.encode(
                key,
                lower,
                upper,
                uint256(liquidity),
                tokenIs0 ? uint128(amounts[k]) : 0,
                tokenIs0 ? 0 : uint128(amounts[k]),
                address(this),
                bytes("")
            );
        }
        actions = abi.encodePacked(actions, uint8(Actions.SETTLE_PAIR));
        params[TRANCHES] = abi.encode(key.currency0, key.currency1);
        positionManager.modifyLiquidities(abi.encode(actions, params), block.timestamp);
    }

    // ------------------------------------------------------------------ fees

    /// @notice Collect accrued LP fees for a launch to `feeRecipient`. Callable by anyone.
    function collectFees(uint256 launchId) external returns (uint256 amount0, uint256 amount1) {
        Launch storage l = _launches[launchId];
        address recipient = feeRecipient;
        uint256 bal0Before = _balanceOf(l.poolKey.currency0, recipient);
        uint256 bal1Before = _balanceOf(l.poolKey.currency1, recipient);

        // A zero-liquidity decrease is a fee "poke" on each tranche position.
        bytes memory actions;
        bytes[] memory params = new bytes[](TRANCHES + 1);
        for (uint256 k = 0; k < TRANCHES; k++) {
            actions = abi.encodePacked(actions, uint8(Actions.DECREASE_LIQUIDITY));
            params[k] = abi.encode(l.firstPositionTokenId + k, uint256(0), uint128(0), uint128(0), bytes(""));
        }
        actions = abi.encodePacked(actions, uint8(Actions.TAKE_PAIR));
        params[TRANCHES] = abi.encode(l.poolKey.currency0, l.poolKey.currency1, recipient);
        positionManager.modifyLiquidities(abi.encode(actions, params), block.timestamp);

        amount0 = _balanceOf(l.poolKey.currency0, recipient) - bal0Before;
        amount1 = _balanceOf(l.poolKey.currency1, recipient) - bal1Before;
        emit FeesCollected(launchId, amount0, amount1);
    }

    // ---------------------------------------------------------------- rescue

    /// @notice Testing-phase escape hatch: transfer a launch's position NFTs out.
    /// Gone forever once disableRescue() is called.
    function rescue(uint256 launchId, address to) external onlyOwner {
        if (!rescueEnabled) revert RescueIsDisabled();
        uint256 first = _launches[launchId].firstPositionTokenId;
        for (uint256 k = 0; k < TRANCHES; k++) {
            IERC721(address(positionManager)).transferFrom(address(this), to, first + k);
            emit Rescued(launchId, first + k, to);
        }
    }

    /// @notice Permanently disable rescue. Irreversible — after this every LP NFT is locked for good.
    function disableRescue() external onlyOwner {
        rescueEnabled = false;
        emit RescueDisabled();
    }

    // ----------------------------------------------------------------- admin

    function setLaunchFee(uint256 fee) external onlyOwner {
        launchFee = fee;
        emit LaunchFeeSet(fee);
    }

    function setFeeRecipient(address recipient) external onlyOwner {
        feeRecipient = recipient;
        emit FeeRecipientSet(recipient);
    }

    function withdrawETH(address to) external onlyOwner {
        (bool ok,) = to.call{value: address(this).balance}("");
        require(ok, "ETH_TRANSFER_FAILED");
    }

    // ----------------------------------------------------------------- views

    function launchesCount() external view returns (uint256) {
        return _launches.length;
    }

    function getLaunch(uint256 launchId) external view returns (Launch memory) {
        return _launches[launchId];
    }

    // --------------------------------------------------------------- helpers

    function _balanceOf(Currency currency, address account) internal view returns (uint256) {
        return ERC20(Currency.unwrap(currency)).balanceOf(account);
    }

    /// @dev Solidity integer division truncates toward zero, so adjust explicitly per direction.
    function _alignUp(int24 tick, int24 spacing) internal pure returns (int24 aligned) {
        aligned = (tick / spacing) * spacing;
        if (aligned < tick) aligned += spacing;
    }

    function _alignDown(int24 tick, int24 spacing) internal pure returns (int24 aligned) {
        aligned = (tick / spacing) * spacing;
        if (aligned > tick) aligned -= spacing;
    }
}
Chain explorer4895msChain node90ms