diff --git a/.openzeppelin/bsc-testnet.json b/.openzeppelin/bsc-testnet.json index 57ffa65e..aa4b7d17 100644 --- a/.openzeppelin/bsc-testnet.json +++ b/.openzeppelin/bsc-testnet.json @@ -48,7 +48,6 @@ }, { "address": "0x272b5917d81b0085fb0F9DedfE883928eB226CFC", - "txHash": "0x303e4f7680bd9e23fa043b387f8d7839f6c9674a82d58b120c007819e1030d51", "kind": "transparent" } ], @@ -2513,6 +2512,530 @@ ] } } + }, + "57bf9084ddfe16fc41c31606f669785bbcde50bac493c8aeddaa56149fc168c4": { + "address": "0xAaA896c97a2d3c5C14a2A99CcAaF51F17f73C4bC", + "layout": { + "solcVersion": "0.8.26", + "storage": [ + { + "label": "factory", + "offset": 0, + "slot": "0", + "type": "t_contract(IFFactoryV2Minimal)9886", + "contract": "BondingV5", + "src": "contracts/launchpadv2/BondingV5.sol:95" + }, + { + "label": "router", + "offset": 0, + "slot": "1", + "type": "t_contract(IFRouterV3Minimal)9948", + "contract": "BondingV5", + "src": "contracts/launchpadv2/BondingV5.sol:96" + }, + { + "label": "agentFactory", + "offset": 0, + "slot": "2", + "type": "t_contract(IAgentFactoryV7Minimal)10013", + "contract": "BondingV5", + "src": "contracts/launchpadv2/BondingV5.sol:97" + }, + { + "label": "bondingConfig", + "offset": 0, + "slot": "3", + "type": "t_contract(BondingConfig)9848", + "contract": "BondingV5", + "src": "contracts/launchpadv2/BondingV5.sol:98" + }, + { + "label": "tokenInfo", + "offset": 0, + "slot": "4", + "type": "t_mapping(t_address,t_struct(Token)9139_storage)", + "contract": "BondingV5", + "src": "contracts/launchpadv2/BondingV5.sol:100" + }, + { + "label": "tokenInfos", + "offset": 0, + "slot": "5", + "type": "t_array(t_address)dyn_storage", + "contract": "BondingV5", + "src": "contracts/launchpadv2/BondingV5.sol:101" + }, + { + "label": "tokenLaunchParams", + "offset": 0, + "slot": "6", + "type": "t_mapping(t_address,t_struct(LaunchParams)9150_storage)", + "contract": "BondingV5", + "src": "contracts/launchpadv2/BondingV5.sol:107" + }, + { + "label": "tokenGradThreshold", + "offset": 0, + "slot": "7", + "type": "t_mapping(t_address,t_uint256)", + "contract": "BondingV5", + "src": "contracts/launchpadv2/BondingV5.sol:110" + }, + { + "label": "isFeeDelegation", + "offset": 0, + "slot": "8", + "type": "t_mapping(t_address,t_bool)", + "contract": "BondingV5", + "src": "contracts/launchpadv2/BondingV5.sol:112" + }, + { + "label": "tokenPreLaunchExtParams", + "offset": 0, + "slot": "9", + "type": "t_mapping(t_address,t_bytes_storage)", + "contract": "BondingV5", + "src": "contracts/launchpadv2/BondingV5.sol:115" + }, + { + "label": "tokenFakeInitialVirtualLiq", + "offset": 0, + "slot": "10", + "type": "t_mapping(t_address,t_uint256)", + "contract": "BondingV5", + "src": "contracts/launchpadv2/BondingV5.sol:120" + }, + { + "label": "isRobotics", + "offset": 0, + "slot": "11", + "type": "t_mapping(t_address,t_bool)", + "contract": "BondingV5", + "src": "contracts/launchpadv2/BondingV5.sol:124" + }, + { + "label": "feeDelegationType", + "offset": 0, + "slot": "12", + "type": "t_mapping(t_address,t_uint8)", + "contract": "BondingV5", + "src": "contracts/launchpadv2/BondingV5.sol:128" + }, + { + "label": "feeDelegationRecipient", + "offset": 0, + "slot": "13", + "type": "t_mapping(t_address,t_bytes32)", + "contract": "BondingV5", + "src": "contracts/launchpadv2/BondingV5.sol:135" + } + ], + "types": { + "t_address": { + "label": "address", + "numberOfBytes": "20" + }, + "t_bool": { + "label": "bool", + "numberOfBytes": "1" + }, + "t_struct(InitializableStorage)443_storage": { + "label": "struct Initializable.InitializableStorage", + "members": [ + { + "label": "_initialized", + "type": "t_uint64", + "offset": 0, + "slot": "0" + }, + { + "label": "_initializing", + "type": "t_bool", + "offset": 8, + "slot": "0" + } + ], + "numberOfBytes": "32" + }, + "t_struct(OwnableStorage)151_storage": { + "label": "struct OwnableUpgradeable.OwnableStorage", + "members": [ + { + "label": "_owner", + "type": "t_address", + "offset": 0, + "slot": "0" + } + ], + "numberOfBytes": "32" + }, + "t_struct(ReentrancyGuardStorage)582_storage": { + "label": "struct ReentrancyGuardUpgradeable.ReentrancyGuardStorage", + "members": [ + { + "label": "_status", + "type": "t_uint256", + "offset": 0, + "slot": "0" + } + ], + "numberOfBytes": "32" + }, + "t_uint256": { + "label": "uint256", + "numberOfBytes": "32" + }, + "t_uint64": { + "label": "uint64", + "numberOfBytes": "8" + }, + "t_array(t_address)dyn_storage": { + "label": "address[]", + "numberOfBytes": "32" + }, + "t_array(t_uint8)dyn_storage": { + "label": "uint8[]", + "numberOfBytes": "32" + }, + "t_bytes32": { + "label": "bytes32", + "numberOfBytes": "32" + }, + "t_bytes_storage": { + "label": "bytes", + "numberOfBytes": "32" + }, + "t_contract(BondingConfig)9848": { + "label": "contract BondingConfig", + "numberOfBytes": "20" + }, + "t_contract(IAgentFactoryV7Minimal)10013": { + "label": "contract IAgentFactoryV7Minimal", + "numberOfBytes": "20" + }, + "t_contract(IFFactoryV2Minimal)9886": { + "label": "contract IFFactoryV2Minimal", + "numberOfBytes": "20" + }, + "t_contract(IFRouterV3Minimal)9948": { + "label": "contract IFRouterV3Minimal", + "numberOfBytes": "20" + }, + "t_mapping(t_address,t_bool)": { + "label": "mapping(address => bool)", + "numberOfBytes": "32" + }, + "t_mapping(t_address,t_bytes32)": { + "label": "mapping(address => bytes32)", + "numberOfBytes": "32" + }, + "t_mapping(t_address,t_bytes_storage)": { + "label": "mapping(address => bytes)", + "numberOfBytes": "32" + }, + "t_mapping(t_address,t_struct(LaunchParams)9150_storage)": { + "label": "mapping(address => struct BondingConfig.LaunchParams)", + "numberOfBytes": "32" + }, + "t_mapping(t_address,t_struct(Token)9139_storage)": { + "label": "mapping(address => struct BondingConfig.Token)", + "numberOfBytes": "32" + }, + "t_mapping(t_address,t_uint256)": { + "label": "mapping(address => uint256)", + "numberOfBytes": "32" + }, + "t_mapping(t_address,t_uint8)": { + "label": "mapping(address => uint8)", + "numberOfBytes": "32" + }, + "t_string_storage": { + "label": "string", + "numberOfBytes": "32" + }, + "t_struct(Data)9100_storage": { + "label": "struct BondingConfig.Data", + "members": [ + { + "label": "token", + "type": "t_address", + "offset": 0, + "slot": "0" + }, + { + "label": "name", + "type": "t_string_storage", + "offset": 0, + "slot": "1" + }, + { + "label": "_name", + "type": "t_string_storage", + "offset": 0, + "slot": "2" + }, + { + "label": "ticker", + "type": "t_string_storage", + "offset": 0, + "slot": "3" + }, + { + "label": "supply", + "type": "t_uint256", + "offset": 0, + "slot": "4" + }, + { + "label": "price", + "type": "t_uint256", + "offset": 0, + "slot": "5" + }, + { + "label": "marketCap", + "type": "t_uint256", + "offset": 0, + "slot": "6" + }, + { + "label": "liquidity", + "type": "t_uint256", + "offset": 0, + "slot": "7" + }, + { + "label": "volume", + "type": "t_uint256", + "offset": 0, + "slot": "8" + }, + { + "label": "volume24H", + "type": "t_uint256", + "offset": 0, + "slot": "9" + }, + { + "label": "prevPrice", + "type": "t_uint256", + "offset": 0, + "slot": "10" + }, + { + "label": "lastUpdated", + "type": "t_uint256", + "offset": 0, + "slot": "11" + } + ], + "numberOfBytes": "384" + }, + "t_struct(LaunchParams)9150_storage": { + "label": "struct BondingConfig.LaunchParams", + "members": [ + { + "label": "launchMode", + "type": "t_uint8", + "offset": 0, + "slot": "0" + }, + { + "label": "airdropBips", + "type": "t_uint16", + "offset": 1, + "slot": "0" + }, + { + "label": "needAcf", + "type": "t_bool", + "offset": 3, + "slot": "0" + }, + { + "label": "antiSniperTaxType", + "type": "t_uint8", + "offset": 4, + "slot": "0" + }, + { + "label": "isProject60days", + "type": "t_bool", + "offset": 5, + "slot": "0" + } + ], + "numberOfBytes": "32" + }, + "t_struct(Token)9139_storage": { + "label": "struct BondingConfig.Token", + "members": [ + { + "label": "creator", + "type": "t_address", + "offset": 0, + "slot": "0" + }, + { + "label": "token", + "type": "t_address", + "offset": 0, + "slot": "1" + }, + { + "label": "pair", + "type": "t_address", + "offset": 0, + "slot": "2" + }, + { + "label": "agentToken", + "type": "t_address", + "offset": 0, + "slot": "3" + }, + { + "label": "data", + "type": "t_struct(Data)9100_storage", + "offset": 0, + "slot": "4" + }, + { + "label": "description", + "type": "t_string_storage", + "offset": 0, + "slot": "16" + }, + { + "label": "cores", + "type": "t_array(t_uint8)dyn_storage", + "offset": 0, + "slot": "17" + }, + { + "label": "image", + "type": "t_string_storage", + "offset": 0, + "slot": "18" + }, + { + "label": "twitter", + "type": "t_string_storage", + "offset": 0, + "slot": "19" + }, + { + "label": "telegram", + "type": "t_string_storage", + "offset": 0, + "slot": "20" + }, + { + "label": "youtube", + "type": "t_string_storage", + "offset": 0, + "slot": "21" + }, + { + "label": "website", + "type": "t_string_storage", + "offset": 0, + "slot": "22" + }, + { + "label": "trading", + "type": "t_bool", + "offset": 0, + "slot": "23" + }, + { + "label": "tradingOnUniswap", + "type": "t_bool", + "offset": 1, + "slot": "23" + }, + { + "label": "applicationId", + "type": "t_uint256", + "offset": 0, + "slot": "24" + }, + { + "label": "initialPurchase", + "type": "t_uint256", + "offset": 0, + "slot": "25" + }, + { + "label": "virtualId", + "type": "t_uint256", + "offset": 0, + "slot": "26" + }, + { + "label": "launchExecuted", + "type": "t_bool", + "offset": 0, + "slot": "27" + } + ], + "numberOfBytes": "896" + }, + "t_uint16": { + "label": "uint16", + "numberOfBytes": "2" + }, + "t_uint8": { + "label": "uint8", + "numberOfBytes": "1" + } + }, + "namespaces": { + "erc7201:openzeppelin.storage.Ownable": [ + { + "contract": "OwnableUpgradeable", + "label": "_owner", + "type": "t_address", + "src": "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol:24", + "offset": 0, + "slot": "0" + } + ], + "erc7201:openzeppelin.storage.ReentrancyGuard": [ + { + "contract": "ReentrancyGuardUpgradeable", + "label": "_status", + "type": "t_uint256", + "src": "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardUpgradeable.sol:40", + "offset": 0, + "slot": "0" + } + ], + "erc7201:openzeppelin.storage.Initializable": [ + { + "contract": "Initializable", + "label": "_initialized", + "type": "t_uint64", + "src": "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol:69", + "offset": 0, + "slot": "0" + }, + { + "contract": "Initializable", + "label": "_initializing", + "type": "t_bool", + "src": "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol:73", + "offset": 8, + "slot": "0" + } + ] + } + }, + "allAddresses": [ + "0xAaA896c97a2d3c5C14a2A99CcAaF51F17f73C4bC", + "0x25a5b4D651ad40B0280f28Aae1aE9123C6082f49" + ] } } } diff --git a/contracts/launchpadv2/BondingV5.sol b/contracts/launchpadv2/BondingV5.sol index 431ce3ca..be06d637 100644 --- a/contracts/launchpadv2/BondingV5.sol +++ b/contracts/launchpadv2/BondingV5.sol @@ -114,11 +114,26 @@ contract BondingV5 is /// @notice Raw `extParams_` from `preLaunch` (per agent token). Backend can read as canonical payload; not updated by `setFeeDelegation`. mapping(address => bytes) public tokenPreLaunchExtParams; - /// @dev Appended in upgrade v2 — must remain last state variable (append-only layout). + /// @dev Appended in upgrade v2 (append-only layout). /// Fake initial virtual liquidity frozen at preLaunch for migration price continuity. /// If unset (pre-upgrade token), graduation uses BondingConfig legacyInitialVirtualLiq. mapping(address => uint256) public tokenFakeInitialVirtualLiq; + /// @dev Appended in upgrade v3 — decoded from `extParams_` at preLaunch (append-only layout). + /// Robotics flag (bit 2 of the flags word). See extParams encoding notes above `preLaunch`. + mapping(address => bool) public isRobotics; + + /// @notice Fee delegation type per token: 0 = none, 1 = address, 2 = twitter + /// (decoded from bits 3-4 of the `extParams_` flags word). + mapping(address => uint8) public feeDelegationType; + + /// @notice Fee delegation recipient from the `extParams_` trailing word, stored as a fixed + /// `bytes32` right-aligned integer: the recipient address as `uint160` for type 1, or + /// the twitter account id as `uint64` for type 2 (the vault is only created just before + /// `launch`). `bytes32(0)` when no delegation. Fixed-size to save gas vs dynamic bytes. + /// @dev MUST remain the last state variable (append-only layout). + mapping(address => bytes32) public feeDelegationRecipient; + event PreLaunched( address indexed token, address indexed pair, @@ -144,6 +159,36 @@ contract BondingV5 is event FeeDelegationUpdated(address indexed token, bool isFeeDelegation); + /// @notice Emitted at preLaunch with the extended launch settings decoded from `extParams_`. + event PreLaunchExtParams( + address indexed token, + bool isFeeDelegation, + bool isRobotics, + uint8 feeDelegationType, + bytes32 feeDelegationRecipient + ); + + event TokenCreated( + uint256 virtualId, + address indexed creator, + address indexed token, + string name, + string symbol, + uint256 maxSupply, + uint256 saleAmount, + uint256 graduationThreshold, + uint256 initialVirtualLiquidity, + uint256 initialPrice, + uint256 initialPurchase, + address quoteAsset, + address pair, + string description, + string image, + BondingConfig.LaunchParams launchParams, + uint256 startTime, + uint256 startTimeDelay + ); + error InvalidTokenStatus(); error InvalidInput(); error SlippageTooHigh(); @@ -157,12 +202,38 @@ contract BondingV5 is _disableInitializers(); } - /// @dev `extParams` first 32-byte word — flags in the LSB (same byte as `abi.encode(bool)`): - /// bit 0: `isFeeDelegation` - /// bit 1: skip `" by Virtuals"` suffix (unset ⇒ append; V1-compatible) - /// Empty `extParams` or length < 32 ⇒ both flags false ⇒ fee delegation off, suffix on. + /// @dev `extParams` encoding — append-only and length-tolerant so it stays backward + /// compatible and can be freely extended: + /// + /// Word 0 (`extParams[0:32]`) is a flags bitfield (same LSB byte as `abi.encode(bool)`): + /// bit 0: `isFeeDelegation` + /// bit 1: skip `" by Virtuals"` suffix (unset ⇒ append; V1-compatible) + /// bit 2: `isRobotics` + /// bits 3-4: `feeDelegationType` (0 = none, 1 = address, 2 = twitter) + /// remaining bits: reserved = 0, available for future flags/enums. + /// + /// Trailing word (`extParams[32:64]`, present only when needed) carries the + /// `feeDelegationRecipient` as a fixed `bytes32`, ABI-encoded as + /// `abi.encode(uint256 flags, bytes32 recipient)` with the recipient a right-aligned integer: + /// - type 1 (address): the recipient address as `uint160` + /// - type 2 (twitter): the twitter account id as `uint64` + /// The recipient is opaque to this contract (stored/emitted as-is); off-chain decodes by type. + /// Future fields are appended after this word and decoded defensively by length. + /// + /// Rules that MUST hold for every future change: + /// - Never repurpose or move an existing bit/offset (bit 0 / bit 1 semantics are frozen). + /// - Decoders tolerate shorter and longer payloads: read a field only when the length + /// covers it, otherwise use the default; ignore trailing bytes / higher bits. + /// + /// Empty `extParams` or length < 32 ⇒ all flags false ⇒ fee delegation off, suffix on, + /// robotics off, no delegation type/recipient (identical to the pre-v3 behaviour). uint256 private constant EXT_PARAMS_FLAG_FEE_DELEGATION = 1; uint256 private constant EXT_PARAMS_FLAG_SKIP_SUFFIX = 2; + uint256 private constant EXT_PARAMS_FLAG_ROBOTICS = 4; // bit 2 + // `feeDelegationType` occupies bits 3-4 of the flags word: shift right by 3 to bring + // those bits down, then mask with 0x3 (2 bits) to isolate them (0 = none, 1 = address, 2 = twitter). + uint256 private constant EXT_PARAMS_FEE_DELEGATION_TYPE_SHIFT = 3; + uint256 private constant EXT_PARAMS_FEE_DELEGATION_TYPE_MASK = 0x3; function _loadExtParamsWord( bytes calldata extParams @@ -187,6 +258,38 @@ contract BondingV5 is return (_loadExtParamsWord(extParams) & EXT_PARAMS_FLAG_SKIP_SUFFIX) == 0; } + function _decodeIsRobotics( + bytes calldata extParams + ) internal pure returns (bool) { + return (_loadExtParamsWord(extParams) & EXT_PARAMS_FLAG_ROBOTICS) != 0; + } + + function _decodeFeeDelegationType( + bytes calldata extParams + ) internal pure returns (uint8) { + return + uint8( + (_loadExtParamsWord(extParams) >> + EXT_PARAMS_FEE_DELEGATION_TYPE_SHIFT) & + EXT_PARAMS_FEE_DELEGATION_TYPE_MASK + ); + } + + /// @dev Reads the trailing `feeDelegationRecipient` word (2nd word of + /// `abi.encode(uint256 flags, bytes32 recipient)`) when present. Length-gated so legacy + /// flags-only payloads (length < 64) return `bytes32(0)`, and future longer payloads + /// (extra words appended after the recipient) still decode this word safely. + function _decodeFeeDelegationRecipient( + bytes calldata extParams + ) internal pure returns (bytes32 recipient) { + if (extParams.length < 64) { + return bytes32(0); + } + assembly ("memory-safe") { + recipient := calldataload(add(extParams.offset, 32)) + } + } + function initialize( address factory_, address router_, @@ -202,8 +305,11 @@ contract BondingV5 is bondingConfig = BondingConfig(bondingConfig_); } - /// @param extParams_ Optional extension payload (first 32 bytes). V1: `abi.encode(bool isFeeDelegation)`. - /// V2: set bit 1 of the word to skip `" by Virtuals"` (bit 1 unset ⇒ append). Use `""` for defaults. + /// @param extParams_ Optional extension payload. See the extParams encoding notes above for the + /// full (append-only, backward-compatible) layout. V1: `abi.encode(bool isFeeDelegation)`. + /// V2: set bit 1 of the flags word to skip `" by Virtuals"`. V3: bit 2 = `isRobotics`, + /// bits 3-4 = `feeDelegationType`, with the optional `feeDelegationRecipient` appended as + /// `abi.encode(uint256 flags, bytes recipient)`. Use `""` for defaults. function preLaunch( string memory name_, string memory ticker_, @@ -403,6 +509,14 @@ contract BondingV5 is isFeeDelegation[token] = isFeeDelegation_; tokenPreLaunchExtParams[token] = extParams_; + // Decode and store the extended launch settings so on-chain is the source of truth. + bool isRobotics_ = _decodeIsRobotics(extParams_); + uint8 feeDelegationType_ = _decodeFeeDelegationType(extParams_); + bytes32 feeDelegationRecipient_ = _decodeFeeDelegationRecipient(extParams_); + isRobotics[token] = isRobotics_; + feeDelegationType[token] = feeDelegationType_; + feeDelegationRecipient[token] = feeDelegationRecipient_; + // Set Data struct fields newToken.data.token = token; newToken.data.name = tokenName; @@ -435,6 +549,36 @@ contract BondingV5 is tokenLaunchParams[token] ); + emit PreLaunchExtParams( + token, + isFeeDelegation_, + isRobotics_, + feeDelegationType_, + feeDelegationRecipient_ + ); + + // Net quote target at the graduation threshold (excludes buy tax / anti-sniper fees). + emit TokenCreated( + tokenInfo[token].virtualId, + msg.sender, + token, + name_, + ticker_, + configInitialSupply * (10 ** IAgentTokenV4(token).decimals()), + bondingCurveSupply, + gradThreshold, + liquidity * 2, + price, + initialPurchase, + assetToken, + pair, + desc_, + img_, + tokenLaunchParams[token], + actualStartTime, + actualStartTimeDelay + ); + return (token, pair, tokenInfo[token].virtualId, initialPurchase); } diff --git a/contracts/launchpadv2/FRouterV3.sol b/contracts/launchpadv2/FRouterV3.sol index e26b4c1b..78cecf6a 100644 --- a/contracts/launchpadv2/FRouterV3.sol +++ b/contracts/launchpadv2/FRouterV3.sol @@ -53,6 +53,18 @@ contract FRouterV3 is IBondingV5ForRouter public bondingV5; IBondingConfigForRouter public bondingConfig; + struct TradeEventData { + address token; + address trader; + address pair; + bool isBuy; + uint256 amountIn; + uint256 amountOut; + uint256 amount; + uint256 taxFee; + uint256 antiSniperFee; + } + event PrivatePoolDrained( address indexed token, address indexed recipient, @@ -67,6 +79,22 @@ contract FRouterV3 is uint256 veTokenAmount ); + event TradeExecuted( + address indexed token, + address indexed trader, + address indexed pair, + bool isBuy, + address quoteAsset, + uint256 amountIn, + uint256 amountOut, + uint256 amount, + uint256 taxFee, + uint256 antiSniperFee, + uint256 reserveTokenAfter, + uint256 reserveAssetAfter, + uint256 lastPrice + ); + /// @custom:oz-upgrades-unsafe-allow constructor constructor() { _disableInitializers(); @@ -184,6 +212,20 @@ contract FRouterV3 is pair.swap(amountIn, 0, 0, amountOut); + _emitTradeExecuted( + TradeEventData({ + token: tokenAddress, + trader: to, + pair: pairAddress, + isBuy: false, + amountIn: amountIn, + amountOut: amountOut, + amount: amount, + taxFee: normalTxFee, + antiSniperFee: antiSniperTxFee + }) + ); + return (amountIn, amountOut); } @@ -240,6 +282,20 @@ contract FRouterV3 is IFPairV2(pair).swap(0, amountOut, amount, 0); + _emitTradeExecuted( + TradeEventData({ + token: tokenAddress, + trader: to, + pair: pair, + isBuy: true, + amountIn: amountIn, + amountOut: amountOut, + amount: amount, + taxFee: normalTxFee, + antiSniperFee: antiSniperTxFee + }) + ); + return (amount, amountOut); } @@ -372,6 +428,30 @@ contract FRouterV3 is return finalTaxStartTime; } + function _emitTradeExecuted(TradeEventData memory trade) private { + IFPairV2 pair = IFPairV2(trade.pair); + (uint256 remainingForSale, uint256 totalRaised) = pair.getReserves(); + uint256 lastPrice = remainingForSale == 0 + ? 0 + : ((totalRaised * 1 ether) / remainingForSale); // last price multiplied by 10 * 10^18 + + emit TradeExecuted( + trade.token, + trade.trader, + trade.pair, + trade.isBuy, + assetToken, + trade.amountIn, + trade.amountOut, + trade.amount, + trade.taxFee, + trade.antiSniperFee, + remainingForSale, + totalRaised, + lastPrice + ); + } + function hasAntiSniperTax(address pairAddress) public view returns (bool) { return _calculateAntiSniperBuyTax(pairAddress) > 0 || diff --git a/scripts/launchpadv5/upgradeBondingV5.ts b/scripts/launchpadv5/upgradeBondingV5.ts new file mode 100644 index 00000000..0fa4ff1c --- /dev/null +++ b/scripts/launchpadv5/upgradeBondingV5.ts @@ -0,0 +1,98 @@ +/** + * Upgrade BondingV5 transparent proxy using CONTRACT_CONTROLLER (ProxyAdmin owner). + * + * Usage: + * BONDING_V5_ADDRESS=0x... npx hardhat run scripts/launchpadv5/upgradeBondingV5.ts --network bsc_testnet + * + * Requires: CONTRACT_CONTROLLER_PRIVATE_KEY, CONTRACT_CONTROLLER, BONDING_V5_ADDRESS + */ +import { ethers, upgrades } from "hardhat"; + +(async () => { + try { + const proxyAddress = + process.env.BONDING_V5_ADDRESS || + "0x272b5917d81b0085fb0F9DedfE883928eB226CFC"; + + if (!process.env.CONTRACT_CONTROLLER_PRIVATE_KEY) { + throw new Error("CONTRACT_CONTROLLER_PRIVATE_KEY is not set"); + } + if (!process.env.CONTRACT_CONTROLLER) { + throw new Error("CONTRACT_CONTROLLER is not set"); + } + + const controllerWallet = new ethers.Wallet( + process.env.CONTRACT_CONTROLLER_PRIVATE_KEY, + ethers.provider + ); + console.log("Using CONTRACT_CONTROLLER:", controllerWallet.address); + console.log("Expected CONTRACT_CONTROLLER:", process.env.CONTRACT_CONTROLLER); + + if ( + controllerWallet.address.toLowerCase() !== + process.env.CONTRACT_CONTROLLER.toLowerCase() + ) { + throw new Error( + "CONTRACT_CONTROLLER_PRIVATE_KEY does not match CONTRACT_CONTROLLER address" + ); + } + + const currentImpl = await upgrades.erc1967.getImplementationAddress(proxyAddress); + const admin = await upgrades.erc1967.getAdminAddress(proxyAddress); + console.log("Proxy:", proxyAddress); + console.log("Current implementation:", currentImpl); + console.log("ProxyAdmin:", admin); + + const proxyAdmin = await ethers.getContractAt( + ["function owner() view returns (address)"], + admin + ); + const owner = await proxyAdmin.owner(); + console.log("ProxyAdmin.owner:", owner); + if (owner.toLowerCase() !== controllerWallet.address.toLowerCase()) { + throw new Error( + `ProxyAdmin owner ${owner} != CONTRACT_CONTROLLER ${controllerWallet.address}` + ); + } + + const BondingV5 = await ethers.getContractFactory("BondingV5"); + const contract = BondingV5.connect(controllerWallet); + + // Proxy may have been upgraded outside this manifest (or by another machine). + // Import the existing deployment so OZ can validate storage + perform the upgrade. + try { + console.log("Force-importing existing proxy into OpenZeppelin manifest..."); + await upgrades.forceImport(proxyAddress, contract, { kind: "transparent" }); + console.log("forceImport OK"); + } catch (importErr: any) { + // Already registered is fine + const msg = importErr?.message || String(importErr); + if (!/already imported|already registered/i.test(msg)) { + console.log("forceImport note:", msg); + } + } + + console.log("Upgrading BondingV5..."); + // Parent-initializer order warning is pre-existing on BondingV5; allow so the upgrade can proceed. + // redeployImplementation: 'always' — forceImport can wrongly bind the *new* artifact hash to the + // *old* impl address when local source has advanced; without this OZ may skip deploying. + const upgraded = await upgrades.upgradeProxy(proxyAddress, contract, { + unsafeAllow: ["incorrect-initializer-order"], + redeployImplementation: "always", + }); + await upgraded.waitForDeployment(); + + const newImpl = await upgrades.erc1967.getImplementationAddress(proxyAddress); + console.log("Upgraded proxy:", await upgraded.getAddress()); + console.log("Previous implementation:", currentImpl); + console.log("New implementation:", newImpl); + if (newImpl.toLowerCase() === currentImpl.toLowerCase()) { + throw new Error( + "Implementation address unchanged after upgrade — redeploy did not take effect" + ); + } + } catch (e) { + console.error("Error:", e); + process.exit(1); + } +})(); diff --git a/test/launchpadv5/bondingV5.js b/test/launchpadv5/bondingV5.js index fb7e27d2..db268537 100644 --- a/test/launchpadv5/bondingV5.js +++ b/test/launchpadv5/bondingV5.js @@ -56,6 +56,7 @@ const ACF_FEE = ethers.parseEther("10"); // Extra fee when needAcf = true (10 on // Bonding curve params const FAKE_INITIAL_VIRTUAL_LIQ = ethers.parseEther("6300"); const TARGET_REAL_VIRTUAL = ethers.parseEther("42000"); +const ACF_FAKE_INITIAL_VIRTUAL_LIQ = ethers.parseEther("14000"); const { setupBondingV5Test } = require("./bondingV5Fixture.js"); describe("BondingV5", function () { @@ -823,7 +824,9 @@ describe("BondingV5", function () { targetRealVirtual: ethers.parseEther("50000"), }; - await bondingConfig.connect(owner).setBondingCurveParams(newParams); + await bondingConfig + .connect(owner) + .setBondingCurveParams(newParams, ACF_FAKE_INITIAL_VIRTUAL_LIQ); const params = await bondingConfig.bondingCurveParams(); expect(params.fakeInitialVirtualLiq).to.equal( @@ -832,10 +835,13 @@ describe("BondingV5", function () { expect(params.targetRealVirtual).to.equal(newParams.targetRealVirtual); // Reset to original - await bondingConfig.connect(owner).setBondingCurveParams({ - fakeInitialVirtualLiq: FAKE_INITIAL_VIRTUAL_LIQ, - targetRealVirtual: TARGET_REAL_VIRTUAL, - }); + await bondingConfig.connect(owner).setBondingCurveParams( + { + fakeInitialVirtualLiq: FAKE_INITIAL_VIRTUAL_LIQ, + targetRealVirtual: TARGET_REAL_VIRTUAL, + }, + ACF_FAKE_INITIAL_VIRTUAL_LIQ + ); }); it("Should revert if non-owner tries to update params", async function () { @@ -3324,6 +3330,7 @@ describe("BondingV5", function () { fakeInitialVirtualLiq: FAKE_INITIAL_VIRTUAL_LIQ, targetRealVirtual: TARGET_REAL_VIRTUAL, }, + ACF_FAKE_INITIAL_VIRTUAL_LIQ, ], { initializer: "initialize" } ); diff --git a/test/launchpadv5/bondingV5DrainLiquidity.js b/test/launchpadv5/bondingV5DrainLiquidity.js index 211fea65..09c06662 100644 --- a/test/launchpadv5/bondingV5DrainLiquidity.js +++ b/test/launchpadv5/bondingV5DrainLiquidity.js @@ -205,7 +205,7 @@ describe("BondingV5 / FRouterV3 — drain liquidity (V5 suite)", function () { { initializer: "initialize" } ); await freshRouter.waitForDeployment(); - await freshRouter.grantRole(await freshRouter.EXECUTOR_ROLE(), admin.address); + await freshRouter.grantRole(await freshRouter.BE_OPS_ROLE(), admin.address); await expect( freshRouter diff --git a/test/launchpadv5/bondingV5ExtParams.js b/test/launchpadv5/bondingV5ExtParams.js new file mode 100644 index 00000000..42f10e04 --- /dev/null +++ b/test/launchpadv5/bondingV5ExtParams.js @@ -0,0 +1,247 @@ +/** + * BondingV5 `extParams` v3: append-only, backward-compatible encoding that carries + * isFeeDelegation (bit 0), skipSuffix (bit 1), isRobotics (bit 2), + * feeDelegationType (bits 3-4) and an optional trailing feeDelegationRecipient. + */ +const { expect } = require("chai"); +const { ethers } = require("hardhat"); +const { time } = require("@nomicfoundation/hardhat-network-helpers"); +const { + loadFixture, +} = require("@nomicfoundation/hardhat-toolbox/network-helpers"); +const { anyValue } = require("@nomicfoundation/hardhat-chai-matchers/withArgs"); + +const { START_TIME_DELAY } = require("../launchpadv2/const.js"); +const { setupV2V3TaxComparisonTest } = require("./bondingV5Tax.fixture.js"); + +const LAUNCH_MODE_NORMAL = 0; +const ANTI_SNIPER_60S = 1; + +const FLAG_FEE_DELEGATION = 1n; +const FLAG_SKIP_SUFFIX = 2n; +const FLAG_ROBOTICS = 4n; +const FEE_DELEGATION_TYPE_SHIFT = 3n; + +const FEE_DELEGATION_TYPE_ADDRESS = 1; +const FEE_DELEGATION_TYPE_TWITTER = 2; + +function buildFlags({ + isFeeDelegation = false, + skipSuffix = false, + isRobotics = false, + feeDelegationType = 0, +} = {}) { + let word = 0n; + if (isFeeDelegation) word |= FLAG_FEE_DELEGATION; + if (skipSuffix) word |= FLAG_SKIP_SUFFIX; + if (isRobotics) word |= FLAG_ROBOTICS; + word |= (BigInt(feeDelegationType) & 0x3n) << FEE_DELEGATION_TYPE_SHIFT; + return word; +} + +/** Flags-only payload (single word). */ +function encodeFlags(opts) { + return ethers.AbiCoder.defaultAbiCoder().encode(["uint256"], [buildFlags(opts)]); +} + +/** Flags + trailing recipient: abi.encode(uint256 flags, bytes32 recipient). */ +function encodeFlagsWithRecipient(opts, recipient32) { + return ethers.AbiCoder.defaultAbiCoder().encode( + ["uint256", "bytes32"], + [buildFlags(opts), recipient32] + ); +} + +/** Recipient as a right-aligned integer in bytes32, matching the contract wire. */ +function addressToRecipient32(addr) { + return ethers.zeroPadValue(addr, 32); // uint160, right-aligned +} +function twitterIdToRecipient32(id) { + return ethers.toBeHex(BigInt(id), 32); // uint64, right-aligned +} + +/** Legacy V1 encoding: abi.encode(bool isFeeDelegation). */ +function encodeLegacyBool(isFeeDelegation) { + return ethers.AbiCoder.defaultAbiCoder().encode(["bool"], [isFeeDelegation]); +} + +async function extParamsFixture() { + return setupV2V3TaxComparisonTest({ includeBondingV4: false }); +} + +describe("BondingV5 extParams — v3 launch settings (robotics + fee delegation)", function () { + let contracts; + let user2; + + before(async function () { + const setup = await loadFixture(extParamsFixture); + contracts = setup.contracts; + user2 = setup.accounts.user2; + }); + + async function preLaunchWithExtParams(extParamsHex) { + const { bondingV5, virtualToken } = contracts; + + await virtualToken + .connect(user2) + .approve(await bondingV5.getAddress(), ethers.MaxUint256); + + const purchaseAmount = ethers.parseEther("1000"); + const startTime = (await time.latest()) + START_TIME_DELAY + 1; + + const tx = await bondingV5.connect(user2).preLaunch( + "ExtParams Token", + "EXT", + [0, 1, 2], + "desc", + "https://example.com/i.png", + ["", "", "", ""], + purchaseAmount, + startTime, + LAUNCH_MODE_NORMAL, + 0, + false, + ANTI_SNIPER_60S, + false, + extParamsHex + ); + + const receipt = await tx.wait(); + const event = receipt.logs.find((log) => { + try { + return bondingV5.interface.parseLog(log)?.name === "PreLaunched"; + } catch { + return false; + } + }); + const tokenAddress = bondingV5.interface.parseLog(event).args.token; + + return { tokenAddress, startTime }; + } + + it("defaults to all-off for empty extParams (backward compatible)", async function () { + const { bondingV5 } = contracts; + const { tokenAddress } = await preLaunchWithExtParams("0x"); + + expect(await bondingV5.isFeeDelegation(tokenAddress)).to.equal(false); + expect(await bondingV5.isRobotics(tokenAddress)).to.equal(false); + expect(await bondingV5.feeDelegationType(tokenAddress)).to.equal(0); + expect(await bondingV5.feeDelegationRecipient(tokenAddress)).to.equal( + ethers.ZeroHash + ); + }); + + it("keeps legacy abi.encode(bool) working with new fields defaulted", async function () { + const { bondingV5 } = contracts; + const { tokenAddress } = await preLaunchWithExtParams(encodeLegacyBool(true)); + + expect(await bondingV5.isFeeDelegation(tokenAddress)).to.equal(true); + expect(await bondingV5.isRobotics(tokenAddress)).to.equal(false); + expect(await bondingV5.feeDelegationType(tokenAddress)).to.equal(0); + expect(await bondingV5.feeDelegationRecipient(tokenAddress)).to.equal( + ethers.ZeroHash + ); + }); + + it("decodes isRobotics from bit 2", async function () { + const { bondingV5 } = contracts; + const { tokenAddress } = await preLaunchWithExtParams( + encodeFlags({ isRobotics: true }) + ); + + expect(await bondingV5.isRobotics(tokenAddress)).to.equal(true); + expect(await bondingV5.isFeeDelegation(tokenAddress)).to.equal(false); + }); + + it("stores address-type fee delegation recipient", async function () { + const { bondingV5 } = contracts; + const recipient = ethers.getAddress( + "0x00000000000000000000000000000000000000aa" + ); + // Address stored as a right-aligned uint160 in bytes32. + const recipient32 = addressToRecipient32(recipient); + + const extParams = encodeFlagsWithRecipient( + { isFeeDelegation: true, feeDelegationType: FEE_DELEGATION_TYPE_ADDRESS }, + recipient32 + ); + const { tokenAddress } = await preLaunchWithExtParams(extParams); + + expect(await bondingV5.isFeeDelegation(tokenAddress)).to.equal(true); + expect(await bondingV5.feeDelegationType(tokenAddress)).to.equal( + FEE_DELEGATION_TYPE_ADDRESS + ); + const stored = await bondingV5.feeDelegationRecipient(tokenAddress); + expect(stored).to.equal(recipient32); + // The address is recoverable from the low 20 bytes. + expect(ethers.getAddress(ethers.dataSlice(stored, 12, 32))).to.equal(recipient); + }); + + it("stores twitter id as a right-aligned uint64 recipient", async function () { + const { bondingV5 } = contracts; + const twitterId = "1234567890"; + const recipient32 = twitterIdToRecipient32(twitterId); + + const extParams = encodeFlagsWithRecipient( + { isFeeDelegation: true, feeDelegationType: FEE_DELEGATION_TYPE_TWITTER }, + recipient32 + ); + const { tokenAddress } = await preLaunchWithExtParams(extParams); + + expect(await bondingV5.feeDelegationType(tokenAddress)).to.equal( + FEE_DELEGATION_TYPE_TWITTER + ); + const stored = await bondingV5.feeDelegationRecipient(tokenAddress); + expect(stored).to.equal(recipient32); + // The numeric id is recovered by reading the word as an integer. + expect(BigInt(stored).toString()).to.equal(twitterId); + }); + + it("emits PreLaunchExtParams with the decoded settings", async function () { + const { bondingV5, virtualToken } = contracts; + const recipient32 = twitterIdToRecipient32("42"); + const extParams = encodeFlagsWithRecipient( + { + isFeeDelegation: true, + isRobotics: true, + feeDelegationType: FEE_DELEGATION_TYPE_TWITTER, + }, + recipient32 + ); + + await virtualToken + .connect(user2) + .approve(await bondingV5.getAddress(), ethers.MaxUint256); + const purchaseAmount = ethers.parseEther("1000"); + const startTime = (await time.latest()) + START_TIME_DELAY + 1; + + await expect( + bondingV5 + .connect(user2) + .preLaunch( + "ExtParams Token", + "EXT", + [0, 1, 2], + "desc", + "https://example.com/i.png", + ["", "", "", ""], + purchaseAmount, + startTime, + LAUNCH_MODE_NORMAL, + 0, + false, + ANTI_SNIPER_60S, + false, + extParams + ) + ) + .to.emit(bondingV5, "PreLaunchExtParams") + .withArgs( + anyValue, + true, + true, + FEE_DELEGATION_TYPE_TWITTER, + recipient32 + ); + }); +}); diff --git a/test/launchpadv5/bondingV5Fixture.js b/test/launchpadv5/bondingV5Fixture.js index 846184a6..75efb337 100644 --- a/test/launchpadv5/bondingV5Fixture.js +++ b/test/launchpadv5/bondingV5Fixture.js @@ -23,6 +23,7 @@ const NORMAL_LAUNCH_FEE = ethers.parseEther("100"); const ACF_FEE = ethers.parseEther("10"); const FAKE_INITIAL_VIRTUAL_LIQ = ethers.parseEther("6300"); const TARGET_REAL_VIRTUAL = ethers.parseEther("42000"); +const ACF_FAKE_INITIAL_VIRTUAL_LIQ = ethers.parseEther("14000"); async function setupBondingV5Test() { const setup = {}; @@ -353,6 +354,7 @@ async function setupBondingV5Test() { scheduledLaunchParams, deployParams, bondingCurveParams, + ACF_FAKE_INITIAL_VIRTUAL_LIQ, ], { initializer: "initialize" } ); @@ -363,6 +365,10 @@ async function setupBondingV5Test() { await bondingConfig.setPrivilegedLauncher(owner.address, true); console.log("setPrivilegedLauncher(true) for owner (test default backend)"); + // Graduation transfers bonding-curve excess tokens here to be burned off-chain + await bondingConfig.setGraduationExcessBurnWallet(beOpsWallet.address); + console.log("graduationExcessBurnWallet set to beOpsWallet"); + // 4.2 Deploy BondingV5 console.log("\n--- Deploying BondingV5 ---"); const BondingV5 = await ethers.getContractFactory("BondingV5"); @@ -440,6 +446,10 @@ async function setupBondingV5Test() { await fRouterV3.grantRole(await fRouterV3.EXECUTOR_ROLE(), admin.address); console.log("EXECUTOR_ROLE granted to admin in FRouterV3"); + // drainPrivatePool / drainUniV2Pool are gated by BE_OPS_ROLE + await fRouterV3.grantRole(await fRouterV3.BE_OPS_ROLE(), admin.address); + console.log("BE_OPS_ROLE granted to admin in FRouterV3"); + await agentFactoryV7.grantRole( await agentFactoryV7.REMOVE_LIQUIDITY_ROLE(), await fRouterV3.getAddress() diff --git a/test/launchpadv5/bondingV5Tax.fixture.js b/test/launchpadv5/bondingV5Tax.fixture.js index eb3cdf60..ec72f697 100644 --- a/test/launchpadv5/bondingV5Tax.fixture.js +++ b/test/launchpadv5/bondingV5Tax.fixture.js @@ -26,6 +26,7 @@ const ACF_FEE = ethers.parseEther("10"); const FAKE_INITIAL_VIRTUAL_LIQ = ethers.parseEther("6300"); const TARGET_REAL_VIRTUAL = ethers.parseEther("42000"); +const ACF_FAKE_INITIAL_VIRTUAL_LIQ = ethers.parseEther("14000"); const BONDING_V4_FEE = 10; @@ -296,11 +297,15 @@ async function setupV2V3TaxComparisonTest(options = {}) { fakeInitialVirtualLiq: FAKE_INITIAL_VIRTUAL_LIQ, targetRealVirtual: TARGET_REAL_VIRTUAL, }, + ACF_FAKE_INITIAL_VIRTUAL_LIQ, ], { initializer: "initialize" } ); await bondingConfig.waitForDeployment(); + // Graduation transfers bonding-curve excess tokens here to be burned off-chain + await bondingConfig.setGraduationExcessBurnWallet(beOpsWallet.address); + let bondingV4; if (includeBondingV4) { console.log("\n--- Deploying BondingV4 (V2 curve; for V2/V3 comparison tests) ---"); diff --git a/test/launchpadv5/indexingEvents.js b/test/launchpadv5/indexingEvents.js new file mode 100644 index 00000000..c76f5902 --- /dev/null +++ b/test/launchpadv5/indexingEvents.js @@ -0,0 +1,227 @@ +// Focused verification of the indexing events added to BondingV5 / FRouterV3. +// Walks one token through preLaunch -> launch -> buy -> sell -> graduation and +// asserts each new event fires with correct, internally-consistent values. +const { expect } = require("chai"); +const { ethers } = require("hardhat"); +const { time } = require("@nomicfoundation/hardhat-network-helpers"); +const { increaseTimeByMinutes } = require("../launchpadv2/util.js"); +const { START_TIME_DELAY } = require("../launchpadv2/const.js"); +const { setupBondingV5Test } = require("./bondingV5Fixture.js"); + +const LAUNCH_MODE_NORMAL = 0; +const ANTI_SNIPER_60S = 1; + +function parseOne(contract, receipt, name) { + for (const log of receipt.logs) { + try { + const p = contract.interface.parseLog(log); + if (p && p.name === name) return p; + } catch (e) {} + } + return undefined; +} + +describe("Indexing events (BondingV5 / FRouterV3)", function () { + let setup, contracts, accounts, addresses; + let tokenAddress, pairAddress, pair; + + before(async function () { + this.timeout(120000); + setup = await setupBondingV5Test(); + contracts = setup.contracts; + accounts = setup.accounts; + addresses = setup.addresses; + + // Fixture leaves graduationExcessBurnWallet unset (zero); set it so the + // graduation excess-burn transfer doesn't revert with TransferToZeroAddress. + await contracts.bondingConfig + .connect(accounts.owner) + .setGraduationExcessBurnWallet(ethers.Wallet.createRandom().address); + }); + + it("preLaunch emits TokenCreated with consistent values", async function () { + const { user1 } = accounts; + const { bondingV5, virtualToken } = contracts; + + const purchaseAmount = ethers.parseEther("1000"); + await virtualToken.connect(user1).approve(addresses.bondingV5, purchaseAmount); + const startTime = (await time.latest()) + START_TIME_DELAY + 1; + + const tx = await bondingV5.connect(user1).preLaunch( + "Idx Token", + "IDX", + [0, 1, 2], + "desc", + "https://example.com/i.png", + ["tw", "tg", "yt", "web"], + purchaseAmount, + startTime, + LAUNCH_MODE_NORMAL, + 0, + false, + ANTI_SNIPER_60S, + false, + "0x" + ); + const receipt = await tx.wait(); + + const pre = parseOne(bondingV5, receipt, "PreLaunched"); + expect(pre, "PreLaunched emitted").to.not.be.undefined; + tokenAddress = pre.args.token; + pairAddress = pre.args.pair; + pair = await ethers.getContractAt("IFPairV2", pairAddress); + + const tc = parseOne(bondingV5, receipt, "TokenCreated"); + expect(tc, "TokenCreated emitted").to.not.be.undefined; + expect(tc.args.creator).to.equal(user1.address); + expect(tc.args.token).to.equal(tokenAddress); + expect(tc.args.name).to.equal("Idx Token"); + expect(tc.args.symbol).to.equal("IDX"); + expect(tc.args.quoteAsset).to.equal(addresses.virtualToken); + expect(tc.args.pair).to.equal(pairAddress); + // v2 fields: virtualId matches PreLaunched, applicationId + virtual liquidity present + expect(tc.args.virtualId).to.equal(pre.args.virtualId); + expect(tc.args.applicationId).to.be.greaterThan(0n); + expect(tc.args.initialVirtualLiquidity).to.be.greaterThan(0n); + // curve sanity: sale supply above graduation threshold, both positive + expect(tc.args.saleAmount).to.be.greaterThan(0n); + expect(tc.args.graduationThreshold).to.be.greaterThan(0n); + expect(tc.args.saleAmount).to.be.greaterThan(tc.args.graduationThreshold); + // targetRaiseAmount computed without underflow/revert and is positive + expect(tc.args.targetRaiseAmount).to.be.greaterThan(0n); + expect(tc.args.initialPrice).to.be.greaterThan(0n); + }); + + it("launch succeeds and enables trading", async function () { + const { bondingV5 } = contracts; + await time.increase(START_TIME_DELAY + 1); + const tx = await bondingV5.launch(tokenAddress); + const receipt = await tx.wait(); + + const launched = parseOne(bondingV5, receipt, "Launched"); + expect(launched, "Launched emitted").to.not.be.undefined; + expect(launched.args.token).to.equal(tokenAddress); + }); + + it("buy emits TradeExecuted with anti-sniper fee active + consistent reserves/price", async function () { + const { user2 } = accounts; + const { bondingV5, fRouterV3, virtualToken } = contracts; + + const token = await ethers.getContractAt("AgentTokenV4", tokenAddress); + const buyAmount = ethers.parseEther("100"); + await virtualToken.connect(user2).approve(addresses.fRouterV3, buyAmount); + + // Independent ground truth: actual balance deltas. + const vBefore = await virtualToken.balanceOf(user2.address); + const tBefore = await token.balanceOf(user2.address); + const taxVaultBefore = await virtualToken.balanceOf(addresses.taxVault); + const antiVaultBefore = await virtualToken.balanceOf(addresses.antiSniperTaxVault); + + const tx = await bondingV5.connect(user2).buy(buyAmount, tokenAddress, 0, (await time.latest()) + 300); + const receipt = await tx.wait(); + + const te = parseOne(fRouterV3, receipt, "TradeExecuted"); + expect(te, "TradeExecuted emitted").to.not.be.undefined; + expect(te.args.isBuy).to.equal(true); + expect(te.args.token).to.equal(tokenAddress); + expect(te.args.pair).to.equal(pairAddress); + expect(te.args.quoteAsset).to.equal(addresses.virtualToken); + // buy semantics: trader pays amountIn total; curve gets `amount` = amountIn - taxes; + // amountOut is the token received by the trader. + expect(te.args.amountIn).to.equal(buyAmount); + expect(te.args.taxFee + te.args.antiSniperFee + te.args.amount).to.equal(buyAmount); + // anti-sniper window still open -> nonzero anti-sniper fee + expect(te.args.antiSniperFee).to.be.greaterThan(0n); + expect(te.args.amountOut).to.be.greaterThan(0n); + + // Ground-truth cross-checks: emitted amounts == actual tokens/VIRTUAL moved. + expect(vBefore - (await virtualToken.balanceOf(user2.address))).to.equal(te.args.amountIn); + expect((await token.balanceOf(user2.address)) - tBefore).to.equal(te.args.amountOut); + expect((await virtualToken.balanceOf(addresses.taxVault)) - taxVaultBefore).to.equal(te.args.taxFee); + expect((await virtualToken.balanceOf(addresses.antiSniperTaxVault)) - antiVaultBefore).to.equal(te.args.antiSniperFee); + + // reserves/price match the pair post-trade + const [rTok, rAsset] = await pair.getReserves(); + expect(te.args.reserveTokenAfter).to.equal(rTok); + expect(te.args.reserveAssetAfter).to.equal(rAsset); + const expectedPrice = rTok === 0n ? 0n : (rAsset * ethers.parseEther("1")) / rTok; + expect(te.args.lastPrice).to.equal(expectedPrice); + }); + + it("buy after anti-sniper window: antiSniperFee == 0", async function () { + const { user2 } = accounts; + const { bondingV5, fRouterV3, virtualToken } = contracts; + await increaseTimeByMinutes(99); + + const buyAmount = ethers.parseEther("100"); + await virtualToken.connect(user2).approve(addresses.fRouterV3, buyAmount); + const tx = await bondingV5.connect(user2).buy(buyAmount, tokenAddress, 0, (await time.latest()) + 300); + const receipt = await tx.wait(); + + const te = parseOne(fRouterV3, receipt, "TradeExecuted"); + expect(te).to.not.be.undefined; + expect(te.args.isBuy).to.equal(true); + expect(te.args.antiSniperFee).to.equal(0n); + }); + + it("sell emits TradeExecuted with isBuy=false and correct net/tax split", async function () { + const { user2 } = accounts; + const { bondingV5, fRouterV3, virtualToken } = contracts; + + const token = await ethers.getContractAt("AgentTokenV4", tokenAddress); + const bal = await token.balanceOf(user2.address); + const sellAmount = bal / 4n; + await token.connect(user2).approve(addresses.fRouterV3, sellAmount); + + const vBefore = await virtualToken.balanceOf(user2.address); + const tBefore = await token.balanceOf(user2.address); + const taxVaultBefore = await virtualToken.balanceOf(addresses.taxVault); + + const tx = await bondingV5.connect(user2).sell(sellAmount, tokenAddress, 0, (await time.latest()) + 300); + const receipt = await tx.wait(); + + const te = parseOne(fRouterV3, receipt, "TradeExecuted"); + expect(te, "TradeExecuted emitted").to.not.be.undefined; + expect(te.args.isBuy).to.equal(false); + // sell semantics: amountIn is the token sold; amountOut is the gross quote out of + // the curve; amount is the net quote received by the trader after tax. + expect(te.args.amountIn).to.equal(sellAmount); + expect(te.args.antiSniperFee).to.equal(0n); + // net received (amount) = gross curve quote (amountOut) - tax + expect(te.args.amount).to.equal(te.args.amountOut - te.args.taxFee); + + // Ground-truth cross-checks: emitted amounts == actual tokens/VIRTUAL moved. + expect(tBefore - (await token.balanceOf(user2.address))).to.equal(te.args.amountIn); + expect((await virtualToken.balanceOf(user2.address)) - vBefore).to.equal(te.args.amount); + expect((await virtualToken.balanceOf(addresses.taxVault)) - taxVaultBefore).to.equal(te.args.taxFee); + + const [rTok, rAsset] = await pair.getReserves(); + expect(te.args.reserveTokenAfter).to.equal(rTok); + expect(te.args.reserveAssetAfter).to.equal(rAsset); + }); + + it("graduation emits Graduated + a final TradeExecuted", async function () { + const { user1 } = accounts; + const { bondingV5, fRouterV3, virtualToken } = contracts; + + // Buy well past the graduation threshold (target raise ~42000 VT). + const bigBuy = ethers.parseEther("100000"); + await virtualToken.connect(user1).approve(addresses.fRouterV3, bigBuy); + const tx = await bondingV5.connect(user1).buy(bigBuy, tokenAddress, 0, (await time.latest()) + 300); + const receipt = await tx.wait(); + + // The triggering buy still emits its TradeExecuted before graduation. + const te = parseOne(fRouterV3, receipt, "TradeExecuted"); + expect(te, "TradeExecuted emitted on graduating buy").to.not.be.undefined; + expect(te.args.isBuy).to.equal(true); + + const grad = parseOne(bondingV5, receipt, "Graduated"); + expect(grad, "Graduated emitted").to.not.be.undefined; + expect(grad.args.token).to.equal(tokenAddress); + const agentToken = grad.args.agentToken; + expect(agentToken).to.not.equal(ethers.ZeroAddress); + + const info = await bondingV5.tokenInfo(tokenAddress); + expect(info.tradingOnUniswap).to.equal(true); + }); +});