Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 31 additions & 14 deletions contracts/launchpadv2/BondingV5.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down
182 changes: 138 additions & 44 deletions test/launchpadv5/bondingV5FeeDelegation.js
Original file line number Diff line number Diff line change
@@ -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");
Expand All @@ -14,7 +19,7 @@ const { setupV2V3TaxComparisonTest } = require("./bondingV5Tax.fixture.js");
const LAUNCH_MODE_NORMAL = 0;
const ANTI_SNIPER_60S = 1;

/** @returns {Promise<string>} 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]);
}
Expand All @@ -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;
Expand Down Expand Up @@ -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");
});
});
});