From 3efab8f885736a0965d33d365eec5f4dcdb98c74 Mon Sep 17 00:00:00 2001 From: jaime wang Date: Thu, 30 Jul 2026 14:48:17 +0800 Subject: [PATCH 1/3] feat: add more launch options in extparam --- contracts/launchpadv2/BondingV5.sol | 109 ++++++++- test/launchpadv5/bondingV5.js | 17 +- test/launchpadv5/bondingV5DrainLiquidity.js | 2 +- test/launchpadv5/bondingV5ExtParams.js | 235 ++++++++++++++++++++ test/launchpadv5/bondingV5Fixture.js | 10 + test/launchpadv5/bondingV5Tax.fixture.js | 5 + 6 files changed, 365 insertions(+), 13 deletions(-) create mode 100644 test/launchpadv5/bondingV5ExtParams.js diff --git a/contracts/launchpadv2/BondingV5.sol b/contracts/launchpadv2/BondingV5.sol index 431ce3ca..c61cbd9d 100644 --- a/contracts/launchpadv2/BondingV5.sol +++ b/contracts/launchpadv2/BondingV5.sol @@ -114,11 +114,25 @@ 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 Raw fee delegation recipient bytes from the `extParams_` trailing segment: + /// a 20-byte address for type 1; the raw twitter account id bytes for type 2 + /// (the recipient vault is only created just before `launch`). Empty when no delegation. + /// @dev MUST remain the last state variable (append-only layout). + mapping(address => bytes) public feeDelegationRecipient; + event PreLaunched( address indexed token, address indexed pair, @@ -144,6 +158,15 @@ 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, + bytes feeDelegationRecipient + ); + error InvalidTokenStatus(); error InvalidInput(); error SlippageTooHigh(); @@ -157,12 +180,34 @@ 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 segment (`extParams[32:]`, present only when needed) carries the variable-length + /// `feeDelegationRecipient`, ABI-encoded as `abi.encode(uint256 flags, bytes recipient)`: + /// - type 1 (address): the 20-byte recipient address + /// - type 2 (twitter): the raw twitter account id bytes + /// Future variable-length fields are appended after this 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 + uint256 private constant EXT_PARAMS_FEE_DELEGATION_TYPE_SHIFT = 3; // bits 3-4 + uint256 private constant EXT_PARAMS_FEE_DELEGATION_TYPE_MASK = 0x3; function _loadExtParamsWord( bytes calldata extParams @@ -187,6 +232,37 @@ 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` bytes when present. Length-gated so legacy + /// flags-only payloads (length ⇐ 32) and future longer payloads both decode safely; extra + /// trailing tuple elements added later do not affect this decode. + function _decodeFeeDelegationRecipient( + bytes calldata extParams + ) internal pure returns (bytes memory) { + // abi.encode(uint256, bytes) is at least 3 words (flags + offset + length). + if (extParams.length < 96) { + return ""; + } + (, bytes memory recipient) = abi.decode(extParams, (uint256, bytes)); + return recipient; + } + function initialize( address factory_, address router_, @@ -202,8 +278,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 +482,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_); + bytes memory 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 +522,14 @@ contract BondingV5 is tokenLaunchParams[token] ); + emit PreLaunchExtParams( + token, + isFeeDelegation_, + isRobotics_, + feeDelegationType_, + feeDelegationRecipient_ + ); + return (token, pair, tokenInfo[token].virtualId, initialPurchase); } 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..f90a6ae0 --- /dev/null +++ b/test/launchpadv5/bondingV5ExtParams.js @@ -0,0 +1,235 @@ +/** + * 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, bytes recipient). */ +function encodeFlagsWithRecipient(opts, recipientBytes) { + return ethers.AbiCoder.defaultAbiCoder().encode( + ["uint256", "bytes"], + [buildFlags(opts), recipientBytes] + ); +} + +/** 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("0x"); + }); + + 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("0x"); + }); + + 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" + ); + const recipientBytes = ethers.hexlify(ethers.getBytes(recipient)); + + const extParams = encodeFlagsWithRecipient( + { isFeeDelegation: true, feeDelegationType: FEE_DELEGATION_TYPE_ADDRESS }, + recipientBytes + ); + const { tokenAddress } = await preLaunchWithExtParams(extParams); + + expect(await bondingV5.isFeeDelegation(tokenAddress)).to.equal(true); + expect(await bondingV5.feeDelegationType(tokenAddress)).to.equal( + FEE_DELEGATION_TYPE_ADDRESS + ); + expect(await bondingV5.feeDelegationRecipient(tokenAddress)).to.equal( + recipientBytes + ); + }); + + it("stores raw twitter id bytes as fee delegation recipient", async function () { + const { bondingV5 } = contracts; + const twitterId = "1234567890"; + const recipientBytes = ethers.hexlify(ethers.toUtf8Bytes(twitterId)); + + const extParams = encodeFlagsWithRecipient( + { isFeeDelegation: true, feeDelegationType: FEE_DELEGATION_TYPE_TWITTER }, + recipientBytes + ); + const { tokenAddress } = await preLaunchWithExtParams(extParams); + + expect(await bondingV5.feeDelegationType(tokenAddress)).to.equal( + FEE_DELEGATION_TYPE_TWITTER + ); + expect(await bondingV5.feeDelegationRecipient(tokenAddress)).to.equal( + recipientBytes + ); + expect(ethers.toUtf8String(await bondingV5.feeDelegationRecipient(tokenAddress))).to.equal( + twitterId + ); + }); + + it("emits PreLaunchExtParams with the decoded settings", async function () { + const { bondingV5, virtualToken } = contracts; + const recipientBytes = ethers.hexlify(ethers.toUtf8Bytes("42")); + const extParams = encodeFlagsWithRecipient( + { + isFeeDelegation: true, + isRobotics: true, + feeDelegationType: FEE_DELEGATION_TYPE_TWITTER, + }, + recipientBytes + ); + + 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, + recipientBytes + ); + }); +}); 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) ---"); From 5b7477b81b214a7a3fec00af442312d7b1e8693f Mon Sep 17 00:00:00 2001 From: jaime wang Date: Thu, 30 Jul 2026 15:22:25 +0800 Subject: [PATCH 2/3] feat: fixed size of fee delegation recipient --- contracts/launchpadv2/BondingV5.sol | 44 ++++++++++--------- test/launchpadv5/bondingV5ExtParams.js | 58 ++++++++++++++++---------- 2 files changed, 59 insertions(+), 43 deletions(-) diff --git a/contracts/launchpadv2/BondingV5.sol b/contracts/launchpadv2/BondingV5.sol index c61cbd9d..152c920d 100644 --- a/contracts/launchpadv2/BondingV5.sol +++ b/contracts/launchpadv2/BondingV5.sol @@ -127,11 +127,12 @@ contract BondingV5 is /// (decoded from bits 3-4 of the `extParams_` flags word). mapping(address => uint8) public feeDelegationType; - /// @notice Raw fee delegation recipient bytes from the `extParams_` trailing segment: - /// a 20-byte address for type 1; the raw twitter account id bytes for type 2 - /// (the recipient vault is only created just before `launch`). Empty when no delegation. + /// @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 => bytes) public feeDelegationRecipient; + mapping(address => bytes32) public feeDelegationRecipient; event PreLaunched( address indexed token, @@ -164,7 +165,7 @@ contract BondingV5 is bool isFeeDelegation, bool isRobotics, uint8 feeDelegationType, - bytes feeDelegationRecipient + bytes32 feeDelegationRecipient ); error InvalidTokenStatus(); @@ -190,11 +191,13 @@ contract BondingV5 is /// bits 3-4: `feeDelegationType` (0 = none, 1 = address, 2 = twitter) /// remaining bits: reserved = 0, available for future flags/enums. /// - /// Trailing segment (`extParams[32:]`, present only when needed) carries the variable-length - /// `feeDelegationRecipient`, ABI-encoded as `abi.encode(uint256 flags, bytes recipient)`: - /// - type 1 (address): the 20-byte recipient address - /// - type 2 (twitter): the raw twitter account id bytes - /// Future variable-length fields are appended after this and decoded defensively by length. + /// 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). @@ -249,18 +252,19 @@ contract BondingV5 is ); } - /// @dev Reads the trailing `feeDelegationRecipient` bytes when present. Length-gated so legacy - /// flags-only payloads (length ⇐ 32) and future longer payloads both decode safely; extra - /// trailing tuple elements added later do not affect this decode. + /// @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 (bytes memory) { - // abi.encode(uint256, bytes) is at least 3 words (flags + offset + length). - if (extParams.length < 96) { - return ""; + ) internal pure returns (bytes32 recipient) { + if (extParams.length < 64) { + return bytes32(0); + } + assembly ("memory-safe") { + recipient := calldataload(add(extParams.offset, 32)) } - (, bytes memory recipient) = abi.decode(extParams, (uint256, bytes)); - return recipient; } function initialize( @@ -485,7 +489,7 @@ contract BondingV5 is // Decode and store the extended launch settings so on-chain is the source of truth. bool isRobotics_ = _decodeIsRobotics(extParams_); uint8 feeDelegationType_ = _decodeFeeDelegationType(extParams_); - bytes memory feeDelegationRecipient_ = _decodeFeeDelegationRecipient(extParams_); + bytes32 feeDelegationRecipient_ = _decodeFeeDelegationRecipient(extParams_); isRobotics[token] = isRobotics_; feeDelegationType[token] = feeDelegationType_; feeDelegationRecipient[token] = feeDelegationRecipient_; diff --git a/test/launchpadv5/bondingV5ExtParams.js b/test/launchpadv5/bondingV5ExtParams.js index f90a6ae0..42f10e04 100644 --- a/test/launchpadv5/bondingV5ExtParams.js +++ b/test/launchpadv5/bondingV5ExtParams.js @@ -44,14 +44,22 @@ function encodeFlags(opts) { return ethers.AbiCoder.defaultAbiCoder().encode(["uint256"], [buildFlags(opts)]); } -/** Flags + trailing recipient: abi.encode(uint256 flags, bytes recipient). */ -function encodeFlagsWithRecipient(opts, recipientBytes) { +/** Flags + trailing recipient: abi.encode(uint256 flags, bytes32 recipient). */ +function encodeFlagsWithRecipient(opts, recipient32) { return ethers.AbiCoder.defaultAbiCoder().encode( - ["uint256", "bytes"], - [buildFlags(opts), recipientBytes] + ["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]); @@ -118,7 +126,9 @@ describe("BondingV5 extParams — v3 launch settings (robotics + fee delegation) 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("0x"); + expect(await bondingV5.feeDelegationRecipient(tokenAddress)).to.equal( + ethers.ZeroHash + ); }); it("keeps legacy abi.encode(bool) working with new fields defaulted", async function () { @@ -128,7 +138,9 @@ describe("BondingV5 extParams — v3 launch settings (robotics + fee delegation) 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("0x"); + expect(await bondingV5.feeDelegationRecipient(tokenAddress)).to.equal( + ethers.ZeroHash + ); }); it("decodes isRobotics from bit 2", async function () { @@ -146,11 +158,12 @@ describe("BondingV5 extParams — v3 launch settings (robotics + fee delegation) const recipient = ethers.getAddress( "0x00000000000000000000000000000000000000aa" ); - const recipientBytes = ethers.hexlify(ethers.getBytes(recipient)); + // Address stored as a right-aligned uint160 in bytes32. + const recipient32 = addressToRecipient32(recipient); const extParams = encodeFlagsWithRecipient( { isFeeDelegation: true, feeDelegationType: FEE_DELEGATION_TYPE_ADDRESS }, - recipientBytes + recipient32 ); const { tokenAddress } = await preLaunchWithExtParams(extParams); @@ -158,43 +171,42 @@ describe("BondingV5 extParams — v3 launch settings (robotics + fee delegation) expect(await bondingV5.feeDelegationType(tokenAddress)).to.equal( FEE_DELEGATION_TYPE_ADDRESS ); - expect(await bondingV5.feeDelegationRecipient(tokenAddress)).to.equal( - recipientBytes - ); + 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 raw twitter id bytes as fee delegation recipient", async function () { + it("stores twitter id as a right-aligned uint64 recipient", async function () { const { bondingV5 } = contracts; const twitterId = "1234567890"; - const recipientBytes = ethers.hexlify(ethers.toUtf8Bytes(twitterId)); + const recipient32 = twitterIdToRecipient32(twitterId); const extParams = encodeFlagsWithRecipient( { isFeeDelegation: true, feeDelegationType: FEE_DELEGATION_TYPE_TWITTER }, - recipientBytes + recipient32 ); const { tokenAddress } = await preLaunchWithExtParams(extParams); expect(await bondingV5.feeDelegationType(tokenAddress)).to.equal( FEE_DELEGATION_TYPE_TWITTER ); - expect(await bondingV5.feeDelegationRecipient(tokenAddress)).to.equal( - recipientBytes - ); - expect(ethers.toUtf8String(await bondingV5.feeDelegationRecipient(tokenAddress))).to.equal( - twitterId - ); + 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 recipientBytes = ethers.hexlify(ethers.toUtf8Bytes("42")); + const recipient32 = twitterIdToRecipient32("42"); const extParams = encodeFlagsWithRecipient( { isFeeDelegation: true, isRobotics: true, feeDelegationType: FEE_DELEGATION_TYPE_TWITTER, }, - recipientBytes + recipient32 ); await virtualToken @@ -229,7 +241,7 @@ describe("BondingV5 extParams — v3 launch settings (robotics + fee delegation) true, true, FEE_DELEGATION_TYPE_TWITTER, - recipientBytes + recipient32 ); }); }); From beeef476277554de4444619049d948d869423e63 Mon Sep 17 00:00:00 2001 From: jaime wang Date: Thu, 30 Jul 2026 16:04:02 +0800 Subject: [PATCH 3/3] chore: improve bit manipulation comment --- contracts/launchpadv2/BondingV5.sol | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/contracts/launchpadv2/BondingV5.sol b/contracts/launchpadv2/BondingV5.sol index 152c920d..01ac7b45 100644 --- a/contracts/launchpadv2/BondingV5.sol +++ b/contracts/launchpadv2/BondingV5.sol @@ -209,7 +209,9 @@ contract BondingV5 is 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 - uint256 private constant EXT_PARAMS_FEE_DELEGATION_TYPE_SHIFT = 3; // bits 3-4 + // `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(