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 01ac7b45..be06d637 100644 --- a/contracts/launchpadv2/BondingV5.sol +++ b/contracts/launchpadv2/BondingV5.sol @@ -168,6 +168,27 @@ contract BondingV5 is 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(); @@ -536,6 +557,28 @@ contract BondingV5 is 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/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); + }); +});