From 846f72061b76fc4a71f63ba54174146440ddc65a Mon Sep 17 00:00:00 2001 From: koo-virtuals Date: Tue, 19 May 2026 10:51:43 +0800 Subject: [PATCH] add _validateExtParams --- contracts/launchpadv2/BondingV5.sol | 45 +++-- test/launchpadv5/bondingV5FeeDelegation.js | 182 ++++++++++++++++----- 2 files changed, 169 insertions(+), 58 deletions(-) diff --git a/contracts/launchpadv2/BondingV5.sol b/contracts/launchpadv2/BondingV5.sol index eaf0846..65c0371 100644 --- a/contracts/launchpadv2/BondingV5.sol +++ b/contracts/launchpadv2/BondingV5.sol @@ -152,23 +152,39 @@ contract BondingV5 is _disableInitializers(); } - /// @notice Decode `isFeeDelegation` from optional extension calldata. - /// @dev V1 layout: first 32 bytes = `abi.encode(bool isFeeDelegation)` (standard ABI word). - /// Empty `extParams` or length less than 32 ⇒ false. Extra trailing bytes are ignored here so - /// callers can forward-compat append more values after the bool (same encoding rules). - /// Non-canonical bool words (not 0 or 1) ⇒ false. - function _decodeIsFeeDelegation(bytes calldata extParams) internal pure returns (bool) { - if (extParams.length < 32) { - return false; + /// @notice Validate the `extParams` payload before storing it. + /// @dev Supported layouts (length → version): + /// 0 bytes → V0: empty, all fields take default values + /// 32 bytes → V1: abi.encode(bool isFeeDelegation) + /// Rules for every non-empty version: + /// • Length must be a multiple of 32 (ABI word-aligned). + /// • First word must be a canonical bool (0 or 1). + /// To introduce V2 (e.g. 64 bytes): add the second-word check here and + /// raise the `len > 32` guard to `len > 64`. + function _validateExtParams(bytes calldata extParams) internal pure { + uint256 len = extParams.length; + if (len == 0) return; + if (len % 32 != 0) revert InvalidInput(); + + uint256 v; + assembly ("memory-safe") { + v := calldataload(extParams.offset) } - bytes32 word; + if (v > 1) revert InvalidInput(); + + // Only V1 (32 bytes) is currently defined. Reject any longer payload until V2 is added. + if (len > 32) revert InvalidInput(); + } + + /// @notice Read `isFeeDelegation` from already-validated `extParams`. + /// @dev Called only after `_validateExtParams` has passed, so no re-validation needed. + function _decodeIsFeeDelegation(bytes calldata extParams) internal pure returns (bool) { + if (extParams.length < 32) return false; + uint256 v; assembly ("memory-safe") { - word := calldataload(extParams.offset) + v := calldataload(extParams.offset) } - uint256 v = uint256(word); - if (v == 1) return true; - if (v == 0) return false; - return false; + return v == 1; } function initialize( @@ -205,6 +221,7 @@ contract BondingV5 is bool isProject60days_, bytes calldata extParams_ ) public nonReentrant returns (address, address, uint, uint256) { + _validateExtParams(extParams_); bool isFeeDelegation_ = _decodeIsFeeDelegation(extParams_); // Fail-fast: validate reserve bips and calculate bonding curve supply upfront diff --git a/test/launchpadv5/bondingV5FeeDelegation.js b/test/launchpadv5/bondingV5FeeDelegation.js index b2a3282..f748af3 100644 --- a/test/launchpadv5/bondingV5FeeDelegation.js +++ b/test/launchpadv5/bondingV5FeeDelegation.js @@ -1,5 +1,10 @@ /** - * BondingV5 `extParams` V1 encodes optional `abi.encode(bool isFeeDelegation)` (same flag the app calls fee delegation). + * BondingV5 `extParams` validation and isFeeDelegation decoding. + * + * extParams layout (strict length whitelist): + * V0 — empty (0 bytes) : all fields default, isFeeDelegation = false + * V1 — 32 bytes : abi.encode(bool isFeeDelegation) + * Any other length or non-canonical bool word → revert InvalidInput */ const { expect } = require("chai"); const { ethers } = require("hardhat"); @@ -14,7 +19,7 @@ const { setupV2V3TaxComparisonTest } = require("./bondingV5Tax.fixture.js"); const LAUNCH_MODE_NORMAL = 0; const ANTI_SNIPER_60S = 1; -/** @returns {Promise} hex `extParams` with first word = canonical ABI-encoded bool */ +/** Canonical ABI-encoded bool word (32 bytes). */ function encodeFeeDelegationFlag(isFeeDelegation) { return ethers.AbiCoder.defaultAbiCoder().encode(["bool"], [isFeeDelegation]); } @@ -23,7 +28,7 @@ async function feeDelegationFixture() { return setupV2V3TaxComparisonTest({ includeBondingV4: false }); } -describe("BondingV5 extParams — isFeeDelegation (fee delegation)", function () { +describe("BondingV5 extParams — validation and isFeeDelegation", function () { let contracts; /** @type {import('ethers').Signer} */ let owner; @@ -81,73 +86,162 @@ describe("BondingV5 extParams — isFeeDelegation (fee delegation)", function () return { tokenAddress, pairAddress, startTime }; } - it("Should store isFeeDelegation true when extParams encodes bool true", async function () { - const { bondingV5 } = contracts; + async function expectPreLaunchRevert(extParamsHex) { + const { bondingV5, virtualToken } = contracts; - const extParams = encodeFeeDelegationFlag(true); - const { tokenAddress } = await preLaunchWithExtParams(extParams); + await virtualToken + .connect(user2) + .approve(await bondingV5.getAddress(), ethers.MaxUint256); - expect(await bondingV5.isFeeDelegation(tokenAddress)).to.equal(true); - }); + const purchaseAmount = ethers.parseEther("1000"); + const startTime = (await time.latest()) + START_TIME_DELAY + 1; - it("Should store isFeeDelegation false when extParams is empty", async function () { - const { bondingV5 } = contracts; + return expect( + bondingV5.connect(user2).preLaunch( + "Bad Token", + "BAD", + [0], + "desc", + "https://example.com/i.png", + ["", "", "", ""], + purchaseAmount, + startTime, + LAUNCH_MODE_NORMAL, + 0, + false, + ANTI_SNIPER_60S, + false, + extParamsHex + ) + ).to.be.revertedWithCustomError(bondingV5, "InvalidInput"); + } - const { tokenAddress } = await preLaunchWithExtParams("0x"); + // ─── Valid extParams (V0 and V1) ───────────────────────────────────────── - expect(await bondingV5.isFeeDelegation(tokenAddress)).to.equal(false); - }); + describe("Valid extParams", function () { + it("V0: empty extParams → isFeeDelegation false", async function () { + const { bondingV5 } = contracts; + const { tokenAddress } = await preLaunchWithExtParams("0x"); + expect(await bondingV5.isFeeDelegation(tokenAddress)).to.equal(false); + }); - it("Should store isFeeDelegation false when extParams encodes bool false", async function () { - const { bondingV5 } = contracts; + it("V1: abi.encode(false) → isFeeDelegation false", async function () { + const { bondingV5 } = contracts; + const { tokenAddress } = await preLaunchWithExtParams( + encodeFeeDelegationFlag(false) + ); + expect(await bondingV5.isFeeDelegation(tokenAddress)).to.equal(false); + }); - const extParams = encodeFeeDelegationFlag(false); - const { tokenAddress } = await preLaunchWithExtParams(extParams); + it("V1: abi.encode(true) → isFeeDelegation true", async function () { + const { bondingV5 } = contracts; + const { tokenAddress } = await preLaunchWithExtParams( + encodeFeeDelegationFlag(true) + ); + expect(await bondingV5.isFeeDelegation(tokenAddress)).to.equal(true); + }); - expect(await bondingV5.isFeeDelegation(tokenAddress)).to.equal(false); + it("V1: raw extParams bytes are stored verbatim in tokenPreLaunchExtParams", async function () { + const { bondingV5 } = contracts; + const extParams = encodeFeeDelegationFlag(true); + const { tokenAddress } = await preLaunchWithExtParams(extParams); + expect(await bondingV5.tokenPreLaunchExtParams(tokenAddress)).to.equal( + extParams + ); + }); }); - it("Should treat non-canonical bool word as false", async function () { - const { bondingV5 } = contracts; + // ─── Invalid extParams — non-ABI-aligned lengths ───────────────────────── - const badWord = ethers.zeroPadValue(ethers.toBeHex(2), 32); - const extParams = ethers.hexlify(badWord); + describe("Invalid extParams — non-aligned length", function () { + it("1 byte → revert InvalidInput", async function () { + await expectPreLaunchRevert(ethers.hexlify(new Uint8Array([0x01]))); + }); - const { tokenAddress } = await preLaunchWithExtParams(extParams); + it("31 bytes → revert InvalidInput", async function () { + await expectPreLaunchRevert( + ethers.hexlify(new Uint8Array(31).fill(0x00)) + ); + }); - expect(await bondingV5.isFeeDelegation(tokenAddress)).to.equal(false); + it("33 bytes (32-byte word + 1 stray byte) → revert InvalidInput", async function () { + const word = ethers.zeroPadValue(ethers.toBeHex(1), 32); // valid bool true + const extra = "01"; + await expectPreLaunchRevert(word + extra); + }); }); - it("Should still read isFeeDelegation true after launch when caller is privileged", async function () { - const { bondingV5, bondingConfig } = contracts; + // ─── Invalid extParams — non-canonical bool word ────────────────────────── + + describe("Invalid extParams — non-canonical first word", function () { + it("value 2 (one above bool range) → revert InvalidInput", async function () { + await expectPreLaunchRevert(ethers.zeroPadValue(ethers.toBeHex(2), 32)); + }); - const extParams = encodeFeeDelegationFlag(true); - const { tokenAddress, startTime } = await preLaunchWithExtParams(extParams); + it("value 255 (0xff) → revert InvalidInput", async function () { + await expectPreLaunchRevert(ethers.zeroPadValue(ethers.toBeHex(255), 32)); + }); - expect(await bondingV5.isFeeDelegation(tokenAddress)).to.equal(true); + it("MAX_UINT256 → revert InvalidInput", async function () { + await expectPreLaunchRevert(ethers.zeroPadValue(ethers.toBeHex(ethers.MaxUint256), 32)); + }); - await bondingConfig.connect(owner).setPrivilegedLauncher(user2.address, true); + it("ASCII-encoded hex string '0x000...' (observed attack payload) → revert InvalidInput", async function () { + // Reproduces the exact attack: caller passes UTF-8 bytes of "0x" + "0"*30 + // instead of a canonical ABI-encoded bool. First byte is 0x30 ('0'), not 0x00. + const attackBytes = ethers.toUtf8Bytes("0x" + "0".repeat(30)); + await expectPreLaunchRevert(ethers.hexlify(attackBytes)); + }); + }); - await time.increaseTo(startTime + 1); - await bondingV5.connect(user2).launch(tokenAddress); + // ─── Invalid extParams — unknown version length ─────────────────────────── - expect(await bondingV5.isFeeDelegation(tokenAddress)).to.equal(true); + describe("Invalid extParams — unknown version (length > 32)", function () { + it("64 bytes (V2 not yet defined) → revert InvalidInput", async function () { + const word0 = ethers.zeroPadValue(ethers.toBeHex(1), 32); // valid bool true + const word1 = ethers.zeroPadValue(ethers.toBeHex(0), 32); // placeholder second word + await expectPreLaunchRevert(word0 + word1.slice(2)); // concat without 0x prefix + }); - await bondingConfig.connect(owner).setPrivilegedLauncher(user2.address, false); + it("96 bytes (V3 not yet defined) → revert InvalidInput", async function () { + const word = ethers.zeroPadValue(ethers.toBeHex(0), 32); + await expectPreLaunchRevert(word + word.slice(2) + word.slice(2)); + }); }); - it("Should revert launch for fee-delegation token when caller is not privileged", async function () { - const { bondingV5, bondingConfig } = contracts; + // ─── Post-launch state persistence ─────────────────────────────────────── + + describe("isFeeDelegation state after launch", function () { + it("isFeeDelegation true survives launch when caller is privileged", async function () { + const { bondingV5, bondingConfig } = contracts; - await bondingConfig.connect(owner).setPrivilegedLauncher(user2.address, false); + const extParams = encodeFeeDelegationFlag(true); + const { tokenAddress, startTime } = await preLaunchWithExtParams(extParams); - const extParams = encodeFeeDelegationFlag(true); - const { tokenAddress, startTime } = await preLaunchWithExtParams(extParams); + expect(await bondingV5.isFeeDelegation(tokenAddress)).to.equal(true); - await time.increaseTo(startTime + 1); + await bondingConfig.connect(owner).setPrivilegedLauncher(user2.address, true); + await time.increaseTo(startTime + 1); + await bondingV5.connect(user2).launch(tokenAddress); - await expect( - bondingV5.connect(user2).launch(tokenAddress) - ).to.be.revertedWithCustomError(bondingV5, "UnauthorizedLauncher"); + expect(await bondingV5.isFeeDelegation(tokenAddress)).to.equal(true); + + await bondingConfig.connect(owner).setPrivilegedLauncher(user2.address, false); + }); + + it("launch reverts for fee-delegation token when caller is not privileged", async function () { + const { bondingV5, bondingConfig } = contracts; + + await bondingConfig.connect(owner).setPrivilegedLauncher(user2.address, false); + + const extParams = encodeFeeDelegationFlag(true); + const { tokenAddress, startTime } = await preLaunchWithExtParams(extParams); + + await time.increaseTo(startTime + 1); + + await expect( + bondingV5.connect(user2).launch(tokenAddress) + ).to.be.revertedWithCustomError(bondingV5, "UnauthorizedLauncher"); + }); }); });