cyberia-token/test/foundry/CAPToken.fuzz.t.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {Test} from "forge-std/Test.sol";
import {CAPToken} from "../../contracts/CAPToken.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";

/**
 * @title CAPTokenFuzzTest
 * @notice Comprehensive fuzz testing for CAPToken contract
 * @dev Uses Foundry's fuzzing capabilities to test edge cases and properties
 */
contract CAPTokenFuzzTest is Test {
	CAPToken public token;
	CAPToken public implementation;
	address public owner;
	address public feeRecipient;

	// Constants from contract
	uint256 constant BASIS_POINTS_DENOMINATOR = 10_000;
	uint256 constant MAX_TAX_BP = 500;
	uint256 constant MAX_TOTAL_TAX_BP = 1000;
	uint256 constant INITIAL_SUPPLY = 1_000_000 ether;
	uint256 constant MAX_SUPPLY = 42_000_000 ether;

	function setUp() public {
		owner = address(this);
		feeRecipient = makeAddr("feeRecipient");

		// Deploy implementation
		implementation = new CAPToken();

		// Deploy proxy
		bytes memory initData = abi.encodeWithSelector(CAPToken.initialize.selector, owner, feeRecipient);
		ERC1967Proxy proxy = new ERC1967Proxy(address(implementation), initData);

		// Wrap in ABI
		token = CAPToken(address(proxy));
	}

	/*//////////////////////////////////////////////////////////////
                        TRANSFER FUZZ TESTS
  //////////////////////////////////////////////////////////////*/

	/// @notice Fuzz test: Transfer should never exceed sender's balance
	function testFuzz_TransferNeverExceedsBalance(address to, uint256 amount) public {
		vm.assume(to != address(0));
		vm.assume(to != address(token));
		vm.assume(to != owner); // Exclude self-transfer (would decrease balance due to tax)
		vm.assume(to != feeRecipient); // Exclude fee recipient
		vm.assume(amount > 0);

		uint256 senderBalance = token.balanceOf(owner);

		if (amount > senderBalance) {
			// Should revert if trying to transfer more than balance
			vm.expectRevert();
			token.transfer(to, amount);
		} else {
			// Should succeed if amount <= balance
			uint256 balanceBefore = token.balanceOf(to);
			token.transfer(to, amount);
			assertGe(token.balanceOf(to), balanceBefore, "Recipient balance should increase");
		}
	}

	/// @notice Fuzz test: Transfer between valid addresses with various amounts
	function testFuzz_TransferBetweenUsers(address from, address to, uint256 seedAmount, uint256 transferAmount) public {
		vm.assume(from != address(0) && to != address(0));
		vm.assume(from != to);
		vm.assume(from != address(token));
		vm.assume(to != address(token));
		vm.assume(to != feeRecipient);
		vm.assume(from != feeRecipient);
		vm.assume(seedAmount > 10000 && seedAmount <= INITIAL_SUPPLY / 2);

		// Give tokens to 'from'
		token.transfer(from, seedAmount);

		uint256 fromBalanceBefore = token.balanceOf(from);
		vm.assume(transferAmount > 100 && transferAmount <= fromBalanceBefore); // Use actual balance after tax, minimum to avoid rounding issues

		uint256 toBalanceBefore = token.balanceOf(to);
		uint256 treasuryBefore = token.balanceOf(feeRecipient);

		// Execute transfer
		vm.prank(from);
		token.transfer(to, transferAmount);

		uint256 fromBalanceAfter = token.balanceOf(from);
		uint256 toBalanceAfter = token.balanceOf(to);
		uint256 treasuryAfter = token.balanceOf(feeRecipient);

		// Verify balance changes
		assertEq(fromBalanceBefore - fromBalanceAfter, transferAmount, "From balance should decrease by exact amount");
		assertLe(toBalanceAfter - toBalanceBefore, transferAmount, "To should receive <= transfer amount (due to tax)");
		assertGe(treasuryAfter, treasuryBefore, "Treasury should receive tax or stay same");

		// Verify conservation (no tokens created/destroyed except rounding)
		uint256 totalChange = (fromBalanceBefore - fromBalanceAfter) - (toBalanceAfter - toBalanceBefore) - (treasuryAfter - treasuryBefore);
		assertApproxEqAbs(totalChange, 0, 1, "Sum of balance changes should equal zero (within rounding)");
	}

	/*//////////////////////////////////////////////////////////////
                        TAX CALCULATION FUZZ TESTS
  //////////////////////////////////////////////////////////////*/

	/// @notice Fuzz test: Tax calculation should never overflow
	function testFuzz_TaxCalculationNoOverflow(uint256 transferAmount) public {
		vm.assume(transferAmount > 100); // Minimum for meaningful tax
		vm.assume(transferAmount <= INITIAL_SUPPLY);

		address user = makeAddr("user");

		uint256 balanceBefore = token.balanceOf(user);

		// Should not revert due to overflow
		token.transfer(user, transferAmount);

		uint256 balanceAfter = token.balanceOf(user);

		// User should receive less than transferred (due to 1% tax)
		assertLt(balanceAfter - balanceBefore, transferAmount, "Tax should be deducted");
		assertGt(balanceAfter, balanceBefore, "User should receive tokens");
	}

	/// @notice Fuzz test: Tax amount should be deterministic
	function testFuzz_TaxDeterminism(uint256 amount) public {
		vm.assume(amount > 1000 && amount <= INITIAL_SUPPLY / 10);

		address user1 = makeAddr("user1");
		address user2 = makeAddr("user2");

		// Give tokens to users
		token.transfer(user1, amount);
		token.transfer(user2, amount);

		uint256 treasuryBefore1 = token.balanceOf(feeRecipient);
		vm.prank(user1);
		token.transfer(owner, amount / 2);
		uint256 tax1 = token.balanceOf(feeRecipient) - treasuryBefore1;

		uint256 treasuryBefore2 = token.balanceOf(feeRecipient);
		vm.prank(user2);
		token.transfer(owner, amount / 2);
		uint256 tax2 = token.balanceOf(feeRecipient) - treasuryBefore2;

		assertEq(tax1, tax2, "Same transfer amount should produce same tax");
	}

	/// @notice Fuzz test: Tax should scale linearly with amount
	function testFuzz_TaxLinearity(uint256 baseAmount) public {
		vm.assume(baseAmount > 10000 && baseAmount <= INITIAL_SUPPLY / 20);

		address user1 = makeAddr("user1");
		address user2 = makeAddr("user2");

		token.transfer(user1, baseAmount * 4);
		token.transfer(user2, baseAmount * 4);

		// Transfer baseAmount
		uint256 treasuryBefore1 = token.balanceOf(feeRecipient);
		vm.prank(user1);
		token.transfer(owner, baseAmount);
		uint256 tax1 = token.balanceOf(feeRecipient) - treasuryBefore1;

		// Transfer 2x baseAmount
		uint256 treasuryBefore2 = token.balanceOf(feeRecipient);
		vm.prank(user2);
		token.transfer(owner, baseAmount * 2);
		uint256 tax2 = token.balanceOf(feeRecipient) - treasuryBefore2;

		// Tax should be approximately 2x (allow for rounding)
		assertApproxEqAbs(tax2, tax1 * 2, 2, "Tax should scale linearly");
	}

	/// @notice Fuzz test: Valid tax rates should always work
	function testFuzz_ValidTaxRates(uint16 transferTax, uint16 sellTax, uint16 buyTax) public {
		vm.assume(transferTax <= MAX_TAX_BP);
		vm.assume(sellTax <= MAX_TAX_BP);
		vm.assume(buyTax <= MAX_TAX_BP);
		vm.assume(transferTax + sellTax + buyTax <= MAX_TOTAL_TAX_BP);

		// Propose and apply tax changes with timelock
		token.proposeTaxChange(transferTax, sellTax, buyTax);
		vm.warp(block.timestamp + 24 hours);
		token.applyTaxChange();

		assertEq(token.transferTaxBp(), transferTax);
		assertEq(token.sellTaxBp(), sellTax);
		assertEq(token.buyTaxBp(), buyTax);
	}

	/// @notice Fuzz test: Invalid tax rates should revert on proposal
	function testFuzz_InvalidTaxRatesRevert(uint256 transferTax, uint256 sellTax, uint256 buyTax) public {
		vm.assume(
			transferTax > MAX_TAX_BP ||
				sellTax > MAX_TAX_BP ||
				buyTax > MAX_TAX_BP ||
				(transferTax + sellTax + buyTax > MAX_TOTAL_TAX_BP && transferTax <= MAX_TAX_BP && sellTax <= MAX_TAX_BP && buyTax <= MAX_TAX_BP)
		);

		// Should revert when proposing invalid tax rates
		vm.expectRevert();
		token.proposeTaxChange(transferTax, sellTax, buyTax);
	}

	/*//////////////////////////////////////////////////////////////
                        ALLOWANCE FUZZ TESTS
  //////////////////////////////////////////////////////////////*/

	/// @notice Fuzz test: Allowance mechanism with random amounts
	function testFuzz_AllowanceMechanism(address spender, uint256 allowanceAmount, uint256 spendAmount) public {
		vm.assume(spender != address(0));
		vm.assume(spender != owner);
		vm.assume(spender != feeRecipient);
		vm.assume(allowanceAmount > 0 && allowanceAmount <= INITIAL_SUPPLY);
		vm.assume(spendAmount > 0 && spendAmount <= allowanceAmount);

		address recipient = makeAddr("recipient");
		vm.assume(recipient != feeRecipient);

		// Approve spender
		token.approve(spender, allowanceAmount);
		assertEq(token.allowance(owner, spender), allowanceAmount, "Allowance should be set");

		// Check if we have enough balance
		uint256 ownerBalance = token.balanceOf(owner);
		if (spendAmount > ownerBalance) {
			// Should revert if trying to spend more than balance
			vm.prank(spender);
			vm.expectRevert();
			token.transferFrom(owner, recipient, spendAmount);
		} else {
			// Should succeed
			vm.prank(spender);
			token.transferFrom(owner, recipient, spendAmount);

			uint256 remainingAllowance = token.allowance(owner, spender);

			// Check if max allowance was used (special case in OpenZeppelin)
			if (allowanceAmount == type(uint256).max) {
				assertEq(remainingAllowance, type(uint256).max, "Max allowance should remain");
			} else {
				assertEq(remainingAllowance, allowanceAmount - spendAmount, "Allowance should decrease");
			}
		}
	}

	/*//////////////////////////////////////////////////////////////
                        SUPPLY FUZZ TESTS
  //////////////////////////////////////////////////////////////*/

	/// @notice Fuzz test: Total supply should remain consistent
	function testFuzz_TotalSupplyConsistency(address user, uint256 transferAmount) public {
		vm.assume(user != address(0));
		vm.assume(user != owner);
		vm.assume(user != feeRecipient);
		vm.assume(transferAmount > 0 && transferAmount <= INITIAL_SUPPLY / 2);

		uint256 totalSupplyBefore = token.totalSupply();

		// Transfer tokens
		token.transfer(user, transferAmount);

		uint256 totalSupplyAfter = token.totalSupply();

		// Total supply should not change (fees go to feeRecipient, not burned)
		assertEq(totalSupplyAfter, totalSupplyBefore, "Total supply should remain constant");
	}

	/// @notice Fuzz test: Mint should respect max supply and rate limit
	function testFuzz_MintRespectsMaxSupply(uint256 mintAmount) public {
		uint256 currentSupply = token.totalSupply();
		uint256 available = MAX_SUPPLY - currentSupply;
		uint256 maxMintPerPeriod = 10_000_000 ether; // Rate limit

		vm.assume(mintAmount > 0 && mintAmount < type(uint256).max - currentSupply); // Prevent overflow

		if (mintAmount <= available && mintAmount <= maxMintPerPeriod) {
			// Should succeed with timelock
			token.proposeMint(owner, mintAmount);
			vm.warp(block.timestamp + 7 days);
			token.executeMint();
			assertEq(token.totalSupply(), currentSupply + mintAmount);
		} else if (mintAmount > available) {
			// Should revert when proposing (exceeds max supply)
			vm.expectRevert("EXCEEDS_MAX_SUPPLY");
			token.proposeMint(owner, mintAmount);
		} else if (mintAmount > maxMintPerPeriod) {
			// Should revert when proposing (exceeds rate limit)
			vm.expectRevert("EXCEEDS_MINT_CAP_PER_PERIOD");
			token.proposeMint(owner, mintAmount);
		}
	}

	/// @notice Fuzz test: Burn should always reduce supply
	function testFuzz_BurnReducesSupply(address user, uint256 seedAmount, uint256 burnAmount) public {
		vm.assume(user != address(0));
		vm.assume(seedAmount > 0 && seedAmount <= INITIAL_SUPPLY / 10);

		// Give tokens to user
		token.transfer(user, seedAmount);

		uint256 balanceBefore = token.balanceOf(user);
		vm.assume(burnAmount > 0 && burnAmount <= balanceBefore); // Use actual balance after tax

		uint256 supplyBefore = token.totalSupply();

		// Burn tokens
		vm.prank(user);
		token.burn(burnAmount);

		assertEq(token.totalSupply(), supplyBefore - burnAmount, "Supply should decrease by burn amount");
		assertEq(token.balanceOf(user), balanceBefore - burnAmount, "Balance should decrease by burn amount");
	}

	/*//////////////////////////////////////////////////////////////
                        POOL FUZZ TESTS
  //////////////////////////////////////////////////////////////*/

	/// @notice Fuzz test: Pool transfers apply correct taxes
	function testFuzz_PoolTaxes(address pool, address user, uint256 amount) public {
		vm.assume(pool != address(0) && user != address(0));
		vm.assume(pool != user && pool != owner);
		vm.assume(user != feeRecipient && pool != feeRecipient);
		vm.assume(amount > 1000 && amount <= INITIAL_SUPPLY / 10);

		// Add pool
		token.addPool(pool);

		// Give tokens to user
		token.transfer(user, amount);

		// Test sell (user -> pool)
		uint256 treasuryBefore = token.balanceOf(feeRecipient);
		vm.prank(user);
		token.transfer(pool, amount / 2);
		uint256 sellTax = token.balanceOf(feeRecipient) - treasuryBefore;

		// Sell tax should be sell tax only (1% with defaults)
		uint256 expectedSellTax = ((amount / 2) * 100) / BASIS_POINTS_DENOMINATOR;
		assertEq(sellTax, expectedSellTax, "Sell tax should be sell tax only");

		// Give tokens to pool
		token.transfer(pool, amount);

		// Test buy (pool -> user)
		treasuryBefore = token.balanceOf(feeRecipient);
		vm.prank(pool);
		token.transfer(user, amount / 2);
		uint256 buyTax = token.balanceOf(feeRecipient) - treasuryBefore;

		// Buy tax should be 0% (default)
		assertEq(buyTax, 0, "Buy tax should be 0% by default");
	}

	/*//////////////////////////////////////////////////////////////
                        DELEGATION FUZZ TESTS
  //////////////////////////////////////////////////////////////*/

	/// @notice Fuzz test: Delegation should not affect balance
	function testFuzz_DelegationDoesNotAffectBalance(address user, address delegate, uint256 amount) public {
		vm.assume(user != address(0) && delegate != address(0));
		vm.assume(user != owner);
		vm.assume(amount > 0 && amount <= INITIAL_SUPPLY / 10);

		// Give tokens to user
		token.transfer(user, amount);

		uint256 balanceBefore = token.balanceOf(user);

		// Delegate
		vm.prank(user);
		token.delegate(delegate);

		uint256 balanceAfter = token.balanceOf(user);

		assertEq(balanceAfter, balanceBefore, "Delegation should not change balance");
	}

	/// @notice Fuzz test: Voting power equals balance after self-delegation
	function testFuzz_VotingPowerEqualsBalance(address user, uint256 amount) public {
		vm.assume(user != address(0));
		vm.assume(user != owner);
		vm.assume(amount > 0 && amount <= INITIAL_SUPPLY / 10);

		// Give tokens to user
		token.transfer(user, amount);

		// Self-delegate
		vm.prank(user);
		token.delegate(user);

		// Roll forward to ensure checkpoint is recorded
		vm.roll(block.number + 1);

		uint256 balance = token.balanceOf(user);
		uint256 votes = token.getVotes(user);

		assertEq(votes, balance, "Voting power should equal balance after self-delegation");
	}

	/*//////////////////////////////////////////////////////////////
                        BURN MODE FUZZ TESTS
  //////////////////////////////////////////////////////////////*/

	/// @notice Fuzz test: Burn mode should reduce supply
	function testFuzz_BurnModeReducesSupply(address user, uint256 amount) public {
		vm.assume(user != address(0));
		vm.assume(user != owner);
		vm.assume(amount > 1000 && amount <= INITIAL_SUPPLY / 10);

		// Enable burn mode
		token.setFeeRecipient(address(0));

		// Give tokens to user
		token.transfer(user, amount);

		uint256 supplyBefore = token.totalSupply();

		// Transfer with burn mode
		address recipient = makeAddr("recipient");
		vm.prank(user);
		token.transfer(recipient, amount / 2);

		uint256 supplyAfter = token.totalSupply();

		// Tax should have been burned
		uint256 expectedBurn = ((amount / 2) * 100) / BASIS_POINTS_DENOMINATOR; // 1% default transfer tax
		assertEq(supplyBefore - supplyAfter, expectedBurn, "Tax should be burned in burn mode");
	}

	/*//////////////////////////////////////////////////////////////
                        INVARIANT HELPERS
  //////////////////////////////////////////////////////////////*/

	/// @notice Fuzz test: Balances should always sum to total supply
	function testFuzz_BalanceSumEqualsSupply(address user1, address user2, uint256 amount1, uint256 amount2) public {
		vm.assume(user1 != address(0) && user2 != address(0));
		vm.assume(user1 != user2);
		vm.assume(user1 != owner && user2 != owner);
		vm.assume(user1 != feeRecipient && user2 != feeRecipient);
		vm.assume(amount1 > 0 && amount1 <= INITIAL_SUPPLY / 4);
		vm.assume(amount2 > 0 && amount2 <= INITIAL_SUPPLY / 4);

		// Distribute tokens
		token.transfer(user1, amount1);
		token.transfer(user2, amount2);

		// Sum all balances
		uint256 balanceSum = token.balanceOf(owner) + token.balanceOf(user1) + token.balanceOf(user2) + token.balanceOf(feeRecipient);

		assertEq(balanceSum, token.totalSupply(), "Sum of balances should equal total supply");
	}
}

Neighbours