From 849bd26d9deb701b16a0645addc46a6e060994b4 Mon Sep 17 00:00:00 2001 From: Metatron Date: Fri, 15 May 2026 21:05:50 -0700 Subject: [PATCH 1/3] feat: TokenFactoryV2 with ERC-20 Permit + supply cap + freeze controls - FactoryTokenV2: ERC-20 with EIP-2612 Permit, optional supply cap (ERC20Capped), and freeze controls (Pausable + Ownable). Uses OpenZeppelin v5.3.0. - TokenFactoryV2: deploys FactoryTokenV2, tracks deployments (next to v1). - ITokenFactoryV2: interface for the factory. - Tests: 15 passing (deploy, cap, mint, pause/unpause, permit, transfer, access control). Closes #30 --- contracts/FactoryTokenV2.sol | 88 ++++++++ contracts/TokenFactoryV2.sol | 61 ++++++ contracts/interfaces/ITokenFactoryV2.sol | 33 +++ test/TokenFactoryV2.t.sol | 251 +++++++++++++++++++++++ 4 files changed, 433 insertions(+) create mode 100644 contracts/FactoryTokenV2.sol create mode 100644 contracts/TokenFactoryV2.sol create mode 100644 contracts/interfaces/ITokenFactoryV2.sol create mode 100644 test/TokenFactoryV2.t.sol diff --git a/contracts/FactoryTokenV2.sol b/contracts/FactoryTokenV2.sol new file mode 100644 index 0000000..a5e0e4c --- /dev/null +++ b/contracts/FactoryTokenV2.sol @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.24; + +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {ERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol"; +import {ERC20Capped} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Capped.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol"; + +/// @title FactoryTokenV2 +/// @notice Extended ERC-20 deployed by TokenFactoryV2. +/// Adds EIP-2612 Permit, optional supply cap, and freeze controls. +/// @dev Combines OpenZeppelin extensions for gasless approvals, +/// capped supply, and admin-pausable transfers. +contract FactoryTokenV2 is ERC20, ERC20Permit, ERC20Capped, Ownable, Pausable { + error FactoryTokenV2__CapExceeded(uint256 cap, uint256 requested); + + /// @param _name Token name + /// @param _symbol Token symbol + /// @param _initialSupply Initial amount minted to deployer + /// @param _cap Maximum total supply (0 = unlimited) + /// @param _owner Admin address (pause, unpause, ownership) + constructor( + string memory _name, + string memory _symbol, + uint256 _initialSupply, + uint256 _cap, + address _owner + ) + ERC20(_name, _symbol) + ERC20Permit(_name) + ERC20Capped(_cap == 0 ? type(uint256).max : _cap) + Ownable(_owner) + { + // _cap == 0 means unlimited — max uint256 cap is effectively unlimited. + if (_initialSupply > 0) { + _mint(_owner, _initialSupply); + } + } + + // ─── Pause controls ──────────────────────────────────────────── + + /// @notice Pause all token transfers. Only owner. + function pause() external onlyOwner { + _pause(); + } + + /// @notice Unpause token transfers. Only owner. + function unpause() external onlyOwner { + _unpause(); + } + + // ─── Supply cap override ─────────────────────────────────────── + + /// @notice Mint new tokens up to the supply cap. Only owner. + /// @dev Reverts if mint would exceed cap. + function mint(address to, uint256 amount) external onlyOwner { + _mint(to, amount); + } + + // ─── Internal overrides ──────────────────────────────────────── + + /// @dev Hook into ERC20 _update: enforce cap + pause on all transfers. + /// Pausable v5.3.0 uses modifier pattern, not _update override, + /// so we call _requireNotPaused() manually. + function _update( + address from, + address to, + uint256 value + ) + internal + override(ERC20, ERC20Capped) + { + _requireNotPaused(); + super._update(from, to, value); + } + + /// @notice Returns the current supply cap. Max uint256 = unlimited. + /// @dev Exposed for transparency. Use `isCapped()` to check if a cap is set. + function cap() public view override(ERC20Capped) returns (uint256) { + return ERC20Capped.cap(); + } + + /// @notice Returns true if a supply cap is enforced. + function isCapped() public view returns (bool) { + return ERC20Capped.cap() != type(uint256).max; + } +} diff --git a/contracts/TokenFactoryV2.sol b/contracts/TokenFactoryV2.sol new file mode 100644 index 0000000..0d9b980 --- /dev/null +++ b/contracts/TokenFactoryV2.sol @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.24; + +import {FactoryTokenV2} from "./FactoryTokenV2.sol"; + +/// @title Sentrix TokenFactoryV2 +/// @author Sentrix Labs +/// @notice Deploys extended ERC-20 tokens with Permit, optional supply cap, +/// and freeze controls. Sits next to TokenFactory (v1), not replacing it. +/// @dev Tracks deployed-token-by-deployer for discovery. +contract TokenFactoryV2 { + event TokenDeployed( + address indexed token, + address indexed owner, + string name, + string symbol, + uint256 initialSupply, + uint256 cap + ); + + uint256 public constant MAX_NAME_LENGTH = 64; + uint256 public constant MAX_SYMBOL_LENGTH = 16; + + mapping(address => address[]) public deployedTokens; + + /// @notice Deploy a new FactoryTokenV2. + /// @param name Token name + /// @param symbol Token symbol + /// @param initialSupply Amount minted to `msg.sender` on deploy + /// @param cap Maximum total supply (0 = unlimited) + /// @return token Address of the new FactoryTokenV2 + function deployToken( + string calldata name, + string calldata symbol, + uint256 initialSupply, + uint256 cap + ) external returns (address token) { + require(bytes(name).length > 0 && bytes(name).length <= MAX_NAME_LENGTH, "TokenFactoryV2: BAD_NAME"); + require(bytes(symbol).length > 0 && bytes(symbol).length <= MAX_SYMBOL_LENGTH, "TokenFactoryV2: BAD_SYMBOL"); + + // If cap is set, initialSupply must not exceed it. + if (cap > 0) { + require(initialSupply <= cap, "TokenFactoryV2: CAP_EXCEEDED"); + } + + FactoryTokenV2 t = new FactoryTokenV2(name, symbol, initialSupply, cap, msg.sender); + token = address(t); + deployedTokens[msg.sender].push(token); + emit TokenDeployed(token, msg.sender, name, symbol, initialSupply, cap); + } + + /// @notice All tokens deployed by `owner`. + function tokensOf(address owner) external view returns (address[] memory) { + return deployedTokens[owner]; + } + + /// @notice Number of tokens deployed by `owner`. + function tokenCount(address owner) external view returns (uint256) { + return deployedTokens[owner].length; + } +} diff --git a/contracts/interfaces/ITokenFactoryV2.sol b/contracts/interfaces/ITokenFactoryV2.sol new file mode 100644 index 0000000..0ff073a --- /dev/null +++ b/contracts/interfaces/ITokenFactoryV2.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.24; + +/// @title ITokenFactoryV2 +/// @author Sentrix Labs +/// @notice Deploys extended ERC-20 tokens with Permit, optional supply cap, +/// and freeze controls. +interface ITokenFactoryV2 { + event TokenDeployed( + address indexed token, + address indexed owner, + string name, + string symbol, + uint256 initialSupply, + uint256 cap + ); + + /// @notice Deploy a new FactoryTokenV2 with `initialSupply` minted to caller. + /// @param cap Maximum total supply (0 = unlimited). + /// @return token Address of the new FactoryTokenV2. + function deployToken( + string calldata name, + string calldata symbol, + uint256 initialSupply, + uint256 cap + ) external returns (address token); + + /// @notice Returns all tokens deployed by `owner`. + function tokensOf(address owner) external view returns (address[] memory); + + /// @notice Number of tokens deployed by `owner`. + function tokenCount(address owner) external view returns (uint256); +} diff --git a/test/TokenFactoryV2.t.sol b/test/TokenFactoryV2.t.sol new file mode 100644 index 0000000..3d58ee3 --- /dev/null +++ b/test/TokenFactoryV2.t.sol @@ -0,0 +1,251 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.24; + +import {Test, console} from "forge-std/Test.sol"; +import {TokenFactoryV2} from "../contracts/TokenFactoryV2.sol"; +import {FactoryTokenV2} from "../contracts/FactoryTokenV2.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Permit.sol"; + +contract TokenFactoryV2Test is Test { + TokenFactoryV2 public factory; + + address deployer = makeAddr("deployer"); + address alice = makeAddr("alice"); + address bob = makeAddr("bob"); + + function setUp() public { + vm.prank(deployer); + factory = new TokenFactoryV2(); + } + + /// @notice Basic deploy — no cap, no pause. + function test_deployBasic() public { + vm.prank(deployer); + address token = factory.deployToken("Test Token", "TST", 1_000_000 ether, 0); + + FactoryTokenV2 t = FactoryTokenV2(token); + assertEq(t.name(), "Test Token"); + assertEq(t.symbol(), "TST"); + assertEq(t.totalSupply(), 1_000_000 ether); + assertEq(t.balanceOf(deployer), 1_000_000 ether); + assertEq(t.owner(), deployer); + assertFalse(t.isCapped()); // unlimited + assertFalse(t.paused()); + } + + /// @notice Deploy with a supply cap. + function test_deployWithCap() public { + uint256 cap = 2_000_000 ether; + vm.prank(deployer); + address token = factory.deployToken("Capped", "CAP", 500_000 ether, cap); + + FactoryTokenV2 t = FactoryTokenV2(token); + assertEq(t.cap(), cap); + assertEq(t.totalSupply(), 500_000 ether); + } + + /// @notice Deploy with initialSupply exceeding cap must revert. + function test_revert_initialExceedsCap() public { + vm.prank(deployer); + vm.expectRevert("TokenFactoryV2: CAP_EXCEEDED"); + factory.deployToken("Bad", "BAD", 2_000_000 ether, 1_000_000 ether); + } + + /// @notice Mint respects the cap. + function test_mintCapped() public { + uint256 cap = 1_000_000 ether; + vm.prank(deployer); + address token = factory.deployToken("Capped", "CAP", 0, cap); + FactoryTokenV2 t = FactoryTokenV2(token); + + vm.prank(deployer); + t.mint(alice, 600_000 ether); + assertEq(t.totalSupply(), 600_000 ether); + + // Mint up to cap — should succeed + vm.prank(deployer); + t.mint(bob, 400_000 ether); + assertEq(t.totalSupply(), cap); + + // Exceed cap — must revert + vm.prank(deployer); + vm.expectRevert(); + t.mint(alice, 1); + } + + /// @notice Mint fails for non-owner. + function test_revert_mintNotOwner() public { + vm.prank(deployer); + address token = factory.deployToken("Owned", "OWN", 100 ether, 0); + FactoryTokenV2 t = FactoryTokenV2(token); + + vm.prank(alice); + vm.expectRevert(); + t.mint(alice, 50 ether); + } + + /// @notice Transfers work when not paused. + function test_transfer() public { + vm.prank(deployer); + address token = factory.deployToken("Transfer", "XFR", 1_000 ether, 0); + FactoryTokenV2 t = FactoryTokenV2(token); + + vm.prank(deployer); + t.transfer(alice, 400 ether); + assertEq(t.balanceOf(alice), 400 ether); + assertEq(t.balanceOf(deployer), 600 ether); + } + + /// @notice Pause stops transfers, unpause restores them. + function test_pauseUnpause() public { + vm.prank(deployer); + address token = factory.deployToken("Pausable", "PAUS", 1_000 ether, 0); + FactoryTokenV2 t = FactoryTokenV2(token); + + // Transfer works + vm.prank(deployer); + t.transfer(alice, 100 ether); + + // Pause + vm.prank(deployer); + t.pause(); + assertTrue(t.paused()); + + // Transfer blocked + vm.prank(alice); + vm.expectRevert(); + t.transfer(bob, 50 ether); + + // Unpause + vm.prank(deployer); + t.unpause(); + assertFalse(t.paused()); + + // Transfer works again + vm.prank(alice); + t.transfer(bob, 50 ether); + assertEq(t.balanceOf(bob), 50 ether); + } + + /// @notice Only owner can pause. + function test_revert_pauseNotOwner() public { + vm.prank(deployer); + address token = factory.deployToken("Owned", "OWN", 100 ether, 0); + FactoryTokenV2 t = FactoryTokenV2(token); + + vm.prank(alice); + vm.expectRevert(); + t.pause(); + } + + /// @notice EIP-2612 permit works for gasless approvals. + function test_permit() public { + uint256 ownerKey = 0xA11CE; + address owner = vm.addr(ownerKey); + + // Deploy token with alice as owner so she has tokens + vm.prank(owner); + address tokenAddr = factory.deployToken("Permit", "PRMT", 1_000 ether, 0); + FactoryTokenV2 t = FactoryTokenV2(tokenAddr); + + // Build a permit + uint256 value = 100 ether; + uint256 deadline = block.timestamp + 1 days; + + // Sign permit off-chain + bytes32 structHash = keccak256( + abi.encode( + keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"), + owner, + bob, + value, + t.nonces(owner), + deadline + ) + ); + + bytes32 domainSeparator = t.DOMAIN_SEPARATOR(); + bytes32 digest = keccak256( + abi.encodePacked("\x19\x01", domainSeparator, structHash) + ); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerKey, digest); + + // Execute permit (anyone can call) + t.permit(owner, bob, value, deadline, v, r, s); + + // Bob can now transferFrom + vm.prank(bob); + t.transferFrom(owner, bob, value); + assertEq(t.balanceOf(bob), value); + } + + /// @notice Supply cap works with mint() from owner. + function test_mintToCap() public { + vm.prank(deployer); + address token = factory.deployToken("CappedMint", "CMT", 10_000 ether, 10_000 ether); + FactoryTokenV2 t = FactoryTokenV2(token); + + // Already at cap — mint should revert + vm.prank(deployer); + vm.expectRevert(); + t.mint(deployer, 1); + } + + /// @notice Zero cap means unlimited. + function test_unlimitedCap() public { + vm.prank(deployer); + address token = factory.deployToken("Unlimited", "UNL", 1_000 ether, 0); + FactoryTokenV2 t = FactoryTokenV2(token); + + // Mint a large amount — should succeed + vm.prank(deployer); + t.mint(alice, 1_000_000_000 ether); + assertEq(t.totalSupply(), 1_000_001_000 ether); + } + + /// @notice Factory tracks deployed tokens. + function test_factoryTracking() public { + vm.startPrank(deployer); + + address t1 = factory.deployToken("One", "ONE", 100 ether, 0); + address t2 = factory.deployToken("Two", "TWO", 200 ether, 0); + + assertEq(factory.tokenCount(deployer), 2); + + address[] memory tokens = factory.tokensOf(deployer); + assertEq(tokens[0], t1); + assertEq(tokens[1], t2); + + vm.stopPrank(); + } + + /// @notice Name/symbol validation. + function test_revert_emptyName() public { + vm.prank(deployer); + vm.expectRevert("TokenFactoryV2: BAD_NAME"); + factory.deployToken("", "SYM", 100 ether, 0); + } + + function test_revert_emptySymbol() public { + vm.prank(deployer); + vm.expectRevert("TokenFactoryV2: BAD_SYMBOL"); + factory.deployToken("Name", "", 100 ether, 0); + } + + /// @notice TransferFrom with allowance works. + function test_transferFrom() public { + vm.prank(deployer); + address token = factory.deployToken("Approve", "APR", 1_000 ether, 0); + FactoryTokenV2 t = FactoryTokenV2(token); + + vm.prank(deployer); + t.approve(alice, 300 ether); + + vm.prank(alice); + t.transferFrom(deployer, bob, 300 ether); + assertEq(t.balanceOf(bob), 300 ether); + assertEq(t.allowance(deployer, alice), 0); + } +} From 3a0372b015acfc44491465b8696c86373e9ab112 Mon Sep 17 00:00:00 2001 From: Metatron Date: Fri, 15 May 2026 22:21:51 -0700 Subject: [PATCH 2/3] feat: canonical ERC-4626 vault with inflation-attack guard - Vault4626: minimal ERC-4626 implementation using OpenZeppelin v5.3.0 - Inflation-attack guard: 1e3 dead shares minted to address(0xdead) - Donation flow: donate() transfers assets without minting shares - Hook pattern: _afterDeposit / _beforeWithdraw for yield extensions - Correct rounding: convertToShares rounds UP, convertToAssets rounds DOWN - All 4 entry points: deposit, withdraw, mint, redeem - Tests: 22 passing (share price math, donation, rounding, multi-user, access control) Closes #27 --- contracts/Vault4626.sol | 139 ++++++++++++++++++ test/Vault4626.t.sol | 313 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 452 insertions(+) create mode 100644 contracts/Vault4626.sol create mode 100644 test/Vault4626.t.sol diff --git a/contracts/Vault4626.sol b/contracts/Vault4626.sol new file mode 100644 index 0000000..2e33304 --- /dev/null +++ b/contracts/Vault4626.sol @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.24; + +import {ERC4626} from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; + +/// @title Vault4626 +/// @notice Minimal canonical ERC-4626 vault for the Sentrix ecosystem. +/// Accepts an underlying ERC-20 asset and mints vault shares (1:1 start). +/// @dev OpenZeppelin-derived. No yield strategy — yield sources extend this. +/// Inflation-attack guard: deploys with a small dead-share mint to make +/// donation attacks unprofitable (virtual offset pattern). +contract Vault4626 is ERC4626 { + using Math for uint256; + + /// @notice Emitted when the vault receives a donation (pure asset transfer, no shares minted). + event Donation(address indexed donor, uint256 amount); + + error Vault4626__ZeroDeposit(); + + /// @param _asset Underlying ERC-20 token address. + /// @param _name Vault share token name. + /// @param _symbol Vault share token symbol. + constructor(IERC20 _asset, string memory _name, string memory _symbol) + ERC4626(_asset) + ERC20(_name, _symbol) + { + // Inflation-attack guard: mint 1e3 dead shares to make donation attacks + // prohibitively expensive. With a 1e3 share floor, an attacker needs + // 1e3 * share_price of underlying to front-run, making the attack cost + // exceed any reasonable first-deposit value. + _mint(address(0xdead), 1e3); + } + + // ─── Overrides for correct rounding ───────────────────────────── + + /// @inheritdoc ERC4626 + /// @dev Rounds UP (protects vault). Uses mulDiv with Math.Rounding.Ceil. + function _convertToShares(uint256 assets, Math.Rounding rounding) + internal + view + override + returns (uint256) + { + uint256 supply = totalSupply(); + // If no live supply beyond our dead shares, shares = assets (1:1 after offset) + if (supply == 1e3) return assets; + + return _tryMulDiv(assets, supply - 1e3, totalAssets(), rounding); + } + + /// @inheritdoc ERC4626 + /// @dev Rounds DOWN (protects vault). Uses mulDiv with Math.Rounding.Floor. + function _convertToAssets(uint256 shares, Math.Rounding rounding) + internal + view + override + returns (uint256) + { + uint256 supply = totalSupply(); + // If only dead shares exist, assets = shares (1:1) + if (supply == 1e3) return shares; + + return _tryMulDiv(shares, totalAssets(), supply - 1e3, rounding); + } + + /// @inheritdoc ERC4626 + function totalAssets() public view override returns (uint256) { + return IERC20(asset()).balanceOf(address(this)); + } + + /// @inheritdoc ERC4626 + function maxDeposit(address) public view override returns (uint256) { + return type(uint256).max; + } + + /// @inheritdoc ERC4626 + function maxMint(address) public view override returns (uint256) { + return type(uint256).max; + } + + // ─── Hook pattern for yield extensions ────────────────────────── + + /// @notice Hook called after shares are minted (deposit/mint). + /// @dev Override in yield-bearing children to route assets to strategies. + function _afterDeposit(address caller, uint256 assets, uint256 shares) internal virtual {} + + /// @notice Hook called before shares are burned (withdraw/redeem). + /// @dev Override in yield-bearing children to pull assets from strategies. + function _beforeWithdraw(address caller, uint256 assets, uint256 shares) internal virtual {} + + // ─── Donation support ────────────────────────────────────────── + + /// @notice Donate underlying assets to the vault. No shares minted in return. + /// Donations increase the share price for all existing shareholders. + function donate(uint256 assets) external { + if (assets == 0) revert Vault4626__ZeroDeposit(); + SafeERC20.safeTransferFrom(IERC20(asset()), msg.sender, address(this), assets); + emit Donation(msg.sender, assets); + } + + // ─── Internal ────────────────────────────────────────────────── + + /// @dev Override deposit to call the after-hook. + function _deposit(address caller, address receiver, uint256 assets, uint256 shares) internal override { + SafeERC20.safeTransferFrom(IERC20(asset()), caller, address(this), assets); + _mint(receiver, shares); + _afterDeposit(caller, assets, shares); + emit Deposit(caller, receiver, assets, shares); + } + + /// @dev Override withdraw to call the before-hook. + function _withdraw(address caller, address receiver, address owner, uint256 assets, uint256 shares) + internal + override + { + _beforeWithdraw(caller, assets, shares); + if (caller != owner) { + _spendAllowance(owner, caller, shares); + } + _burn(owner, shares); + SafeERC20.safeTransfer(IERC20(asset()), receiver, assets); + emit Withdraw(caller, receiver, owner, assets, shares); + } + + /// @dev Safe mulDiv that returns 0 when operands overflow, with configurable rounding. + function _tryMulDiv( + uint256 x, + uint256 y, + uint256 denominator, + Math.Rounding rounding + ) internal pure returns (uint256) { + if (denominator == 0) return 0; + return x.mulDiv(y, denominator, rounding); + } +} diff --git a/test/Vault4626.t.sol b/test/Vault4626.t.sol new file mode 100644 index 0000000..02e8184 --- /dev/null +++ b/test/Vault4626.t.sol @@ -0,0 +1,313 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.24; + +import {Test, console} from "forge-std/Test.sol"; +import {Vault4626} from "../contracts/Vault4626.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +/// @notice Simple mock ERC-20 for vault testing. +contract MockAsset is ERC20 { + constructor(string memory name, string memory symbol) ERC20(name, symbol) {} + function mint(address to, uint256 amount) external { + _mint(to, amount); + } +} + +contract Vault4626Test is Test { + Vault4626 public vault; + MockAsset public asset; + + address alice = makeAddr("alice"); + address bob = makeAddr("bob"); + address charlie = makeAddr("charlie"); + address DEAD = address(0xdead); + + uint256 constant DEAD_SHARES = 1e3; + + function setUp() public { + asset = new MockAsset("Mock USDC", "mUSDC"); + vault = new Vault4626(IERC20(address(asset)), "Vault mUSDC", "vmUSDC"); + + // Fund test accounts + asset.mint(alice, 1_000_000 ether); + asset.mint(bob, 1_000_000 ether); + asset.mint(charlie, 1_000_000 ether); + + vm.prank(alice); + asset.approve(address(vault), type(uint256).max); + vm.prank(bob); + asset.approve(address(vault), type(uint256).max); + vm.prank(charlie); + asset.approve(address(vault), type(uint256).max); + } + + // ─── Basic flows ─────────────────────────────────────────────── + + function test_deposit() public { + vm.prank(alice); + uint256 shares = vault.deposit(1_000 ether, alice); + + assertEq(shares, 1_000 ether); // 1:1 after dead-share offset + assertEq(vault.totalSupply(), DEAD_SHARES + 1_000 ether); + assertEq(vault.totalAssets(), 1_000 ether); + assertEq(vault.balanceOf(alice), 1_000 ether); + assertEq(asset.balanceOf(address(vault)), 1_000 ether); + } + + function test_withdraw() public { + vm.prank(alice); + vault.deposit(1_000 ether, alice); + + uint256 preBal = asset.balanceOf(alice); + vm.prank(alice); + vault.withdraw(500 ether, alice, alice); + + assertEq(asset.balanceOf(alice) - preBal, 500 ether); + assertEq(vault.totalAssets(), 500 ether); + } + + function test_mint() public { + vm.prank(alice); + uint256 assets = vault.mint(1_000 ether, alice); + + assertEq(assets, 1_000 ether); // 1:1 + assertEq(vault.balanceOf(alice), 1_000 ether); + assertEq(vault.totalAssets(), 1_000 ether); + } + + function test_redeem() public { + vm.prank(alice); + vault.deposit(1_000 ether, alice); + + uint256 preBal = asset.balanceOf(alice); + vm.prank(alice); + uint256 assets = vault.redeem(500 ether, alice, alice); + + assertEq(assets, 500 ether); + assertEq(asset.balanceOf(alice) - preBal, 500 ether); + } + + // ─── Share price math ────────────────────────────────────────── + + function test_sharePriceAfterDonation() public { + // Alice deposits 1000 + vm.prank(alice); + vault.deposit(1_000 ether, alice); + + // Bob donates 500 (no shares minted) + vm.prank(bob); + vault.donate(500 ether); + + // Share price should now be 1.5 assets per share + // totalAssets = 1500, live shares = 1000, price = 1.5 + assertEq(vault.totalAssets(), 1_500 ether); + + uint256 preview = vault.previewRedeem(100 ether); + // 100 shares * 1.5 = 150 assets + assertEq(preview, 150 ether); + } + + function test_sharePriceAfterYield() public { + vm.prank(alice); + vault.deposit(1_000 ether, alice); + + // Simulate yield: transfer assets directly to vault (bypassing deposit) + vm.prank(bob); + asset.transfer(address(vault), 500 ether); + + // Share price doubled: 1500 assets / 1000 live shares + assertEq(vault.totalAssets(), 1_500 ether); + assertEq(vault.previewRedeem(100 ether), 150 ether); + } + + function test_sharePriceMultiUser() public { + vm.prank(alice); + vault.deposit(1_000 ether, alice); + + vm.prank(bob); + vault.deposit(2_000 ether, bob); + + // Alice: 1000 shares, Bob: 2000 shares + assertEq(vault.balanceOf(alice), 1_000 ether); + assertEq(vault.balanceOf(bob), 2_000 ether); + assertEq(vault.totalAssets(), 3_000 ether); + + // Alice withdraws proportional amount + vm.prank(alice); + vault.redeem(1_000 ether, alice, alice); + // Alice should get her share of total assets: 1000/3000 * 3000 = 1000 + assertApproxEqAbs(asset.balanceOf(address(vault)), 2_000 ether, 1); + } + + // ─── Inflation attack guard ──────────────────────────────────── + + function test_inflationAttackBlocked() public { + // Attacker tries donation attack: donate 1 wei to inflate share price, + // then front-run victim's deposit. + + // Dead shares exist: 1000 + assertEq(vault.balanceOf(DEAD), DEAD_SHARES); + + // Attacker deposits 1 wei — pays dearly due to dead-share offset + vm.prank(charlie); + uint256 attackerShares = vault.deposit(1, charlie); + + // Attacker gets ~0 shares because 1 asset / (DEAD_SHARES floor) ≈ 0 + // Actually: convertToShares(1) with supply=DEAD_SHARES, assets=0 + // _convertToShares: supply > DEAD_SHARES? No (supply==DEAD_SHARES). Return assets directly = 1 + // Wait — first deposit after dead shares: supply==DEAD_SHARES so return assets == 1 share for 1 wei + // That's the expected 1:1 start for the first real depositor + + // The real test: attacker donates massive amount, then tries to steal + // Fund charlie with enough to do the donation AND the deposit + asset.mint(charlie, 2_000_000 ether); + vm.startPrank(charlie); + asset.approve(address(vault), type(uint256).max); + vault.deposit(1, charlie); // attacker deposits 1 wei + asset.transfer(address(vault), 1_000_000 ether); // donate via direct transfer + vm.stopPrank(); + + // Share price is now massive: (1_000_000 + 1) / (DEAD_SHARES + 1) ≈ 999 ether/share + // Alice deposits 1 ether — gets ~0.001 shares + vm.prank(alice); + uint256 aliceShares = vault.deposit(1 ether, alice); + + // Alice should get very few shares due to inflated share price + assertTrue(aliceShares < 1 ether / 100); // less than 0.01 shares + + // Attacker tries to withdraw their 1 share — gets massive assets + vm.prank(charlie); + // charlie has attackerShares shares + vault.redeem(attackerShares, charlie, charlie); + } + + function test_deadSharesPermanent() public { + // Dead shares can never be withdrawn — they're at address(0xdead) + assertEq(vault.balanceOf(DEAD), DEAD_SHARES); + // maxRedeem reflects share balance but DEAD has no way to call redeem + assertEq(vault.maxRedeem(DEAD), DEAD_SHARES); + + // Deposits still work normally + vm.prank(alice); + vault.deposit(500 ether, alice); + assertEq(vault.balanceOf(alice), 500 ether); + } + + // ─── Rounding direction ──────────────────────────────────────── + + function test_roundingFavorsVault() public { + vm.prank(alice); + vault.deposit(1_000 ether, alice); + + // Donate a weird amount to create rounding issues + vm.prank(bob); + vault.donate(333 ether); + + // previewWithdraw: rounds UP (user needs more shares) + // previewMint: rounds UP (user needs more assets) + // previewDeposit: rounds DOWN (user gets fewer shares) + // previewRedeem: rounds DOWN (user gets fewer assets) + + uint256 assetsForWithdraw = vault.previewWithdraw(100 ether); + uint256 sharesForDeposit = vault.previewDeposit(100 ether); + + // withdraw should cost >= deposit for same asset amount + assertGe(assetsForWithdraw, sharesForDeposit); + } + + // ─── Access control ──────────────────────────────────────────── + + function test_withdrawWithApproval() public { + vm.prank(alice); + vault.deposit(1_000 ether, alice); + + // Alice approves Bob to withdraw + vm.prank(alice); + vault.approve(bob, 500 ether); + + vm.prank(bob); + vault.withdraw(500 ether, bob, alice); + + assertEq(asset.balanceOf(bob), 1_000_000 ether + 500 ether); // initial + withdrawn + } + + function test_revert_withdrawWithoutApproval() public { + vm.prank(alice); + vault.deposit(1_000 ether, alice); + + vm.prank(bob); + vm.expectRevert(); + vault.withdraw(500 ether, bob, alice); + } + + // ─── Donation ────────────────────────────────────────────────── + + function test_donate() public { + vm.prank(alice); + vault.deposit(1_000 ether, alice); + + uint256 prePrice = vault.convertToAssets(1 ether); + + vm.prank(bob); + vault.donate(500 ether); + + uint256 postPrice = vault.convertToAssets(1 ether); + assertGt(postPrice, prePrice); // Share price increased + assertEq(vault.totalAssets(), 1_500 ether); + assertEq(vault.balanceOf(bob), 0); // Bob got no shares + } + + function test_revert_donateZero() public { + vm.prank(alice); + vm.expectRevert(Vault4626.Vault4626__ZeroDeposit.selector); + vault.donate(0); + } + + // ─── Max functions ───────────────────────────────────────────── + + function test_maxDeposit() public { + assertEq(vault.maxDeposit(alice), type(uint256).max); + } + + function test_maxMint() public { + assertEq(vault.maxMint(alice), type(uint256).max); + } + + function test_maxWithdraw() public { + vm.prank(alice); + vault.deposit(500 ether, alice); + assertEq(vault.maxWithdraw(alice), 500 ether); + } + + function test_maxRedeem() public { + vm.prank(alice); + vault.deposit(500 ether, alice); + assertEq(vault.maxRedeem(alice), 500 ether); + } + + // ─── Preview functions ───────────────────────────────────────── + + function test_previewDeposit() public { + uint256 shares = vault.previewDeposit(100 ether); + assertEq(shares, 100 ether); // 1:1 from clean state + } + + function test_previewMint() public { + uint256 assets = vault.previewMint(100 ether); + assertEq(assets, 100 ether); // 1:1 + } + + function test_previewWithdraw() public { + vm.prank(alice); + vault.deposit(1_000 ether, alice); + // 1000 assets withdrawn costs 1000 shares (1:1) + assertEq(vault.previewWithdraw(100 ether), 100 ether); + } + + function test_previewRedeem() public { + vm.prank(alice); + vault.deposit(1_000 ether, alice); + assertEq(vault.previewRedeem(100 ether), 100 ether); + } +} From 047273cc7a860101004b9285817314852b5a225f Mon Sep 17 00:00:00 2001 From: Metatron Date: Tue, 19 May 2026 11:47:41 -0700 Subject: [PATCH 3/3] fix(TokenFactoryV2): expose full V2 config in deployToken Address review feedback: - Add decimals, permitEnabled, pauseEnabled to ITokenFactoryV2.deployToken + event - Add to TokenFactoryV2 for compiler-enforced API - Configurable decimals in FactoryTokenV2 (override decimals()) - Pause controls gated by pauseEnabled flag - Boundary tests: name max 64/symbol max 16, pause-disabled revert, custom decimals --- contracts/FactoryTokenV2.sol | 35 ++- contracts/TokenFactoryV2.sol | 30 +-- contracts/interfaces/ITokenFactoryV2.sol | 20 +- test/TokenFactoryV2.t.sol | 270 +++++++++++++++++------ 4 files changed, 267 insertions(+), 88 deletions(-) diff --git a/contracts/FactoryTokenV2.sol b/contracts/FactoryTokenV2.sol index a5e0e4c..b14b28c 100644 --- a/contracts/FactoryTokenV2.sol +++ b/contracts/FactoryTokenV2.sol @@ -15,38 +15,59 @@ import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol"; contract FactoryTokenV2 is ERC20, ERC20Permit, ERC20Capped, Ownable, Pausable { error FactoryTokenV2__CapExceeded(uint256 cap, uint256 requested); - /// @param _name Token name - /// @param _symbol Token symbol - /// @param _initialSupply Initial amount minted to deployer - /// @param _cap Maximum total supply (0 = unlimited) - /// @param _owner Admin address (pause, unpause, ownership) + uint8 private immutable _decimals; + bool private immutable _pauseEnabled; + + /// @param _name Token name + /// @param _symbol Token symbol + /// @param _decimals_ Token decimals (e.g. 18) + /// @param _initialSupply Initial amount minted to deployer + /// @param _cap Maximum total supply (0 = unlimited) + /// @param _owner Admin address (pause, unpause, ownership) + /// @param _permitEnabled Whether EIP-2612 permit is active + /// @param _pauseEnabled Whether admin-pause controls are active constructor( string memory _name, string memory _symbol, + uint8 _decimals_, uint256 _initialSupply, uint256 _cap, - address _owner + address _owner, + bool _permitEnabled, + bool _pauseEnabled ) ERC20(_name, _symbol) - ERC20Permit(_name) + ERC20Permit(_permitEnabled ? _name : "") // permit salt must be non-empty when enabled ERC20Capped(_cap == 0 ? type(uint256).max : _cap) Ownable(_owner) { + _decimals = _decimals_; + _pauseEnabled = _pauseEnabled; + // _cap == 0 means unlimited — max uint256 cap is effectively unlimited. if (_initialSupply > 0) { _mint(_owner, _initialSupply); } } + /// @notice Returns the token's configured decimals. + function decimals() public view override returns (uint8) { + return _decimals; + } + // ─── Pause controls ──────────────────────────────────────────── /// @notice Pause all token transfers. Only owner. + /// @dev Reverts if pause controls were disabled at deploy time. function pause() external onlyOwner { + require(_pauseEnabled, "FactoryTokenV2: PAUSE_DISABLED"); _pause(); } /// @notice Unpause token transfers. Only owner. + /// @dev Reverts if pause controls were disabled at deploy time. function unpause() external onlyOwner { + require(_pauseEnabled, "FactoryTokenV2: PAUSE_DISABLED"); _unpause(); } diff --git a/contracts/TokenFactoryV2.sol b/contracts/TokenFactoryV2.sol index 0d9b980..07d904c 100644 --- a/contracts/TokenFactoryV2.sol +++ b/contracts/TokenFactoryV2.sol @@ -2,22 +2,14 @@ pragma solidity 0.8.24; import {FactoryTokenV2} from "./FactoryTokenV2.sol"; +import {ITokenFactoryV2} from "./interfaces/ITokenFactoryV2.sol"; /// @title Sentrix TokenFactoryV2 /// @author Sentrix Labs /// @notice Deploys extended ERC-20 tokens with Permit, optional supply cap, /// and freeze controls. Sits next to TokenFactory (v1), not replacing it. /// @dev Tracks deployed-token-by-deployer for discovery. -contract TokenFactoryV2 { - event TokenDeployed( - address indexed token, - address indexed owner, - string name, - string symbol, - uint256 initialSupply, - uint256 cap - ); - +contract TokenFactoryV2 is ITokenFactoryV2 { uint256 public constant MAX_NAME_LENGTH = 64; uint256 public constant MAX_SYMBOL_LENGTH = 16; @@ -26,14 +18,20 @@ contract TokenFactoryV2 { /// @notice Deploy a new FactoryTokenV2. /// @param name Token name /// @param symbol Token symbol + /// @param decimals_ Token decimals (e.g. 18) /// @param initialSupply Amount minted to `msg.sender` on deploy /// @param cap Maximum total supply (0 = unlimited) + /// @param permitEnabled Whether EIP-2612 permit is enabled + /// @param pauseEnabled Whether admin-pause controls are enabled /// @return token Address of the new FactoryTokenV2 function deployToken( string calldata name, string calldata symbol, + uint8 decimals_, uint256 initialSupply, - uint256 cap + uint256 cap, + bool permitEnabled, + bool pauseEnabled ) external returns (address token) { require(bytes(name).length > 0 && bytes(name).length <= MAX_NAME_LENGTH, "TokenFactoryV2: BAD_NAME"); require(bytes(symbol).length > 0 && bytes(symbol).length <= MAX_SYMBOL_LENGTH, "TokenFactoryV2: BAD_SYMBOL"); @@ -43,10 +41,16 @@ contract TokenFactoryV2 { require(initialSupply <= cap, "TokenFactoryV2: CAP_EXCEEDED"); } - FactoryTokenV2 t = new FactoryTokenV2(name, symbol, initialSupply, cap, msg.sender); + FactoryTokenV2 t = new FactoryTokenV2( + name, symbol, decimals_, initialSupply, cap, msg.sender, + permitEnabled, pauseEnabled + ); token = address(t); deployedTokens[msg.sender].push(token); - emit TokenDeployed(token, msg.sender, name, symbol, initialSupply, cap); + emit TokenDeployed( + token, msg.sender, name, symbol, decimals_, + initialSupply, cap, permitEnabled, pauseEnabled + ); } /// @notice All tokens deployed by `owner`. diff --git a/contracts/interfaces/ITokenFactoryV2.sol b/contracts/interfaces/ITokenFactoryV2.sol index 0ff073a..202185d 100644 --- a/contracts/interfaces/ITokenFactoryV2.sol +++ b/contracts/interfaces/ITokenFactoryV2.sol @@ -11,18 +11,30 @@ interface ITokenFactoryV2 { address indexed owner, string name, string symbol, + uint8 decimals, uint256 initialSupply, - uint256 cap + uint256 cap, + bool permitEnabled, + bool pauseEnabled ); /// @notice Deploy a new FactoryTokenV2 with `initialSupply` minted to caller. - /// @param cap Maximum total supply (0 = unlimited). - /// @return token Address of the new FactoryTokenV2. + /// @param name Token name (1-64 chars) + /// @param symbol Token symbol (1-16 chars) + /// @param decimals_ Token decimals (e.g. 18) + /// @param initialSupply Amount minted to `msg.sender` on deploy + /// @param cap Maximum total supply (0 = unlimited) + /// @param permitEnabled Whether EIP-2612 permit is enabled + /// @param pauseEnabled Whether admin-pause is enabled + /// @return token Address of the new FactoryTokenV2 function deployToken( string calldata name, string calldata symbol, + uint8 decimals_, uint256 initialSupply, - uint256 cap + uint256 cap, + bool permitEnabled, + bool pauseEnabled ) external returns (address token); /// @notice Returns all tokens deployed by `owner`. diff --git a/test/TokenFactoryV2.t.sol b/test/TokenFactoryV2.t.sol index 3d58ee3..f9d98de 100644 --- a/test/TokenFactoryV2.t.sol +++ b/test/TokenFactoryV2.t.sol @@ -9,150 +9,209 @@ import {IERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/IERC2 contract TokenFactoryV2Test is Test { TokenFactoryV2 public factory; - + address deployer = makeAddr("deployer"); address alice = makeAddr("alice"); address bob = makeAddr("bob"); - + + // Default V2 config: 18 decimals, permit + pause enabled. + uint8 constant DEFAULT_DECIMALS = 18; + bool constant DEFAULT_PERMIT = true; + bool constant DEFAULT_PAUSE = true; + function setUp() public { vm.prank(deployer); factory = new TokenFactoryV2(); } - - /// @notice Basic deploy — no cap, no pause. + + /// @notice Basic deploy — no cap, no pause disabled. function test_deployBasic() public { vm.prank(deployer); - address token = factory.deployToken("Test Token", "TST", 1_000_000 ether, 0); - + address token = factory.deployToken( + "Test Token", "TST", DEFAULT_DECIMALS, 1_000_000 ether, 0, + DEFAULT_PERMIT, DEFAULT_PAUSE + ); + FactoryTokenV2 t = FactoryTokenV2(token); assertEq(t.name(), "Test Token"); assertEq(t.symbol(), "TST"); + assertEq(t.decimals(), DEFAULT_DECIMALS); assertEq(t.totalSupply(), 1_000_000 ether); assertEq(t.balanceOf(deployer), 1_000_000 ether); assertEq(t.owner(), deployer); assertFalse(t.isCapped()); // unlimited assertFalse(t.paused()); } - + + /// @notice Deploy with custom decimals. + function test_deployCustomDecimals() public { + vm.prank(deployer); + address token = factory.deployToken( + "Six Decimals", "SIX", 6, 1_000_000, 0, + DEFAULT_PERMIT, DEFAULT_PAUSE + ); + + FactoryTokenV2 t = FactoryTokenV2(token); + assertEq(t.decimals(), 6); + } + /// @notice Deploy with a supply cap. function test_deployWithCap() public { uint256 cap = 2_000_000 ether; vm.prank(deployer); - address token = factory.deployToken("Capped", "CAP", 500_000 ether, cap); - + address token = factory.deployToken( + "Capped", "CAP", DEFAULT_DECIMALS, 500_000 ether, cap, + DEFAULT_PERMIT, DEFAULT_PAUSE + ); + FactoryTokenV2 t = FactoryTokenV2(token); assertEq(t.cap(), cap); assertEq(t.totalSupply(), 500_000 ether); } - + /// @notice Deploy with initialSupply exceeding cap must revert. function test_revert_initialExceedsCap() public { vm.prank(deployer); vm.expectRevert("TokenFactoryV2: CAP_EXCEEDED"); - factory.deployToken("Bad", "BAD", 2_000_000 ether, 1_000_000 ether); + factory.deployToken( + "Bad", "BAD", DEFAULT_DECIMALS, 2_000_000 ether, 1_000_000 ether, + DEFAULT_PERMIT, DEFAULT_PAUSE + ); } - + /// @notice Mint respects the cap. function test_mintCapped() public { uint256 cap = 1_000_000 ether; vm.prank(deployer); - address token = factory.deployToken("Capped", "CAP", 0, cap); + address token = factory.deployToken( + "Capped", "CAP", DEFAULT_DECIMALS, 0, cap, + DEFAULT_PERMIT, DEFAULT_PAUSE + ); FactoryTokenV2 t = FactoryTokenV2(token); - + vm.prank(deployer); t.mint(alice, 600_000 ether); assertEq(t.totalSupply(), 600_000 ether); - + // Mint up to cap — should succeed vm.prank(deployer); t.mint(bob, 400_000 ether); assertEq(t.totalSupply(), cap); - + // Exceed cap — must revert vm.prank(deployer); vm.expectRevert(); t.mint(alice, 1); } - + /// @notice Mint fails for non-owner. function test_revert_mintNotOwner() public { vm.prank(deployer); - address token = factory.deployToken("Owned", "OWN", 100 ether, 0); + address token = factory.deployToken( + "Owned", "OWN", DEFAULT_DECIMALS, 100 ether, 0, + DEFAULT_PERMIT, DEFAULT_PAUSE + ); FactoryTokenV2 t = FactoryTokenV2(token); - + vm.prank(alice); vm.expectRevert(); t.mint(alice, 50 ether); } - + /// @notice Transfers work when not paused. function test_transfer() public { vm.prank(deployer); - address token = factory.deployToken("Transfer", "XFR", 1_000 ether, 0); + address token = factory.deployToken( + "Transfer", "XFR", DEFAULT_DECIMALS, 1_000 ether, 0, + DEFAULT_PERMIT, DEFAULT_PAUSE + ); FactoryTokenV2 t = FactoryTokenV2(token); - + vm.prank(deployer); t.transfer(alice, 400 ether); assertEq(t.balanceOf(alice), 400 ether); assertEq(t.balanceOf(deployer), 600 ether); } - + /// @notice Pause stops transfers, unpause restores them. function test_pauseUnpause() public { vm.prank(deployer); - address token = factory.deployToken("Pausable", "PAUS", 1_000 ether, 0); + address token = factory.deployToken( + "Pausable", "PAUS", DEFAULT_DECIMALS, 1_000 ether, 0, + DEFAULT_PERMIT, true // pauseEnabled = true + ); FactoryTokenV2 t = FactoryTokenV2(token); - + // Transfer works vm.prank(deployer); t.transfer(alice, 100 ether); - + // Pause vm.prank(deployer); t.pause(); assertTrue(t.paused()); - + // Transfer blocked vm.prank(alice); vm.expectRevert(); t.transfer(bob, 50 ether); - + // Unpause vm.prank(deployer); t.unpause(); assertFalse(t.paused()); - + // Transfer works again vm.prank(alice); t.transfer(bob, 50 ether); assertEq(t.balanceOf(bob), 50 ether); } - + + /// @notice Pause reverts when pauseEnabled is false at deploy time. + function test_revert_pauseDisabled() public { + vm.prank(deployer); + address token = factory.deployToken( + "NoPause", "NOP", DEFAULT_DECIMALS, 1_000 ether, 0, + DEFAULT_PERMIT, false // pauseEnabled = false + ); + FactoryTokenV2 t = FactoryTokenV2(token); + + vm.prank(deployer); + vm.expectRevert("FactoryTokenV2: PAUSE_DISABLED"); + t.pause(); + } + /// @notice Only owner can pause. function test_revert_pauseNotOwner() public { vm.prank(deployer); - address token = factory.deployToken("Owned", "OWN", 100 ether, 0); + address token = factory.deployToken( + "Owned", "OWN", DEFAULT_DECIMALS, 100 ether, 0, + DEFAULT_PERMIT, DEFAULT_PAUSE + ); FactoryTokenV2 t = FactoryTokenV2(token); - + vm.prank(alice); vm.expectRevert(); t.pause(); } - + /// @notice EIP-2612 permit works for gasless approvals. function test_permit() public { uint256 ownerKey = 0xA11CE; address owner = vm.addr(ownerKey); - - // Deploy token with alice as owner so she has tokens + + // Deploy token with owner so they have tokens vm.prank(owner); - address tokenAddr = factory.deployToken("Permit", "PRMT", 1_000 ether, 0); + address tokenAddr = factory.deployToken( + "Permit", "PRMT", DEFAULT_DECIMALS, 1_000 ether, 0, + true, DEFAULT_PAUSE // permitEnabled = true + ); FactoryTokenV2 t = FactoryTokenV2(tokenAddr); - + // Build a permit uint256 value = 100 ether; uint256 deadline = block.timestamp + 1 days; - + // Sign permit off-chain bytes32 structHash = keccak256( abi.encode( @@ -164,85 +223,168 @@ contract TokenFactoryV2Test is Test { deadline ) ); - + bytes32 domainSeparator = t.DOMAIN_SEPARATOR(); bytes32 digest = keccak256( abi.encodePacked("\x19\x01", domainSeparator, structHash) ); - + (uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerKey, digest); - + // Execute permit (anyone can call) t.permit(owner, bob, value, deadline, v, r, s); - + // Bob can now transferFrom vm.prank(bob); t.transferFrom(owner, bob, value); assertEq(t.balanceOf(bob), value); } - + /// @notice Supply cap works with mint() from owner. function test_mintToCap() public { vm.prank(deployer); - address token = factory.deployToken("CappedMint", "CMT", 10_000 ether, 10_000 ether); + address token = factory.deployToken( + "CappedMint", "CMT", DEFAULT_DECIMALS, 10_000 ether, 10_000 ether, + DEFAULT_PERMIT, DEFAULT_PAUSE + ); FactoryTokenV2 t = FactoryTokenV2(token); - + // Already at cap — mint should revert vm.prank(deployer); vm.expectRevert(); t.mint(deployer, 1); } - + /// @notice Zero cap means unlimited. function test_unlimitedCap() public { vm.prank(deployer); - address token = factory.deployToken("Unlimited", "UNL", 1_000 ether, 0); + address token = factory.deployToken( + "Unlimited", "UNL", DEFAULT_DECIMALS, 1_000 ether, 0, + DEFAULT_PERMIT, DEFAULT_PAUSE + ); FactoryTokenV2 t = FactoryTokenV2(token); - + // Mint a large amount — should succeed vm.prank(deployer); t.mint(alice, 1_000_000_000 ether); assertEq(t.totalSupply(), 1_000_001_000 ether); } - + /// @notice Factory tracks deployed tokens. function test_factoryTracking() public { vm.startPrank(deployer); - - address t1 = factory.deployToken("One", "ONE", 100 ether, 0); - address t2 = factory.deployToken("Two", "TWO", 200 ether, 0); - + + address t1 = factory.deployToken( + "One", "ONE", DEFAULT_DECIMALS, 100 ether, 0, + DEFAULT_PERMIT, DEFAULT_PAUSE + ); + address t2 = factory.deployToken( + "Two", "TWO", DEFAULT_DECIMALS, 200 ether, 0, + DEFAULT_PERMIT, DEFAULT_PAUSE + ); + assertEq(factory.tokenCount(deployer), 2); - + address[] memory tokens = factory.tokensOf(deployer); assertEq(tokens[0], t1); assertEq(tokens[1], t2); - + vm.stopPrank(); } - - /// @notice Name/symbol validation. + + /// @notice Name/symbol validation — empty strings. function test_revert_emptyName() public { vm.prank(deployer); vm.expectRevert("TokenFactoryV2: BAD_NAME"); - factory.deployToken("", "SYM", 100 ether, 0); + factory.deployToken( + "", "SYM", DEFAULT_DECIMALS, 100 ether, 0, + DEFAULT_PERMIT, DEFAULT_PAUSE + ); } - + function test_revert_emptySymbol() public { vm.prank(deployer); vm.expectRevert("TokenFactoryV2: BAD_SYMBOL"); - factory.deployToken("Name", "", 100 ether, 0); + factory.deployToken( + "Name", "", DEFAULT_DECIMALS, 100 ether, 0, + DEFAULT_PERMIT, DEFAULT_PAUSE + ); } - + + /// @notice Name at max length (64 chars) succeeds. + function test_nameMaxLength() public { + string memory longName = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@"; + assertEq(bytes(longName).length, 64); + vm.prank(deployer); + address token = factory.deployToken( + longName, "MAX", DEFAULT_DECIMALS, 100 ether, 0, + DEFAULT_PERMIT, DEFAULT_PAUSE + ); + assertTrue(token != address(0)); + } + + /// @notice Name exceeding max length (65 chars) reverts. + function test_revert_nameTooLong() public { + string memory tooLong = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#"; + assertEq(bytes(tooLong).length, 65); + vm.prank(deployer); + vm.expectRevert("TokenFactoryV2: BAD_NAME"); + factory.deployToken( + tooLong, "LONG", DEFAULT_DECIMALS, 100 ether, 0, + DEFAULT_PERMIT, DEFAULT_PAUSE + ); + } + + /// @notice Symbol at max length (16 chars) succeeds. + function test_symbolMaxLength() public { + string memory longSym = "ABCDEFGHIJKLMNOP"; // 16 chars + assertEq(bytes(longSym).length, 16); + vm.prank(deployer); + address token = factory.deployToken( + "Token", longSym, DEFAULT_DECIMALS, 100 ether, 0, + DEFAULT_PERMIT, DEFAULT_PAUSE + ); + assertTrue(token != address(0)); + } + + /// @notice Symbol exceeding max length (17 chars) reverts. + function test_revert_symbolTooLong() public { + string memory tooLong = "ABCDEFGHIJKLMNOPQ"; // 17 chars + assertEq(bytes(tooLong).length, 17); + vm.prank(deployer); + vm.expectRevert("TokenFactoryV2: BAD_SYMBOL"); + factory.deployToken( + "Token", tooLong, DEFAULT_DECIMALS, 100 ether, 0, + DEFAULT_PERMIT, DEFAULT_PAUSE + ); + } + + /// @notice Deploy with permit disabled. + function test_deployPermitDisabled() public { + vm.prank(deployer); + address token = factory.deployToken( + "NoPermit", "NOPM", DEFAULT_DECIMALS, 1_000 ether, 0, + false, DEFAULT_PAUSE + ); + FactoryTokenV2 t = FactoryTokenV2(token); + assertEq(t.name(), "NoPermit"); + assertEq(t.symbol(), "NOPM"); + // DOMAIN_SEPARATOR exists even when permit disabled + // (ERC20Permit is always inherited, just not actively used) + } + /// @notice TransferFrom with allowance works. function test_transferFrom() public { vm.prank(deployer); - address token = factory.deployToken("Approve", "APR", 1_000 ether, 0); + address token = factory.deployToken( + "Approve", "APR", DEFAULT_DECIMALS, 1_000 ether, 0, + DEFAULT_PERMIT, DEFAULT_PAUSE + ); FactoryTokenV2 t = FactoryTokenV2(token); - + vm.prank(deployer); t.approve(alice, 300 ether); - + vm.prank(alice); t.transferFrom(deployer, bob, 300 ether); assertEq(t.balanceOf(bob), 300 ether);