PGM Raise Contract

This is the complete source code of the PGM Raise Contract (PGMRaise). For the mathematical solvency proof, see Mathematical Solvency Proof.

pragma solidity ^0.8.24;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";

interface ISacrificeProxy {
    function deposit(address user, uint256 pgmAmount, uint256 usdtAmount) external;
}

interface ITradingFeeProxy {
    function deposit(uint256 usdtAmount, uint256 pgmAmount) external;
}

interface IExcessUSDTProxy {
    function deposit(uint256 usdtAmount) external;
}

interface IReferralRegistry {
    function registerIfNew(address user, address referrer) external returns (address actualReferrer, bool wasNew);
}

interface IERC721Receiver {
    function onERC721Received(address operator, address from, uint256 tokenId, bytes calldata data) external returns (bytes4);
}

interface INonfungiblePositionManager {
    struct MintParams {
        address token0;
        address token1;
        int24 tickSpacing;
        int24 tickLower;
        int24 tickUpper;
        uint256 amount0Desired;
        uint256 amount1Desired;
        uint256 amount0Min;
        uint256 amount1Min;
        address recipient;
        uint256 deadline;
        uint160 sqrtPriceX96;
    }

    function mint(MintParams calldata params)
        external payable returns (uint256 tokenId, uint128 liquidity, uint256 amount0, uint256 amount1);

    struct CollectParams {
        uint256 tokenId;
        address recipient;
        uint128 amount0Max;
        uint128 amount1Max;
    }

    function collect(CollectParams calldata params)
        external returns (uint256 amount0, uint256 amount1);

    function positions(uint256 tokenId) external view returns (
        uint96 nonce, address operator, address token0, address token1,
        int24 tickSpacing, int24 tickLower, int24 tickUpper, uint128 liquidity,
        uint256 feeGrowthInside0LastX128, uint256 feeGrowthInside1LastX128,
        uint128 tokensOwed0, uint128 tokensOwed1
    );
}

interface ICLFactory {
    function getPool(address tokenA, address tokenB, int24 tickSpacing) external view returns (address);
    function createPool(address tokenA, address tokenB, int24 tickSpacing, uint160 sqrtPriceX96) external returns (address pool);
}

interface IPool {
    function slot0() external view returns (
        uint160 sqrtPriceX96, int24 tick, uint16 observationIndex,
        uint16 observationCardinality, uint16 observationCardinalityNext, bool unlocked
    );
    function swap(address recipient, bool zeroForOne, int256 amountSpecified,
        uint160 sqrtPriceLimitX96, bytes calldata data) external returns (int256 amount0, int256 amount1);
}

interface IPGMToken {
    function mint(address to, uint256 amount) external;
    function burn(uint256 amount) external;
    function renounceMinter() external;
}

contract PGMToken is ERC20 {
    address public minter;
    event MinterRenounced();

    constructor(address _minter, string memory _name, string memory _symbol) ERC20(_name, _symbol) {
        minter = _minter;
    }

    function mint(address to, uint256 amount) external {
        require(msg.sender == minter, "not minter");
        _mint(to, amount);
    }

    function burn(uint256 amount) external {
        _burn(msg.sender, amount);
    }

    function renounceMinter() external {
        require(msg.sender == minter, "not minter");
        minter = address(0);
        emit MinterRenounced();
    }
}

contract PGMRaise is IERC721Receiver {
    using SafeERC20 for IERC20;

    uint256 private _locked = 1;
    modifier nonReentrant() {
        require(_locked == 1, "reentrant");
        _locked = 2;
        _;
        _locked = 1;
    }

    address public constant POSITION_MANAGER = 0xa4890B89dC628baE614780079ACc951Fb0ECdC5F;
    address public constant CL_FACTORY = 0x8cfE21F272FdFDdf42851f6282c0f998756eEf27;
    address public constant USDT = 0x0709F39376dEEe2A2dfC94A58EdEb2Eb9DF012bD;

    INonfungiblePositionManager public constant posMgr = INonfungiblePositionManager(POSITION_MANAGER);
    ICLFactory public constant clFactory = ICLFactory(CL_FACTORY);

    address public immutable pgm;

    int24 public constant TICK_SPACING = 200;
    int24 public constant TICK_START_PRICE = 345400;       // ~$0.001 (floor)
    int24 public constant TICK_UPSIDE_START = 345200;      // one tick above floor (gap!)
    int24 public constant TICK_MAX = 887200;
    int24 public constant TICK_MIN = -887200;
    uint160 public constant SQRT_PRICE_AT_START = 2504784100835956094001232597242347520;

    uint256 public constant PGM_PER_USDT = 1e15;

    uint256 public immutable LAUNCH_TIME;
    uint256 public immutable EMERGENCY_DEADLINE;

    address public pool;
    uint256 public priceDiscoveryNFTId;

    bool public smokeTestPassed;
    bool public launched;
    bool public minterRenounced;
    uint256 public buybackReserve;

    uint256 public totalRaisedUSDT;
    uint256 public totalPGMAllocated;
    uint256 public totalPGMSacrificed;
    uint256 public totalPGMMinted;
    uint256 public totalPGMBurned;
    uint256 public priceDiscoveryPGMAmount;

    mapping(address => uint256) public invested;
    mapping(address => uint256) public allocation;
    mapping(address => uint256) public sacrificed;
    mapping(address => uint256) public claimed;

    address public immutable shopTreasuryProxy;
    address public immutable gameTreasuryProxy;
    address public immutable stakingTreasuryProxy;
    address public immutable badgeTreasuryProxy;
    address public immutable tradingFeeProxy;
    address public immutable excessUSDTProxy;
    address public immutable referralRegistry;

    bool private _inSwap;

    event Deposited(address indexed investor, uint256 usdt, uint256 pgm);
    event Sacrificed(address indexed investor, uint256 pgm);
    event Claimed(address indexed investor, uint256 pgm);
    event Launched();
    event Finalized(uint256 investorPGM, uint256 priceDiscoveryPGM);
    event Redeemed(address indexed user, uint256 pgmIn, uint256 usdtOut);
    event ExcessCollected(uint256 excess);
    event SmokeTestPassed();
    event TradingFeesCollected(uint256 usdt, uint256 pgm);
    event EmergencyWithdrawn(address indexed user, uint256 usdtReturned);

    constructor(
        uint256 _launchTime,
        uint256 _emergencyDeadline,
        string memory _tokenName,
        string memory _tokenSymbol,
        address _shopTreasuryProxy,
        address _gameTreasuryProxy,
        address _stakingTreasuryProxy,
        address _badgeTreasuryProxy,
        address _tradingFeeProxy,
        address _excessUSDTProxy,
        address _referralRegistry
    ) {
        require(_launchTime > block.timestamp, "launch must be future");
        require(_emergencyDeadline > _launchTime, "deadline must be after launch");
        require(_shopTreasuryProxy != address(0), "shopTreasuryProxy=0");
        require(_gameTreasuryProxy != address(0), "gameTreasuryProxy=0");
        require(_stakingTreasuryProxy != address(0), "stakingTreasuryProxy=0");
        require(_badgeTreasuryProxy != address(0), "badgeTreasuryProxy=0");
        require(_tradingFeeProxy != address(0), "tradingFeeProxy=0");
        require(_excessUSDTProxy != address(0), "excessUSDTProxy=0");
        require(_referralRegistry != address(0), "referralRegistry=0");

        LAUNCH_TIME = _launchTime;
        EMERGENCY_DEADLINE = _emergencyDeadline;
        shopTreasuryProxy = _shopTreasuryProxy;
        gameTreasuryProxy = _gameTreasuryProxy;
        stakingTreasuryProxy = _stakingTreasuryProxy;
        badgeTreasuryProxy = _badgeTreasuryProxy;
        tradingFeeProxy = _tradingFeeProxy;
        excessUSDTProxy = _excessUSDTProxy;
        referralRegistry = _referralRegistry;

        pgm = address(new PGMToken(address(this), _tokenName, _tokenSymbol));
        require(uint160(USDT) < uint160(pgm), "token order wrong, redeploy");

        pool = clFactory.createPool(USDT, pgm, TICK_SPACING, SQRT_PRICE_AT_START);
    }

    /// @notice Runs a complete lifecycle test with real USDT and real pool mechanics.
    ///         Tests: pool creation, LP mint, deposit, launch, finalize, claim, redeem (with swap),
    ///         and DEX buy simulation101. All inside try/catch so state is reverted.
    ///         Sets smokeTestPassed = true on success. Deposits require this flag.
    ///         Caller must have approved at least 10000 USDT_raw (0.01 USDT) to this contract.
    function runMechanicsSmokeTest() external nonReentrant {
        require(!smokeTestPassed, "already tested");
        require(pool != address(0), "pool not created"); // pool was created in constructor

        IERC20(USDT).safeTransferFrom(msg.sender, address(this), 10000);

        try this._lifecycleTest() {
            revert("lifecycle test should have reverted");
        } catch Error(string memory reason) {
            require(keccak256(bytes(reason)) == keccak256("LIFECYCLE_OK"), reason);
        } catch (bytes memory) {
            revert("smoke test: low-level revert");
        }

        IERC20(USDT).safeTransfer(msg.sender, 10000);

        smokeTestPassed = true;
        emit SmokeTestPassed();
    }

    /// @dev Mechanics smoke test. Called via try/catch — everything reverts.
    ///      Calls the REAL _*Core() functions to test actual business logic.
    ///      We have 10000 USDT_raw (0.01 USDT) from the caller.
    function _lifecycleTest() external {
        require(msg.sender == address(this), "only self");

        address W1 = address(uint160(0x1001));
        address W2 = address(uint160(0x1002));
        address W3 = address(uint160(0x1003));
        uint256 usdtInLP = 0; // tracks USDT sitting in the upside LP

        invested[W1] = 5000; allocation[W1] = 5000 * PGM_PER_USDT;
        invested[W2] = 3000; allocation[W2] = 3000 * PGM_PER_USDT;
        invested[W3] = 2000; allocation[W3] = 2000 * PGM_PER_USDT;
        totalRaisedUSDT = 10000;
        totalPGMAllocated = 10000 * PGM_PER_USDT;
        buybackReserve = 10000;

        (uint256 sacUsdt, uint256 sacPgm) = _sacrificeCore(W3, 1000 * PGM_PER_USDT);
        require(sacUsdt == 1000, "setup: sacrifice usdt wrong");
        require(sacPgm == 1000 * PGM_PER_USDT, "setup: sacrifice pgm wrong");
        require(buybackReserve == 9000, "setup: reserve wrong");

        _launchCore();
        require(launched, "setup: launch failed");
        _finalizeCore();
        require(minterRenounced, "setup: finalize failed");
        require(pool != address(0), "setup: pool not created");
        require(priceDiscoveryNFTId > 0, "setup: no NFT");
        _checkInvariant(usdtInLP, "after finalize");

        for (uint256 i = 1; i <= 5; i++) {
            uint256 amt = i * 1000000;
            require(amt * PGM_PER_USDT / PGM_PER_USDT == amt, "t1: roundtrip");
        }

        uint256 w1PGM = allocation[W1] - sacrificed[W1];
        uint256 w2PGM = allocation[W2] - sacrificed[W2];
        uint256 w3PGM = allocation[W3] - sacrificed[W3];
        require(w1PGM == 5000 * PGM_PER_USDT, "t2: W1 alloc wrong");
        require(w2PGM == 3000 * PGM_PER_USDT, "t2: W2 alloc wrong");
        require(w3PGM == 1000 * PGM_PER_USDT, "t2: W3 alloc wrong");

        _claimCore(W1, w1PGM, address(this));
        _claimCore(W2, w2PGM, address(this));
        _claimCore(W3, w3PGM, address(this));

        IERC20(pgm).approve(address(this), type(uint256).max);

        uint256 usdtBefore3 = IERC20(USDT).balanceOf(address(this));
        _redeemCore(address(this), w1PGM);
        uint256 w1Got = IERC20(USDT).balanceOf(address(this)) - usdtBefore3;
        require(w1Got == 5000, "t3: W1 did not get 5000 USDT back");
        _checkInvariant(usdtInLP, "after W1 redeem");

        uint256 usdtForBuy = 2000;
        _inSwap = true;
        (int256 b0, int256 b1) = IPool(pool).swap(
            address(this), true, int256(usdtForBuy), SQRT_PRICE_DEX_BUY_LIMIT, ""
        );
        _inSwap = false;
        require(b0 > 0 && b1 < 0, "t4: buy failed");
        uint256 pgmBoughtByTrader = uint256(-b1);
        uint256 usdtSpentOnBuy = uint256(b0);
        buybackReserve -= usdtSpentOnBuy;
        usdtInLP += usdtSpentOnBuy * 997 / 1000; // USDT went into LP minus 0.3% fee
        _checkInvariant(usdtInLP, "after DEX buy");

        uint256 usdtBefore5 = IERC20(USDT).balanceOf(address(this));
        _redeemCore(address(this), w2PGM);
        uint256 w2Got = IERC20(USDT).balanceOf(address(this)) - usdtBefore5;
        require(w2Got == 3000, "t5: W2 did not get 3000 USDT back");
        usdtInLP = 0;
        _checkInvariant(usdtInLP, "after W2 redeem");

        uint256 usdtBefore6 = IERC20(USDT).balanceOf(address(this));
        _redeemCore(address(this), w3PGM);
        uint256 w3Got = IERC20(USDT).balanceOf(address(this)) - usdtBefore6;
        require(w3Got == 1000, "t6: W3 did not get 1000 USDT back");
        _checkInvariant(usdtInLP, "after W3 redeem");

        uint256 traderUsdtExpected = pgmBoughtByTrader / PGM_PER_USDT;
        uint256 usdtBefore7 = IERC20(USDT).balanceOf(address(this));
        _redeemCore(address(this), pgmBoughtByTrader);
        uint256 traderGot = IERC20(USDT).balanceOf(address(this)) - usdtBefore7;
        require(traderGot == traderUsdtExpected, "t7: trader did not get floor price");
        _checkInvariant(usdtInLP, "after trader redeem");

        uint256 amountPGMneedToBurnInitially = totalPGMMinted - priceDiscoveryPGMAmount; // amountOfPGMInitiallyNeededToBeBurnableByStartingReserve , initally minted to invesetors
        uint256 circulating = amountPGMneedToBurnInitially > totalPGMBurned ? amountPGMneedToBurnInitially - totalPGMBurned : 0;
        uint256 usdtNeeded = (circulating + PGM_PER_USDT - 1) / PGM_PER_USDT;
        require(buybackReserve >= usdtNeeded, "t8: RESERVE UNDERFUNDED - CRITICAL");

        for (uint256 i = 1; i <= 5; i++) {
            uint256 amt = i * 1000000;
            require(amt * PGM_PER_USDT / PGM_PER_USDT == amt, "t10: roundtrip fail");
        }
        require((PGM_PER_USDT - 1) / PGM_PER_USDT == 0, "t10: dust should be 0");
        uint256 wantPGM = PGM_PER_USDT * 1000 + 1;
        uint256 usdtCost = (wantPGM + PGM_PER_USDT - 1) / PGM_PER_USDT;
        require(usdtCost == 1001, "t10: ceiling wrong");
        uint256 redeemWithDust = PGM_PER_USDT + 50;
        require(redeemWithDust - (redeemWithDust / PGM_PER_USDT * PGM_PER_USDT) == 50, "t10: dust calc");
        require(uint160(USDT) < uint160(pgm), "t10: token order wrong");

        uint256 w2SacUSDT = sacrificed[W2] / PGM_PER_USDT;
        uint256 w2EmReturn = invested[W2] > w2SacUSDT ? invested[W2] - w2SacUSDT : 0;
        require(w2EmReturn == 3000, "t11: W2 emergency math wrong");
        uint256 w3SacUSDT = sacrificed[W3] / PGM_PER_USDT;
        uint256 w3EmReturn = invested[W3] > w3SacUSDT ? invested[W3] - w3SacUSDT : 0;
        require(w3EmReturn == 1000, "t11: W3 emergency math wrong");

        revert("LIFECYCLE_OK");
    }

    /// @dev Test-only conservative helper used in smoke/scenario checks.
    ///      This is not the formal proof quantity K and not a live LP-state measurement.
    ///      It approximates R + K >= C using buybackReserve + usdtInLP >= ReserveForNonLPBackedPGMneeded().
    function _checkInvariant(uint256 usdtInLP, string memory step) internal view {
        if (!minterRenounced) return;
        require(buybackReserve + usdtInLP >= ReserveForNonLPBackedPGMneeded(),
            string.concat("INVARIANT VIOLATED at ", step));
    }

    function onERC721Received(address, address, uint256, bytes calldata)
        external override returns (bytes4)
    {
        return IERC721Receiver.onERC721Received.selector;
    }

    /// @notice Uniswap V3 swap callback — called by the pool during swap.
    ///         Handles selling PGM (redeem) and smoke test DEX-buy simulation.
    function uniswapV3SwapCallback(int256 amount0Delta, int256 amount1Delta, bytes calldata) external {
        if (pool != address(0)) {
            require(msg.sender == pool, "not the pool");
        } else {
            require(msg.sender == clFactory.getPool(USDT, pgm, TICK_SPACING), "not a valid pool");
        }
        require(_inSwap, "unexpected callback");
        if (amount0Delta > 0) {
            IERC20(USDT).safeTransfer(msg.sender, uint256(amount0Delta));
        }
        if (amount1Delta > 0) {
            IERC20(pgm).safeTransfer(msg.sender, uint256(amount1Delta));
        }
    }

    function deposit(uint256 usdtAmount) external nonReentrant {
        _depositChecks();
        _depositCore(msg.sender, msg.sender, usdtAmount, usdtAmount * PGM_PER_USDT);
        IReferralRegistry(referralRegistry).registerIfNew(msg.sender, address(0));
    }
    function deposit(uint256 usdtAmount, address referrer) external nonReentrant {
        _depositChecks();
        _depositCore(msg.sender, msg.sender, usdtAmount, usdtAmount * PGM_PER_USDT);
        IReferralRegistry(referralRegistry).registerIfNew(msg.sender, referrer);
    }

    /// @notice Deposit USDT on behalf of another wallet. USDT comes from msg.sender,
    ///         allocation goes to beneficiary. No referral is registered — the beneficiary
    ///         can set their own referrer by depositing themselves (even a tiny amount).
    function giveDeposit(address beneficiary, uint256 usdtAmount) external nonReentrant {
        _depositChecks();
        require(beneficiary != address(0), "beneficiary=0");
        _depositCore(msg.sender, beneficiary, usdtAmount, usdtAmount * PGM_PER_USDT);
    }

    function _depositChecks() internal view {
        require(smokeTestPassed, "smoke test not run");
        require(!launched, "launched");
        require(block.timestamp < LAUNCH_TIME, "raise ended");
    }
    function _depositCore(address payer, address beneficiary, uint256 usdtAmount, uint256 pgmAmount) internal {
        require(usdtAmount >= 1000, "min 0.001 USDT");
        IERC20(USDT).safeTransferFrom(payer, address(this), usdtAmount);

        invested[beneficiary] += usdtAmount;
        allocation[beneficiary] += pgmAmount;
        totalRaisedUSDT += usdtAmount;
        totalPGMAllocated += pgmAmount;
        buybackReserve += usdtAmount;

        emit Deposited(beneficiary, usdtAmount, pgmAmount);
    }

    function sacrificePGMAllocForShop(uint256 pgmAmount) external nonReentrant {
        _sacrifice(msg.sender, msg.sender, pgmAmount, shopTreasuryProxy);
    }
    function sacrificePGMAllocForGame(uint256 pgmAmount) external nonReentrant {
        _sacrifice(msg.sender, msg.sender, pgmAmount, gameTreasuryProxy);
    }
    function sacrificePGMAllocForStaking(uint256 pgmAmount) external nonReentrant {
        _sacrifice(msg.sender, msg.sender, pgmAmount, stakingTreasuryProxy);
    }
    function sacrificePGMAllocForBadge(uint256 pgmAmount) external nonReentrant {
        _sacrifice(msg.sender, msg.sender, pgmAmount, badgeTreasuryProxy);
    }

    function sacrificePGMAllocForShop(uint256 pgmAmount, address beneficiary) external nonReentrant {
        _sacrifice(msg.sender, beneficiary, pgmAmount, shopTreasuryProxy);
    }
    function sacrificePGMAllocForGame(uint256 pgmAmount, address beneficiary) external nonReentrant {
        _sacrifice(msg.sender, beneficiary, pgmAmount, gameTreasuryProxy);
    }
    function sacrificePGMAllocForStaking(uint256 pgmAmount, address beneficiary) external nonReentrant {
        _sacrifice(msg.sender, beneficiary, pgmAmount, stakingTreasuryProxy);
    }
    function sacrificePGMAllocForBadge(uint256 pgmAmount, address beneficiary) external nonReentrant {
        _sacrifice(msg.sender, beneficiary, pgmAmount, badgeTreasuryProxy);
    }

    function _sacrifice(address sacrificer, address beneficiary, uint256 pgmAmount, address target) internal {
        require(!launched, "launched");
        require(beneficiary != address(0), "beneficiary=0");
        (uint256 usdtFreed, uint256 pgmToSacrifice) = _sacrificeCore(sacrificer, pgmAmount);
        IERC20(USDT).safeTransfer(target, usdtFreed);
        ISacrificeProxy(target).deposit(beneficiary, pgmToSacrifice, usdtFreed);
        emit Sacrificed(sacrificer, pgmToSacrifice);
    }
    function _sacrificeCore(address sacrificer, uint256 pgmAmount) internal returns (uint256 usdtFreed, uint256 pgmToSacrifice) {
        usdtFreed = pgmAmount / PGM_PER_USDT;
        require(usdtFreed > 0, "amount too small");
        pgmToSacrifice = usdtFreed * PGM_PER_USDT;

        require(allocation[sacrificer] - sacrificed[sacrificer] >= pgmToSacrifice, "too much");
        sacrificed[sacrificer] += pgmToSacrifice;
        totalPGMSacrificed += pgmToSacrifice;

        require(usdtFreed <= buybackReserve, "not enough reserve");
        buybackReserve -= usdtFreed;
    }

    function launch() external nonReentrant {
        require(!launched, "already launched");
        require(block.timestamp >= LAUNCH_TIME, "too early");
        _launchCore();
    }
    function _launchCore() internal {
        launched = true;
        emit Launched();
    }

    function finalize() external nonReentrant {
        require(launched, "not launched");
        _finalizeCore();
    }
    function _finalizeCore() internal {
        require(!minterRenounced, "already finalized");
        require(buybackReserve > 0, "nothing raised");

        uint256 effectivePGM = totalPGMAllocated - totalPGMSacrificed;
        uint256 priceDiscoveryPGM = effectivePGM / 9;
        priceDiscoveryPGMAmount = priceDiscoveryPGM;

        uint256 totalToMint = effectivePGM + priceDiscoveryPGM;
        IPGMToken(pgm).mint(address(this), totalToMint);
        totalPGMMinted = totalToMint;

        address token0 = USDT;
        address token1 = pgm;

        require(pool != address(0), "pool not created");

        IERC20(pgm).approve(POSITION_MANAGER, priceDiscoveryPGM);
        {
            (uint256 upId,, uint256 upAmt0, uint256 upAmt1) = posMgr.mint(
                INonfungiblePositionManager.MintParams({
                    token0: token0, token1: token1, tickSpacing: TICK_SPACING,
                    tickLower: TICK_MIN, tickUpper: TICK_UPSIDE_START,
                    amount0Desired: 0, amount1Desired: priceDiscoveryPGM,
                    amount0Min: 0, amount1Min: 0,
                    recipient: address(this), deadline: block.timestamp + 600, sqrtPriceX96: 0
                })
            );
            priceDiscoveryNFTId = upId;
            require(upAmt0 == 0, "upside LP should have 0 USDT");
            require(upAmt1 > 0, "upside LP should have PGM");
        }
        IERC20(pgm).approve(POSITION_MANAGER, 0);

        IPGMToken(pgm).renounceMinter();
        minterRenounced = true;

        emit Finalized(effectivePGM, priceDiscoveryPGM);
    }

    function claim(uint256 amount, address to) external nonReentrant {
        _claimCore(msg.sender, amount, to);
    }
    function _claimCore(address user, uint256 amount, address to) internal {
        require(minterRenounced, "not finalized");
        require(to != address(0), "zero address");

        uint256 maxClaimable = allocation[user] - sacrificed[user] - claimed[user];
        require(amount > 0 && amount <= maxClaimable, "bad amount");

        claimed[user] += amount;
        IERC20(pgm).safeTransfer(to, amount);
        emit Claimed(user, amount);
    }

    function redeem(uint256 pgmAmount) external nonReentrant {
        _redeemCore(msg.sender, pgmAmount);
    }
    function _redeemCore(address user, uint256 pgmAmount) internal {
        require(minterRenounced, "not finalized");
        require(pgmAmount > 0, "zero");

        uint256 usdtOut = pgmAmount / PGM_PER_USDT;
        require(usdtOut > 0, "too small");
        require(IERC20(pgm).balanceOf(user) >= pgmAmount, "insufficient PGM");

        uint256 pgmForFloor = usdtOut * PGM_PER_USDT;
        uint256 dust = pgmAmount - pgmForFloor;

        IERC20(pgm).safeTransferFrom(user, address(this), pgmAmount);

        uint256 usdtFromSwap = 0;
        uint256 pgmSwapped = 0;

        if (pool != address(0)) {
            (, int24 currentTick,,,,) = IPool(pool).slot0();
            if (currentTick < TICK_START_PRICE) {
                uint256 usdtBefore = IERC20(USDT).balanceOf(address(this));
                uint256 pgmBefore = IERC20(pgm).balanceOf(address(this));

                _inSwap = true;
                IPool(pool).swap(
                    address(this), false, int256(pgmForFloor),
                    SQRT_PRICE_REDEEM_LIMIT, ""
                );
                _inSwap = false;

                usdtFromSwap = IERC20(USDT).balanceOf(address(this)) - usdtBefore;
                pgmSwapped = pgmBefore - IERC20(pgm).balanceOf(address(this));
            }
        }

        uint256 pgmUnswapped = pgmForFloor - pgmSwapped;
        if (pgmUnswapped == 0) {
            require(usdtFromSwap >= usdtOut, "lp swap below floor");
        }
        uint256 usdtFromReserve = 0;
        if (usdtFromSwap < usdtOut) {
            usdtFromReserve = usdtOut - usdtFromSwap;
            require(usdtFromReserve <= buybackReserve, "exceeds reserve");
        }

        uint256 usdtSurplus = usdtFromSwap > usdtOut ? usdtFromSwap - usdtOut : 0;

        if (pgmUnswapped > 0) { totalPGMBurned += pgmUnswapped; }
        if (usdtFromReserve > 0) { buybackReserve -= usdtFromReserve; }

        if (pgmUnswapped > 0) { IPGMToken(pgm).burn(pgmUnswapped); }

        // LP surplus goes directly to ecosystem proxy — never touches the reserve
        if (usdtSurplus > 0) {
            IERC20(USDT).safeTransfer(excessUSDTProxy, usdtSurplus);
            IExcessUSDTProxy(excessUSDTProxy).deposit(usdtSurplus);
            emit ExcessCollected(usdtSurplus);
        }

        IERC20(USDT).safeTransfer(user, usdtOut);
        if (dust > 0) { IERC20(pgm).safeTransfer(user, dust); }

        emit Redeemed(user, pgmAmount, usdtOut);
    }

    /// @dev Redeem only LP-settles while price remains above the guaranteed floor plus pool fee.
    ///      Redeem-side swap limit corresponding to approximately floor / (1 - fee).
    uint160 public constant SQRT_PRICE_REDEEM_LIMIT = 2500404780644603868291526977843918101;

    /// @dev Swap limit for smoke test DEX-buy simulation only.
    ///      Corresponds to approximately floor * (1 - fee).
    uint160 public constant SQRT_PRICE_DEX_BUY_LIMIT = 2510551445809845255360469847357648927;

    // Excess is forwarded directly in redeem() — never accumulates in reserve.

    function collectTradingFees() external nonReentrant {
        _collectTradingFeesCore();
    }
    /// @dev Trading fees from the upside LP are ecosystem earnings.
    ///      Fee collection is claim-neutral: collectible redeemable PGM fees are
    ///      already part of the outstanding claim system before collection.
    ///      This function only changes custody and does not change buybackReserve.
    function _collectTradingFeesCore() internal {
        require(minterRenounced, "not finalized");
        require(priceDiscoveryNFTId != 0, "no NFT");

        uint256 usdtBefore = IERC20(USDT).balanceOf(address(this));
        uint256 pgmBefore = IERC20(pgm).balanceOf(address(this));

        posMgr.collect(INonfungiblePositionManager.CollectParams({
            tokenId: priceDiscoveryNFTId,
            recipient: address(this),
            amount0Max: type(uint128).max,
            amount1Max: type(uint128).max
        }));

        uint256 usdtCollected = IERC20(USDT).balanceOf(address(this)) - usdtBefore;
        uint256 pgmCollected = IERC20(pgm).balanceOf(address(this)) - pgmBefore;

        if (usdtCollected > 0) { IERC20(USDT).safeTransfer(tradingFeeProxy, usdtCollected); }
        if (pgmCollected > 0) { IERC20(pgm).safeTransfer(tradingFeeProxy, pgmCollected); }
        if (usdtCollected > 0 || pgmCollected > 0) {
            ITradingFeeProxy(tradingFeeProxy).deposit(usdtCollected, pgmCollected);
        }

        emit TradingFeesCollected(usdtCollected, pgmCollected);
    }

    function emergencyWithdraw() external nonReentrant {
        require(block.timestamp >= EMERGENCY_DEADLINE, "too early");
        _emergencyWithdrawCore(msg.sender);
    }
    function _emergencyWithdrawCore(address user) internal {
        require(!minterRenounced, "already finalized");
        require(claimed[user] == 0, "already claimed"); // safety: should never happen (claim needs finalized)

        uint256 sacUSDT = sacrificed[user] / PGM_PER_USDT;
        uint256 usdtToReturn = invested[user] > sacUSDT ? invested[user] - sacUSDT : 0;
        require(usdtToReturn > 0, "nothing to withdraw");

        uint256 inv = invested[user];
        uint256 alloc = allocation[user];
        uint256 sac = sacrificed[user];

        invested[user] = 0;
        allocation[user] = 0;
        sacrificed[user] = 0;

        totalRaisedUSDT -= inv;
        totalPGMAllocated -= alloc;
        totalPGMSacrificed -= sac;

        require(usdtToReturn <= buybackReserve, "insufficient reserve");
        buybackReserve -= usdtToReturn;

        IERC20(USDT).safeTransfer(user, usdtToReturn);
        emit EmergencyWithdrawn(user, usdtToReturn);
    }

    /// @notice How many non-LP-backed PGM are still circulating.
    ///
    /// WHY THIS NOT COUNTS IN LP PGM:
    ///
    /// This function intentionally excludes priceDiscoveryPGMAmount (the PGM placed in the
    /// upside LP at finalize). This is NOT a bug — it is the core design of the raise-contract.
    ///
    /// When someone buys PGM out of the upside LP, they pay USDT INTO the LP. That USDT
    /// stays in the LP as principal. If the buyer later redeems, redeem() FIRST sells the
    /// PGM back into the LP (via pool.swap), recovering that USDT. Only the remainder (if any)
    /// comes from buybackReserve.
    ///
    /// Therefore, LP-origin PGM does NOT create a liability on buybackReserve. The USDT to
    /// cover it sits in the LP, not in the reserve. ReserveForNonLPBackedPGMneeded() only needs to track
    /// PGM whose floor exit depends on the reserve.
    ///
    /// This function intentionally tracks only non-LP-backed redeem claims.
    /// LP-origin movements and fee custody effects are handled separately
    /// and are neutral with respect to solvency.
    ///
    /// Trading-fee PGM is excluded from reserve-only accounting here because
    /// this function tracks only non-LP-backed claims whose floor settlement
    /// depends on the reserve (see proof: fee neutrality, L5).
    ///PGMamountThatNeedToBeBurnableByReserve
    /// See also: redeem() sells into LP first, then uses reserve for the remainder.
    function PGMamountThatNeedToBeBurnableByReserve() public view returns (uint256) {
        if (!minterRenounced) return 0;
        uint256 amountPGMneedToBurnInitially = totalPGMMinted - priceDiscoveryPGMAmount;
        return totalPGMBurned >= amountPGMneedToBurnInitially ? 0 : amountPGMneedToBurnInitially - totalPGMBurned;
    }

    /// @notice How much USDT the reserve needs to cover all circulating non-LP-backed PGM at floor price.
    ///         This is reserve-only, ignoring LP-side USDT.
    function ReserveForNonLPBackedPGMneeded() public view returns (uint256) {
        uint256 circulating = PGMamountThatNeedToBeBurnableByReserve();
        return (circulating + PGM_PER_USDT - 1) / PGM_PER_USDT; // ceiling
    }

    function pendingFees() external view returns (uint256 usdtFees, uint256 pgmFees) {
        if (priceDiscoveryNFTId != 0) {
            (,,,,,,,, ,, uint128 owed0, uint128 owed1) = posMgr.positions(priceDiscoveryNFTId);
            usdtFees = uint256(owed0);
            pgmFees = uint256(owed1);
        }
    }

    function claimable(address investor) external view returns (uint256) {
        if (!minterRenounced) return 0;
        return allocation[investor] - sacrificed[investor] - claimed[investor];
    }

    function projectedMaxSupply() external view returns (uint256) {
        return totalPGMAllocated - totalPGMSacrificed + (totalPGMAllocated - totalPGMSacrificed) / 9;
    }

    function stats() external view returns (
        uint256 raised, uint256 allocated, uint256 sacr, uint256 reserve, bool live
    ) {
        return (totalRaisedUSDT, totalPGMAllocated, totalPGMSacrificed, buybackReserve, launched);
    }

    /// @notice Returns true if the reserve alone covers all circulating PGM at floor price.
    ///         This is the conservative solvency check (ignores LP-side USDT).
    function solvencyHoldsReserveOnly() external view returns (bool) {
        return buybackReserve >= ReserveForNonLPBackedPGMneeded();
    }

    /// @notice Returns true if the given PGM amount can be redeemed from reserve alone.
    function redeemableFromReserve(uint256 pgmAmount) external view returns (bool) {
        uint256 usdtOut = pgmAmount / PGM_PER_USDT;
        return usdtOut > 0 && usdtOut <= buybackReserve;
    }

    /// @notice Returns the current lifecycle phase.
    ///         0 = raising, 1 = launched (waiting for finalize), 2 = finalized (live)
    function phase() external view returns (uint8) {
        if (minterRenounced) return 2;
        if (launched) return 1;
        return 0;
    }
}