diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a6f1362f..a08a60f7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -273,6 +273,29 @@ forge test --match-path test/ERC20.sol forge test --gas-report ``` +### Test Structure + +Tests in Compose follow a specific organizational pattern: + +- **Facet Tests** (`test/[Feature]/[Feature]Facet.t.sol`): Test external functions of facets +- **Library Tests** (`test/[Feature]/Lib[Feature].t.sol`): Test internal library functions +- **Test Harnesses** (`test/[Feature]/harnesses/`): Special contracts that expose internal functions for testing + - Facet harnesses add initialization and helper functions + - Library harnesses expose internal functions as external + +Example structure: +``` +test/ +├── ERC20/ +│ ├── ERC20Facet.t.sol # Tests for facet external functions +│ ├── LibERC20.t.sol # Tests for library internal functions +│ └── harnesses/ +│ ├── ERC20FacetHarness.sol # Adds mint() and initialize() +│ └── LibERC20Harness.sol # Exposes internal functions +``` + +See [test/README.md](test/README.md) for detailed testing documentation and patterns. + ### Test Writing Guidelines - Write comprehensive tests for all new functionality - Test edge cases and error conditions diff --git a/README.md b/README.md index 0b32abaf..36ac480f 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,8 @@ forge build # Run tests forge test + +# For test documentation, see test/README.md ``` ## What Makes Compose Different diff --git a/test/ERC20.sol b/test/ERC20.sol deleted file mode 100644 index 7b2d8a92..00000000 --- a/test/ERC20.sol +++ /dev/null @@ -1,24 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.13; - -import {Test} from "forge-std/Test.sol"; -import {ERC20Facet} from "../src/token/ERC20/ERC20/ERC20Facet.sol"; - -contract CounterTest is Test { - ERC20Facet public erc20; - - function setUp() public { - erc20 = new ERC20Facet(); - //erc20.setNumber(0); - } - - function test_Increment() public { - //counter.increment(); - // assertEq(counter.number(), 1); - } - - function testFuzz_SetNumber(uint256 x) public { - // counter.setNumber(x); - // assertEq(counter.number(), x); - } -} diff --git a/test/ERC20/ERC20Facet.t.sol b/test/ERC20/ERC20Facet.t.sol new file mode 100644 index 00000000..2ecc159c --- /dev/null +++ b/test/ERC20/ERC20Facet.t.sol @@ -0,0 +1,1030 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +import {Test} from "forge-std/Test.sol"; +import {ERC20Facet} from "../../src/token/ERC20/ERC20/ERC20Facet.sol"; +import {ERC20FacetHarness} from "./harnesses/ERC20FacetHarness.sol"; + +contract ERC20FacetTest is Test { + ERC20FacetHarness public token; + + address public alice; + address public bob; + address public charlie; + + string constant TOKEN_NAME = "Test Token"; + string constant TOKEN_SYMBOL = "TEST"; + uint8 constant TOKEN_DECIMALS = 18; + uint256 constant INITIAL_SUPPLY = 1000000e18; + + event Transfer(address indexed _from, address indexed _to, uint256 _value); + event Approval(address indexed _owner, address indexed _spender, uint256 _value); + + function setUp() public { + alice = makeAddr("alice"); + bob = makeAddr("bob"); + charlie = makeAddr("charlie"); + + token = new ERC20FacetHarness(); + token.initialize(TOKEN_NAME, TOKEN_SYMBOL, TOKEN_DECIMALS); + token.mint(alice, INITIAL_SUPPLY); + } + + // ============================================ + // Metadata Tests + // ============================================ + + function test_Name() public view { + assertEq(token.name(), TOKEN_NAME); + } + + function test_Symbol() public view { + assertEq(token.symbol(), TOKEN_SYMBOL); + } + + function test_Decimals() public view { + assertEq(token.decimals(), TOKEN_DECIMALS); + } + + function test_TotalSupply() public view { + assertEq(token.totalSupply(), INITIAL_SUPPLY); + } + + function test_BalanceOf() public view { + assertEq(token.balanceOf(alice), INITIAL_SUPPLY); + assertEq(token.balanceOf(bob), 0); + } + + // ============================================ + // Transfer Tests + // ============================================ + + function test_Transfer() public { + uint256 amount = 100e18; + + vm.prank(alice); + vm.expectEmit(true, true, true, true); + emit Transfer(alice, bob, amount); + bool success = token.transfer(bob, amount); + + assertTrue(success); + assertEq(token.balanceOf(alice), INITIAL_SUPPLY - amount); + assertEq(token.balanceOf(bob), amount); + } + + function test_Transfer_ReturnsTrue() public { + uint256 amount = 100e18; + + vm.prank(alice); + bool result = token.transfer(bob, amount); + + assertTrue(result, "transfer should return true"); + } + + function test_Transfer_ToSelf() public { + uint256 amount = 100e18; + + vm.prank(alice); + token.transfer(alice, amount); + + assertEq(token.balanceOf(alice), INITIAL_SUPPLY); + } + + function test_Transfer_ZeroAmount() public { + vm.prank(alice); + token.transfer(bob, 0); + + assertEq(token.balanceOf(alice), INITIAL_SUPPLY); + assertEq(token.balanceOf(bob), 0); + } + + function test_Transfer_EntireBalance() public { + vm.prank(alice); + token.transfer(bob, INITIAL_SUPPLY); + + assertEq(token.balanceOf(alice), 0); + assertEq(token.balanceOf(bob), INITIAL_SUPPLY); + } + + function test_Fuzz_Transfer(address to, uint256 amount) public { + vm.assume(to != address(0)); + vm.assume(amount <= INITIAL_SUPPLY); + + vm.prank(alice); + token.transfer(to, amount); + + if (to == alice) { + assertEq(token.balanceOf(alice), INITIAL_SUPPLY); + } else { + assertEq(token.balanceOf(alice), INITIAL_SUPPLY - amount); + assertEq(token.balanceOf(to), amount); + } + } + + function test_RevertWhen_TransferToZeroAddress() public { + vm.prank(alice); + vm.expectRevert(abi.encodeWithSelector(ERC20Facet.ERC20InvalidReceiver.selector, address(0))); + token.transfer(address(0), 100e18); + } + + function test_RevertWhen_TransferInsufficientBalance() public { + uint256 amount = INITIAL_SUPPLY + 1; + + vm.prank(alice); + vm.expectRevert( + abi.encodeWithSelector(ERC20Facet.ERC20InsufficientBalance.selector, alice, INITIAL_SUPPLY, amount) + ); + token.transfer(bob, amount); + } + + function test_RevertWhen_TransferFromZeroBalance() public { + vm.prank(bob); + vm.expectRevert(abi.encodeWithSelector(ERC20Facet.ERC20InsufficientBalance.selector, bob, 0, 1)); + token.transfer(alice, 1); + } + + function test_RevertWhen_TransferOverflowsRecipient() public { + uint256 maxBalance = type(uint256).max - 100; + + // Mint near-max tokens to bob + token.mint(bob, maxBalance); + + // Alice tries to transfer 200 tokens to bob, which would overflow + vm.prank(alice); + vm.expectRevert(); // Arithmetic overflow + token.transfer(bob, 200); + } + + // ============================================ + // Approval Tests + // ============================================ + + function test_Approve() public { + uint256 amount = 100e18; + + vm.prank(alice); + vm.expectEmit(true, true, true, true); + emit Approval(alice, bob, amount); + bool success = token.approve(bob, amount); + + assertTrue(success); + assertEq(token.allowance(alice, bob), amount); + } + + function test_Approve_ReturnsTrue() public { + uint256 amount = 100e18; + + vm.prank(alice); + bool result = token.approve(bob, amount); + + assertTrue(result, "approve should return true"); + } + + function test_Approve_UpdateExisting() public { + vm.startPrank(alice); + token.approve(bob, 100e18); + token.approve(bob, 200e18); + vm.stopPrank(); + + assertEq(token.allowance(alice, bob), 200e18); + } + + function test_Approve_ZeroAmount() public { + vm.startPrank(alice); + token.approve(bob, 100e18); + token.approve(bob, 0); + vm.stopPrank(); + + assertEq(token.allowance(alice, bob), 0); + } + + function test_Fuzz_Approve(address spender, uint256 amount) public { + vm.assume(spender != address(0)); + + vm.prank(alice); + token.approve(spender, amount); + + assertEq(token.allowance(alice, spender), amount); + } + + function test_RevertWhen_ApproveZeroAddressSpender() public { + vm.prank(alice); + vm.expectRevert(abi.encodeWithSelector(ERC20Facet.ERC20InvalidSpender.selector, address(0))); + token.approve(address(0), 100e18); + } + + // ============================================ + // TransferFrom Tests + // ============================================ + + function test_TransferFrom() public { + uint256 amount = 100e18; + + vm.prank(alice); + token.approve(bob, amount); + + vm.prank(bob); + vm.expectEmit(true, true, true, true); + emit Transfer(alice, charlie, amount); + bool success = token.transferFrom(alice, charlie, amount); + + assertTrue(success); + assertEq(token.balanceOf(alice), INITIAL_SUPPLY - amount); + assertEq(token.balanceOf(charlie), amount); + assertEq(token.allowance(alice, bob), 0); + } + + function test_TransferFrom_ReturnsTrue() public { + uint256 amount = 100e18; + + vm.prank(alice); + token.approve(bob, amount); + + vm.prank(bob); + bool result = token.transferFrom(alice, charlie, amount); + + assertTrue(result, "transferFrom should return true"); + } + + function test_TransferFrom_PartialAllowance() public { + uint256 allowanceAmount = 200e18; + uint256 transferAmount = 100e18; + + vm.prank(alice); + token.approve(bob, allowanceAmount); + + vm.prank(bob); + token.transferFrom(alice, charlie, transferAmount); + + assertEq(token.balanceOf(alice), INITIAL_SUPPLY - transferAmount); + assertEq(token.balanceOf(charlie), transferAmount); + assertEq(token.allowance(alice, bob), allowanceAmount - transferAmount); + } + + function test_Fuzz_TransferFrom(uint256 approval, uint256 amount) public { + vm.assume(approval <= INITIAL_SUPPLY); + vm.assume(amount <= approval); + + vm.prank(alice); + token.approve(bob, approval); + + vm.prank(bob); + token.transferFrom(alice, charlie, amount); + + assertEq(token.balanceOf(alice), INITIAL_SUPPLY - amount); + assertEq(token.balanceOf(charlie), amount); + assertEq(token.allowance(alice, bob), approval - amount); + } + + function test_TransferFrom_UnlimitedAllowance() public { + uint256 amount = 100e18; + uint256 maxAllowance = type(uint256).max; + + // Set unlimited allowance + vm.prank(alice); + token.approve(bob, maxAllowance); + + // Perform first transfer + vm.prank(bob); + token.transferFrom(alice, charlie, amount); + + // Check that allowance remains unchanged (unlimited) + assertEq(token.allowance(alice, bob), maxAllowance); + assertEq(token.balanceOf(alice), INITIAL_SUPPLY - amount); + assertEq(token.balanceOf(charlie), amount); + + // Perform second transfer to verify allowance is still unlimited + vm.prank(bob); + token.transferFrom(alice, charlie, amount); + + // Check that allowance is still unchanged (unlimited) + assertEq(token.allowance(alice, bob), maxAllowance); + assertEq(token.balanceOf(alice), INITIAL_SUPPLY - 2 * amount); + assertEq(token.balanceOf(charlie), 2 * amount); + } + + function test_TransferFrom_UnlimitedAllowance_MultipleTransfers() public { + uint256 maxAllowance = type(uint256).max; + uint256 transferAmount = 50e18; + uint256 numTransfers = 10; + + // Set unlimited allowance + vm.prank(alice); + token.approve(bob, maxAllowance); + + // Perform multiple transfers + for (uint256 i = 0; i < numTransfers; i++) { + vm.prank(bob); + token.transferFrom(alice, charlie, transferAmount); + + // Verify allowance remains unlimited after each transfer + assertEq(token.allowance(alice, bob), maxAllowance); + } + + // Verify final balances + assertEq(token.balanceOf(alice), INITIAL_SUPPLY - (transferAmount * numTransfers)); + assertEq(token.balanceOf(charlie), transferAmount * numTransfers); + } + + function test_RevertWhen_TransferFromZeroAddressSender() public { + vm.expectRevert(abi.encodeWithSelector(ERC20Facet.ERC20InvalidSender.selector, address(0))); + token.transferFrom(address(0), bob, 100e18); + } + + function test_RevertWhen_TransferFromZeroAddressReceiver() public { + vm.prank(alice); + token.approve(bob, 100e18); + + vm.prank(bob); + vm.expectRevert(abi.encodeWithSelector(ERC20Facet.ERC20InvalidReceiver.selector, address(0))); + token.transferFrom(alice, address(0), 100e18); + } + + function test_RevertWhen_TransferFromInsufficientAllowance() public { + uint256 allowanceAmount = 50e18; + uint256 transferAmount = 100e18; + + vm.prank(alice); + token.approve(bob, allowanceAmount); + + vm.prank(bob); + vm.expectRevert( + abi.encodeWithSelector(ERC20Facet.ERC20InsufficientAllowance.selector, bob, allowanceAmount, transferAmount) + ); + token.transferFrom(alice, charlie, transferAmount); + } + + function test_RevertWhen_TransferFromInsufficientBalance() public { + uint256 amount = INITIAL_SUPPLY + 1; + + vm.prank(alice); + token.approve(bob, amount); + + vm.prank(bob); + vm.expectRevert( + abi.encodeWithSelector(ERC20Facet.ERC20InsufficientBalance.selector, alice, INITIAL_SUPPLY, amount) + ); + token.transferFrom(alice, charlie, amount); + } + + function test_RevertWhen_TransferFromNoAllowance() public { + vm.prank(bob); + vm.expectRevert(abi.encodeWithSelector(ERC20Facet.ERC20InsufficientAllowance.selector, bob, 0, 100e18)); + token.transferFrom(alice, charlie, 100e18); + } + + // ============================================ + // Burn Tests + // ============================================ + + function test_Burn() public { + uint256 amount = 100e18; + + vm.prank(alice); + vm.expectEmit(true, true, true, true); + emit Transfer(alice, address(0), amount); + token.burn(amount); + + assertEq(token.balanceOf(alice), INITIAL_SUPPLY - amount); + assertEq(token.totalSupply(), INITIAL_SUPPLY - amount); + } + + function test_Burn_EntireBalance() public { + vm.prank(alice); + token.burn(INITIAL_SUPPLY); + + assertEq(token.balanceOf(alice), 0); + assertEq(token.totalSupply(), 0); + } + + function test_Fuzz_Burn(uint256 amount) public { + vm.assume(amount <= INITIAL_SUPPLY); + + vm.prank(alice); + token.burn(amount); + + assertEq(token.balanceOf(alice), INITIAL_SUPPLY - amount); + assertEq(token.totalSupply(), INITIAL_SUPPLY - amount); + } + + function test_RevertWhen_BurnInsufficientBalance() public { + uint256 amount = INITIAL_SUPPLY + 1; + + vm.prank(alice); + vm.expectRevert( + abi.encodeWithSelector(ERC20Facet.ERC20InsufficientBalance.selector, alice, INITIAL_SUPPLY, amount) + ); + token.burn(amount); + } + + function test_RevertWhen_BurnFromZeroBalance() public { + vm.prank(bob); + vm.expectRevert(abi.encodeWithSelector(ERC20Facet.ERC20InsufficientBalance.selector, bob, 0, 1)); + token.burn(1); + } + + // ============================================ + // BurnFrom Tests + // ============================================ + + function test_BurnFrom() public { + uint256 amount = 100e18; + + vm.prank(alice); + token.approve(bob, amount); + + vm.prank(bob); + vm.expectEmit(true, true, true, true); + emit Transfer(alice, address(0), amount); + token.burnFrom(alice, amount); + + assertEq(token.balanceOf(alice), INITIAL_SUPPLY - amount); + assertEq(token.allowance(alice, bob), 0); + assertEq(token.totalSupply(), INITIAL_SUPPLY - amount); + } + + function test_BurnFrom_PartialAllowance() public { + uint256 allowanceAmount = 200e18; + uint256 burnAmount = 100e18; + + vm.prank(alice); + token.approve(bob, allowanceAmount); + + vm.prank(bob); + token.burnFrom(alice, burnAmount); + + assertEq(token.balanceOf(alice), INITIAL_SUPPLY - burnAmount); + assertEq(token.allowance(alice, bob), allowanceAmount - burnAmount); + assertEq(token.totalSupply(), INITIAL_SUPPLY - burnAmount); + } + + function test_Fuzz_BurnFrom(uint256 approval, uint256 amount) public { + vm.assume(approval <= INITIAL_SUPPLY); + vm.assume(amount <= approval); + + vm.prank(alice); + token.approve(bob, approval); + + vm.prank(bob); + token.burnFrom(alice, amount); + + assertEq(token.balanceOf(alice), INITIAL_SUPPLY - amount); + assertEq(token.allowance(alice, bob), approval - amount); + assertEq(token.totalSupply(), INITIAL_SUPPLY - amount); + } + + function test_BurnFrom_UnlimitedAllowance() public { + uint256 amount = 100e18; + uint256 maxAllowance = type(uint256).max; + + // Set unlimited allowance + vm.prank(alice); + token.approve(bob, maxAllowance); + + // Perform first burn + vm.prank(bob); + token.burnFrom(alice, amount); + + // Check that allowance remains unchanged (unlimited) + assertEq(token.allowance(alice, bob), maxAllowance); + assertEq(token.balanceOf(alice), INITIAL_SUPPLY - amount); + assertEq(token.totalSupply(), INITIAL_SUPPLY - amount); + + // Perform second burn to verify allowance is still unlimited + vm.prank(bob); + token.burnFrom(alice, amount); + + // Check that allowance is still unchanged (unlimited) + assertEq(token.allowance(alice, bob), maxAllowance); + assertEq(token.balanceOf(alice), INITIAL_SUPPLY - 2 * amount); + assertEq(token.totalSupply(), INITIAL_SUPPLY - 2 * amount); + } + + function test_BurnFrom_UnlimitedAllowance_MultipleBurns() public { + uint256 maxAllowance = type(uint256).max; + uint256 burnAmount = 50e18; + uint256 numBurns = 10; + + // Set unlimited allowance + vm.prank(alice); + token.approve(bob, maxAllowance); + + // Perform multiple burns + for (uint256 i = 0; i < numBurns; i++) { + vm.prank(bob); + token.burnFrom(alice, burnAmount); + + // Verify allowance remains unlimited after each burn + assertEq(token.allowance(alice, bob), maxAllowance); + } + + // Verify final balances and total supply + assertEq(token.balanceOf(alice), INITIAL_SUPPLY - (burnAmount * numBurns)); + assertEq(token.totalSupply(), INITIAL_SUPPLY - (burnAmount * numBurns)); + } + + function test_RevertWhen_BurnFromInsufficientAllowance() public { + uint256 allowanceAmount = 50e18; + uint256 burnAmount = 100e18; + + vm.prank(alice); + token.approve(bob, allowanceAmount); + + vm.prank(bob); + vm.expectRevert( + abi.encodeWithSelector(ERC20Facet.ERC20InsufficientAllowance.selector, bob, allowanceAmount, burnAmount) + ); + token.burnFrom(alice, burnAmount); + } + + function test_RevertWhen_BurnFromInsufficientBalance() public { + uint256 amount = INITIAL_SUPPLY + 1; + + vm.prank(alice); + token.approve(bob, amount); + + vm.prank(bob); + vm.expectRevert( + abi.encodeWithSelector(ERC20Facet.ERC20InsufficientBalance.selector, alice, INITIAL_SUPPLY, amount) + ); + token.burnFrom(alice, amount); + } + + function test_RevertWhen_BurnFromNoAllowance() public { + vm.prank(bob); + vm.expectRevert(abi.encodeWithSelector(ERC20Facet.ERC20InsufficientAllowance.selector, bob, 0, 100e18)); + token.burnFrom(alice, 100e18); + } + + // ============================================ + // EIP-2612 Permit Tests + // ============================================ + + function test_Nonces() public view { + assertEq(token.nonces(alice), 0); + assertEq(token.nonces(bob), 0); + } + + function test_DOMAIN_SEPARATOR() public view { + bytes32 expectedDomainSeparator = keccak256( + abi.encode( + keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), + keccak256(bytes(TOKEN_NAME)), + keccak256("1"), + block.chainid, + address(token) + ) + ); + assertEq(token.DOMAIN_SEPARATOR(), expectedDomainSeparator); + } + + function test_DOMAIN_SEPARATOR_ConsistentWithinSameChain() public view { + // First call - computes domain separator + bytes32 separator1 = token.DOMAIN_SEPARATOR(); + + // Second call - recomputes and should return same value for same chain ID + bytes32 separator2 = token.DOMAIN_SEPARATOR(); + + assertEq(separator1, separator2); + } + + function test_DOMAIN_SEPARATOR_RecalculatesAfterFork() public { + // Get initial domain separator on chain 1 + uint256 originalChainId = block.chainid; + bytes32 separator1 = token.DOMAIN_SEPARATOR(); + + // Simulate chain fork (chain ID changes) + vm.chainId(originalChainId + 1); + + // Domain separator should recalculate with new chain ID + bytes32 separator2 = token.DOMAIN_SEPARATOR(); + + // Separators should be different + assertTrue(separator1 != separator2); + + // New separator should match expected value for new chain ID + bytes32 expectedSeparator = keccak256( + abi.encode( + keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), + keccak256(bytes(TOKEN_NAME)), + keccak256("1"), + originalChainId + 1, + address(token) + ) + ); + assertEq(separator2, expectedSeparator); + } + + function test_Permit() public { + uint256 ownerPrivateKey = 0xA11CE; + address owner = vm.addr(ownerPrivateKey); + address spender = bob; + uint256 value = 100e18; + uint256 nonce = 0; + uint256 deadline = block.timestamp + 1 hours; + + bytes32 structHash = keccak256( + abi.encode( + keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"), + owner, + spender, + value, + nonce, + deadline + ) + ); + + bytes32 hash = keccak256(abi.encodePacked("\x19\x01", token.DOMAIN_SEPARATOR(), structHash)); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerPrivateKey, hash); + + vm.expectEmit(true, true, true, true); + emit Approval(owner, spender, value); + token.permit(owner, spender, value, deadline, v, r, s); + + assertEq(token.allowance(owner, spender), value); + assertEq(token.nonces(owner), 1); + } + + function test_Permit_IncreasesNonce() public { + uint256 ownerPrivateKey = 0xB0B; + address owner = vm.addr(ownerPrivateKey); + uint256 deadline = block.timestamp + 1 hours; + + for (uint256 i = 0; i < 3; i++) { + bytes32 structHash = keccak256( + abi.encode( + keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"), + owner, + bob, + 100e18, + i, + deadline + ) + ); + + bytes32 hash = keccak256(abi.encodePacked("\x19\x01", token.DOMAIN_SEPARATOR(), structHash)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerPrivateKey, hash); + + token.permit(owner, bob, 100e18, deadline, v, r, s); + assertEq(token.nonces(owner), i + 1); + } + } + + function test_RevertWhen_PermitExpired() public { + uint256 ownerPrivateKey = 0xA11CE; + address owner = vm.addr(ownerPrivateKey); + uint256 value = 100e18; + uint256 deadline = block.timestamp - 1; + + bytes32 structHash = keccak256( + abi.encode( + keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"), + owner, + bob, + value, + 0, + deadline + ) + ); + + bytes32 hash = keccak256(abi.encodePacked("\x19\x01", token.DOMAIN_SEPARATOR(), structHash)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerPrivateKey, hash); + + vm.expectRevert( + abi.encodeWithSelector(ERC20Facet.ERC2612InvalidSignature.selector, owner, bob, value, deadline, v, r, s) + ); + token.permit(owner, bob, value, deadline, v, r, s); + } + + function test_RevertWhen_PermitInvalidSignature() public { + uint256 ownerPrivateKey = 0xA11CE; + address owner = vm.addr(ownerPrivateKey); + uint256 wrongPrivateKey = 0xBAD; + uint256 value = 100e18; + uint256 deadline = block.timestamp + 1 hours; + + bytes32 structHash = keccak256( + abi.encode( + keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"), + owner, + bob, + value, + 0, + deadline + ) + ); + + bytes32 hash = keccak256(abi.encodePacked("\x19\x01", token.DOMAIN_SEPARATOR(), structHash)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(wrongPrivateKey, hash); + + vm.expectRevert( + abi.encodeWithSelector(ERC20Facet.ERC2612InvalidSignature.selector, owner, bob, value, deadline, v, r, s) + ); + token.permit(owner, bob, value, deadline, v, r, s); + } + + function test_RevertWhen_PermitReplay() public { + uint256 ownerPrivateKey = 0xA11CE; + address owner = vm.addr(ownerPrivateKey); + uint256 value = 100e18; + uint256 deadline = block.timestamp + 1 hours; + + bytes32 structHash = keccak256( + abi.encode( + keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"), + owner, + bob, + value, + 0, + deadline + ) + ); + + bytes32 hash = keccak256(abi.encodePacked("\x19\x01", token.DOMAIN_SEPARATOR(), structHash)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerPrivateKey, hash); + + token.permit(owner, bob, value, deadline, v, r, s); + + vm.expectRevert( + abi.encodeWithSelector(ERC20Facet.ERC2612InvalidSignature.selector, owner, bob, value, deadline, v, r, s) + ); + token.permit(owner, bob, value, deadline, v, r, s); + } + + function test_RevertWhen_PermitZeroAddressSpender() public { + uint256 ownerPrivateKey = 0xA11CE; + address owner = vm.addr(ownerPrivateKey); + uint256 value = 100e18; + uint256 deadline = block.timestamp + 1 hours; + + bytes32 structHash = keccak256( + abi.encode( + keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"), + owner, + address(0), + value, + 0, + deadline + ) + ); + + bytes32 hash = keccak256(abi.encodePacked("\x19\x01", token.DOMAIN_SEPARATOR(), structHash)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerPrivateKey, hash); + + vm.expectRevert(abi.encodeWithSelector(ERC20Facet.ERC20InvalidSpender.selector, address(0))); + token.permit(owner, address(0), value, deadline, v, r, s); + } + + function test_Permit_MaxValue() public { + uint256 ownerPrivateKey = 0xA11CE; + address owner = vm.addr(ownerPrivateKey); + uint256 value = type(uint256).max; + uint256 deadline = block.timestamp + 1 hours; + + bytes32 structHash = keccak256( + abi.encode( + keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"), + owner, + bob, + value, + 0, + deadline + ) + ); + + bytes32 hash = keccak256(abi.encodePacked("\x19\x01", token.DOMAIN_SEPARATOR(), structHash)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerPrivateKey, hash); + + token.permit(owner, bob, value, deadline, v, r, s); + + assertEq(token.allowance(owner, bob), type(uint256).max); + assertEq(token.nonces(owner), 1); + } + + function test_Permit_ThenTransferFrom() public { + uint256 ownerPrivateKey = 0xA11CE; + address owner = vm.addr(ownerPrivateKey); + uint256 permitValue = 500e18; + uint256 transferAmount = 300e18; + uint256 deadline = block.timestamp + 1 hours; + + token.mint(owner, 1000e18); + + bytes32 structHash = keccak256( + abi.encode( + keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"), + owner, + bob, + permitValue, + 0, + deadline + ) + ); + + bytes32 hash = keccak256(abi.encodePacked("\x19\x01", token.DOMAIN_SEPARATOR(), structHash)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerPrivateKey, hash); + + token.permit(owner, bob, permitValue, deadline, v, r, s); + + uint256 ownerBalanceBefore = token.balanceOf(owner); + + vm.prank(bob); + token.transferFrom(owner, charlie, transferAmount); + + assertEq(token.balanceOf(owner), ownerBalanceBefore - transferAmount); + assertEq(token.balanceOf(charlie), transferAmount); + assertEq(token.allowance(owner, bob), permitValue - transferAmount); + } + + function test_RevertWhen_PermitWrongNonce() public { + uint256 ownerPrivateKey = 0xA11CE; + address owner = vm.addr(ownerPrivateKey); + uint256 value = 100e18; + uint256 wrongNonce = 99; + uint256 deadline = block.timestamp + 1 hours; + + bytes32 structHash = keccak256( + abi.encode( + keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"), + owner, + bob, + value, + wrongNonce, + deadline + ) + ); + + bytes32 hash = keccak256(abi.encodePacked("\x19\x01", token.DOMAIN_SEPARATOR(), structHash)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerPrivateKey, hash); + + vm.expectRevert( + abi.encodeWithSelector(ERC20Facet.ERC2612InvalidSignature.selector, owner, bob, value, deadline, v, r, s) + ); + token.permit(owner, bob, value, deadline, v, r, s); + } + + function test_Permit_ZeroValue() public { + uint256 ownerPrivateKey = 0xA11CE; + address owner = vm.addr(ownerPrivateKey); + uint256 value = 0; + uint256 deadline = block.timestamp + 1 hours; + + bytes32 structHash = keccak256( + abi.encode( + keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"), + owner, + bob, + value, + 0, + deadline + ) + ); + + bytes32 hash = keccak256(abi.encodePacked("\x19\x01", token.DOMAIN_SEPARATOR(), structHash)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerPrivateKey, hash); + + token.permit(owner, bob, value, deadline, v, r, s); + + assertEq(token.allowance(owner, bob), 0); + assertEq(token.nonces(owner), 1); + } + + function test_Permit_MultipleDifferentSpenders() public { + uint256 ownerPrivateKey = 0xA11CE; + address owner = vm.addr(ownerPrivateKey); + uint256 deadline = block.timestamp + 1 hours; + + address[] memory spenders = new address[](3); + spenders[0] = bob; + spenders[1] = charlie; + spenders[2] = makeAddr("dave"); + + uint256[] memory values = new uint256[](3); + values[0] = 100e18; + values[1] = 200e18; + values[2] = 300e18; + + for (uint256 i = 0; i < spenders.length; i++) { + bytes32 structHash = keccak256( + abi.encode( + keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"), + owner, + spenders[i], + values[i], + i, + deadline + ) + ); + + bytes32 hash = keccak256(abi.encodePacked("\x19\x01", token.DOMAIN_SEPARATOR(), structHash)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerPrivateKey, hash); + + token.permit(owner, spenders[i], values[i], deadline, v, r, s); + assertEq(token.allowance(owner, spenders[i]), values[i]); + } + + assertEq(token.nonces(owner), 3); + } + + function test_Permit_OverwritesExistingAllowance() public { + uint256 ownerPrivateKey = 0xA11CE; + address owner = vm.addr(ownerPrivateKey); + uint256 deadline = block.timestamp + 1 hours; + + token.mint(owner, 1000e18); + + vm.prank(owner); + token.approve(bob, 100e18); + assertEq(token.allowance(owner, bob), 100e18); + + uint256 newValue = 500e18; + bytes32 structHash = keccak256( + abi.encode( + keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"), + owner, + bob, + newValue, + 0, + deadline + ) + ); + + bytes32 hash = keccak256(abi.encodePacked("\x19\x01", token.DOMAIN_SEPARATOR(), structHash)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerPrivateKey, hash); + + token.permit(owner, bob, newValue, deadline, v, r, s); + + assertEq(token.allowance(owner, bob), newValue); + } + + function test_RevertWhen_PermitMalformedSignature() public { + uint256 ownerPrivateKey = 0xA11CE; + address owner = vm.addr(ownerPrivateKey); + uint256 value = 100e18; + uint256 deadline = block.timestamp + 1 hours; + + bytes32 structHash = keccak256( + abi.encode( + keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"), + owner, + bob, + value, + 0, + deadline + ) + ); + + bytes32 hash = keccak256(abi.encodePacked("\x19\x01", token.DOMAIN_SEPARATOR(), structHash)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerPrivateKey, hash); + + // Test with invalid v value (should be 27 or 28) + vm.expectRevert( + abi.encodeWithSelector(ERC20Facet.ERC2612InvalidSignature.selector, owner, bob, value, deadline, 99, r, s) + ); + token.permit(owner, bob, value, deadline, 99, r, s); + + // Test with zero r value + vm.expectRevert( + abi.encodeWithSelector( + ERC20Facet.ERC2612InvalidSignature.selector, owner, bob, value, deadline, v, bytes32(0), s + ) + ); + token.permit(owner, bob, value, deadline, v, bytes32(0), s); + + // Test with zero s value + vm.expectRevert( + abi.encodeWithSelector( + ERC20Facet.ERC2612InvalidSignature.selector, owner, bob, value, deadline, v, r, bytes32(0) + ) + ); + token.permit(owner, bob, value, deadline, v, r, bytes32(0)); + } + + function test_Fuzz_Permit(uint256 ownerKey, address spender, uint256 value, uint256 deadline) public { + vm.assume(ownerKey != 0 && ownerKey < type(uint256).max / 2); + vm.assume(spender != address(0)); + vm.assume(deadline > block.timestamp); + + address owner = vm.addr(ownerKey); + + bytes32 structHash = keccak256( + abi.encode( + keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"), + owner, + spender, + value, + 0, + deadline + ) + ); + + bytes32 hash = keccak256(abi.encodePacked("\x19\x01", token.DOMAIN_SEPARATOR(), structHash)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerKey, hash); + + token.permit(owner, spender, value, deadline, v, r, s); + + assertEq(token.allowance(owner, spender), value); + assertEq(token.nonces(owner), 1); + } +} diff --git a/test/ERC20/LibERC20.t.sol b/test/ERC20/LibERC20.t.sol new file mode 100644 index 00000000..6cb2b00a --- /dev/null +++ b/test/ERC20/LibERC20.t.sol @@ -0,0 +1,445 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +import {Test} from "forge-std/Test.sol"; +import {LibERC20Harness} from "./harnesses/LibERC20Harness.sol"; +import {LibERC20} from "../../src/token/ERC20/ERC20/LibERC20.sol"; + +contract LibERC20Test is Test { + LibERC20Harness public harness; + + address public alice; + address public bob; + address public charlie; + + string constant TOKEN_NAME = "Test Token"; + string constant TOKEN_SYMBOL = "TEST"; + uint8 constant TOKEN_DECIMALS = 18; + + event Transfer(address indexed _from, address indexed _to, uint256 _value); + event Approval(address indexed _owner, address indexed _spender, uint256 _value); + + function setUp() public { + alice = makeAddr("alice"); + bob = makeAddr("bob"); + charlie = makeAddr("charlie"); + + harness = new LibERC20Harness(); + harness.initialize(TOKEN_NAME, TOKEN_SYMBOL, TOKEN_DECIMALS); + } + + // ============================================ + // Metadata Tests + // ============================================ + + function test_Name() public view { + assertEq(harness.name(), TOKEN_NAME); + } + + function test_Symbol() public view { + assertEq(harness.symbol(), TOKEN_SYMBOL); + } + + function test_Decimals() public view { + assertEq(harness.decimals(), TOKEN_DECIMALS); + } + + function test_InitialTotalSupply() public view { + assertEq(harness.totalSupply(), 0); + } + + // ============================================ + // Mint Tests + // ============================================ + + function test_Mint() public { + uint256 amount = 100e18; + + vm.expectEmit(true, true, true, true); + emit Transfer(address(0), alice, amount); + harness.mint(alice, amount); + + assertEq(harness.balanceOf(alice), amount); + assertEq(harness.totalSupply(), amount); + } + + function test_Mint_Multiple() public { + harness.mint(alice, 100e18); + harness.mint(bob, 200e18); + harness.mint(alice, 50e18); + + assertEq(harness.balanceOf(alice), 150e18); + assertEq(harness.balanceOf(bob), 200e18); + assertEq(harness.totalSupply(), 350e18); + } + + function test_Fuzz_Mint(address to, uint256 amount) public { + vm.assume(to != address(0)); + vm.assume(amount < type(uint256).max / 2); + + harness.mint(to, amount); + + assertEq(harness.balanceOf(to), amount); + assertEq(harness.totalSupply(), amount); + } + + function test_RevertWhen_MintToZeroAddress() public { + vm.expectRevert(abi.encodeWithSelector(LibERC20.ERC20InvalidReceiver.selector, address(0))); + harness.mint(address(0), 100e18); + } + + // ============================================ + // Burn Tests + // ============================================ + + function test_Burn() public { + uint256 mintAmount = 100e18; + uint256 burnAmount = 30e18; + + harness.mint(alice, mintAmount); + + vm.prank(alice); + vm.expectEmit(true, true, true, true); + emit Transfer(alice, address(0), burnAmount); + harness.burn(alice, burnAmount); + + assertEq(harness.balanceOf(alice), mintAmount - burnAmount); + assertEq(harness.totalSupply(), mintAmount - burnAmount); + } + + function test_Burn_EntireBalance() public { + uint256 amount = 100e18; + + harness.mint(alice, amount); + harness.burn(alice, amount); + + assertEq(harness.balanceOf(alice), 0); + assertEq(harness.totalSupply(), 0); + } + + function test_Fuzz_Burn(address account, uint256 mintAmount, uint256 burnAmount) public { + vm.assume(account != address(0)); + vm.assume(mintAmount < type(uint256).max / 2); + vm.assume(burnAmount <= mintAmount); + + harness.mint(account, mintAmount); + harness.burn(account, burnAmount); + + assertEq(harness.balanceOf(account), mintAmount - burnAmount); + assertEq(harness.totalSupply(), mintAmount - burnAmount); + } + + function test_RevertWhen_BurnFromZeroAddress() public { + vm.expectRevert(abi.encodeWithSelector(LibERC20.ERC20InvalidSender.selector, address(0))); + harness.burn(address(0), 100e18); + } + + function test_RevertWhen_BurnInsufficientBalance() public { + harness.mint(alice, 50e18); + + vm.expectRevert(abi.encodeWithSelector(LibERC20.ERC20InsufficientBalance.selector, alice, 50e18, 100e18)); + harness.burn(alice, 100e18); + } + + function test_RevertWhen_BurnZeroBalance() public { + vm.expectRevert(abi.encodeWithSelector(LibERC20.ERC20InsufficientBalance.selector, alice, 0, 1)); + harness.burn(alice, 1); + } + + // ============================================ + // Transfer Tests + // ============================================ + + function test_Transfer() public { + uint256 amount = 100e18; + + harness.mint(alice, 200e18); + + vm.prank(alice); + vm.expectEmit(true, true, true, true); + emit Transfer(alice, bob, amount); + harness.transfer(bob, amount); + + assertEq(harness.balanceOf(alice), 100e18); + assertEq(harness.balanceOf(bob), amount); + } + + function test_Transfer_ToSelf() public { + uint256 amount = 100e18; + + harness.mint(alice, amount); + + vm.prank(alice); + harness.transfer(alice, amount); + + assertEq(harness.balanceOf(alice), amount); + } + + function test_Transfer_ZeroAmount() public { + harness.mint(alice, 100e18); + + vm.prank(alice); + harness.transfer(bob, 0); + + assertEq(harness.balanceOf(alice), 100e18); + assertEq(harness.balanceOf(bob), 0); + } + + function test_Fuzz_Transfer(uint256 balance, uint256 amount) public { + vm.assume(balance < type(uint256).max / 2); + vm.assume(amount <= balance); + + harness.mint(alice, balance); + + vm.prank(alice); + harness.transfer(bob, amount); + + assertEq(harness.balanceOf(alice), balance - amount); + assertEq(harness.balanceOf(bob), amount); + } + + function test_RevertWhen_TransferToZeroAddress() public { + harness.mint(alice, 100e18); + + vm.prank(alice); + vm.expectRevert(abi.encodeWithSelector(LibERC20.ERC20InvalidReceiver.selector, address(0))); + harness.transfer(address(0), 100e18); + } + + function test_RevertWhen_TransferInsufficientBalance() public { + harness.mint(alice, 50e18); + + vm.prank(alice); + vm.expectRevert(abi.encodeWithSelector(LibERC20.ERC20InsufficientBalance.selector, alice, 50e18, 100e18)); + harness.transfer(bob, 100e18); + } + + function test_RevertWhen_TransferOverflowsRecipient() public { + uint256 bobBalance = type(uint256).max - 100; + uint256 aliceBalance = 200; + + // Mint near-max tokens to bob directly (bypassing totalSupply) + // This simulates a scenario where bob already has near-max tokens + bytes32 storageSlot = keccak256("compose.erc20"); + uint256 bobBalanceSlot = uint256(keccak256(abi.encode(bob, uint256(storageSlot) + 4))); // balanceOf mapping slot + vm.store(address(harness), bytes32(bobBalanceSlot), bytes32(bobBalance)); + + // Mint tokens to alice normally + harness.mint(alice, aliceBalance); + + // Alice tries to transfer 200 tokens to bob, which would overflow bob's balance + vm.prank(alice); + vm.expectRevert(); // Arithmetic overflow + harness.transfer(bob, aliceBalance); + } + + function test_RevertWhen_MintOverflowsRecipient() public { + uint256 maxBalance = type(uint256).max - 100; + + // Mint near-max tokens to alice + harness.mint(alice, maxBalance); + + // Try to mint more, which would overflow + vm.expectRevert(); // Arithmetic overflow + harness.mint(alice, 200); + } + + // ============================================ + // Approve Tests + // ============================================ + + function test_Approve() public { + uint256 amount = 100e18; + + vm.prank(alice); + vm.expectEmit(true, true, true, true); + emit Approval(alice, bob, amount); + harness.approve(bob, amount); + + assertEq(harness.allowance(alice, bob), amount); + } + + function test_Approve_UpdateExisting() public { + vm.startPrank(alice); + harness.approve(bob, 100e18); + harness.approve(bob, 200e18); + vm.stopPrank(); + + assertEq(harness.allowance(alice, bob), 200e18); + } + + function test_Approve_ToZero() public { + vm.startPrank(alice); + harness.approve(bob, 100e18); + harness.approve(bob, 0); + vm.stopPrank(); + + assertEq(harness.allowance(alice, bob), 0); + } + + function test_Fuzz_Approve(address spender, uint256 amount) public { + vm.assume(spender != address(0)); + + vm.prank(alice); + harness.approve(spender, amount); + + assertEq(harness.allowance(alice, spender), amount); + } + + function test_RevertWhen_ApproveZeroAddressSpender() public { + vm.prank(alice); + vm.expectRevert(abi.encodeWithSelector(LibERC20.ERC20InvalidSpender.selector, address(0))); + harness.approve(address(0), 100e18); + } + + // ============================================ + // TransferFrom Tests + // ============================================ + + function test_TransferFrom() public { + uint256 amount = 100e18; + + harness.mint(alice, 200e18); + + vm.prank(alice); + harness.approve(bob, amount); + + vm.prank(bob); + vm.expectEmit(true, true, true, true); + emit Transfer(alice, charlie, amount); + harness.transferFrom(alice, charlie, amount); + + assertEq(harness.balanceOf(alice), 100e18); + assertEq(harness.balanceOf(charlie), amount); + assertEq(harness.allowance(alice, bob), 0); + } + + function test_TransferFrom_PartialAllowance() public { + uint256 allowanceAmount = 200e18; + uint256 transferAmount = 100e18; + + harness.mint(alice, 300e18); + + vm.prank(alice); + harness.approve(bob, allowanceAmount); + + vm.prank(bob); + harness.transferFrom(alice, charlie, transferAmount); + + assertEq(harness.balanceOf(alice), 200e18); + assertEq(harness.balanceOf(charlie), transferAmount); + assertEq(harness.allowance(alice, bob), allowanceAmount - transferAmount); + } + + function test_Fuzz_TransferFrom(uint256 balance, uint256 approval, uint256 amount) public { + vm.assume(balance < type(uint256).max / 2); + vm.assume(approval <= balance); + vm.assume(amount <= approval); + + harness.mint(alice, balance); + + vm.prank(alice); + harness.approve(bob, approval); + + vm.prank(bob); + harness.transferFrom(alice, charlie, amount); + + assertEq(harness.balanceOf(alice), balance - amount); + assertEq(harness.balanceOf(charlie), amount); + assertEq(harness.allowance(alice, bob), approval - amount); + } + + function test_RevertWhen_TransferFromZeroAddressSender() public { + vm.expectRevert(abi.encodeWithSelector(LibERC20.ERC20InvalidSender.selector, address(0))); + harness.transferFrom(address(0), bob, 100e18); + } + + function test_RevertWhen_TransferFromZeroAddressReceiver() public { + harness.mint(alice, 100e18); + + vm.prank(alice); + harness.approve(bob, 100e18); + + vm.prank(bob); + vm.expectRevert(abi.encodeWithSelector(LibERC20.ERC20InvalidReceiver.selector, address(0))); + harness.transferFrom(alice, address(0), 100e18); + } + + function test_RevertWhen_TransferFromInsufficientAllowance() public { + uint256 allowanceAmount = 50e18; + uint256 transferAmount = 100e18; + + harness.mint(alice, 200e18); + + vm.prank(alice); + harness.approve(bob, allowanceAmount); + + vm.prank(bob); + vm.expectRevert( + abi.encodeWithSelector(LibERC20.ERC20InsufficientAllowance.selector, bob, allowanceAmount, transferAmount) + ); + harness.transferFrom(alice, charlie, transferAmount); + } + + function test_RevertWhen_TransferFromInsufficientBalance() public { + uint256 balance = 50e18; + uint256 amount = 100e18; + + harness.mint(alice, balance); + + vm.prank(alice); + harness.approve(bob, amount); + + vm.prank(bob); + vm.expectRevert(abi.encodeWithSelector(LibERC20.ERC20InsufficientBalance.selector, alice, balance, amount)); + harness.transferFrom(alice, charlie, amount); + } + + function test_RevertWhen_TransferFromNoAllowance() public { + harness.mint(alice, 100e18); + + vm.prank(bob); + vm.expectRevert(abi.encodeWithSelector(LibERC20.ERC20InsufficientAllowance.selector, bob, 0, 100e18)); + harness.transferFrom(alice, charlie, 100e18); + } + + // ============================================ + // Integration Tests + // ============================================ + + function test_MintTransferBurn_Flow() public { + harness.mint(alice, 1000e18); + assertEq(harness.totalSupply(), 1000e18); + + vm.prank(alice); + harness.transfer(bob, 300e18); + + vm.prank(bob); + harness.transfer(charlie, 100e18); + + harness.burn(alice, 200e18); + + assertEq(harness.balanceOf(alice), 500e18); + assertEq(harness.balanceOf(bob), 200e18); + assertEq(harness.balanceOf(charlie), 100e18); + assertEq(harness.totalSupply(), 800e18); + } + + function test_ApproveTransferFromBurn_Flow() public { + harness.mint(alice, 1000e18); + + vm.prank(alice); + harness.approve(bob, 500e18); + + vm.prank(bob); + harness.transferFrom(alice, charlie, 200e18); + + assertEq(harness.allowance(alice, bob), 300e18); + + harness.burn(charlie, 50e18); + + assertEq(harness.balanceOf(alice), 800e18); + assertEq(harness.balanceOf(charlie), 150e18); + assertEq(harness.totalSupply(), 950e18); + } +} diff --git a/test/ERC20/harnesses/ERC20FacetHarness.sol b/test/ERC20/harnesses/ERC20FacetHarness.sol new file mode 100644 index 00000000..623b434e --- /dev/null +++ b/test/ERC20/harnesses/ERC20FacetHarness.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +import {ERC20Facet} from "../../../src/token/ERC20/ERC20/ERC20Facet.sol"; + +/// @title ERC20FacetHarness +/// @notice Test harness for ERC20Facet that adds initialization and minting for testing +contract ERC20FacetHarness is ERC20Facet { + /// @notice Initialize the ERC20 token storage + /// @dev Only used for testing - production diamonds should initialize in constructor + function initialize(string memory _name, string memory _symbol, uint8 _decimals) external { + ERC20Storage storage s = getStorage(); + s.name = _name; + s.symbol = _symbol; + s.decimals = _decimals; + } + + /// @notice Mint tokens to an address + /// @dev Only used for testing - exposes internal mint functionality + function mint(address _to, uint256 _value) external { + ERC20Storage storage s = getStorage(); + if (_to == address(0)) { + revert ERC20InvalidReceiver(address(0)); + } + unchecked { + s.totalSupply += _value; + s.balanceOf[_to] += _value; + } + emit Transfer(address(0), _to, _value); + } +} diff --git a/test/ERC20/harnesses/LibERC20Harness.sol b/test/ERC20/harnesses/LibERC20Harness.sol new file mode 100644 index 00000000..514ad0a2 --- /dev/null +++ b/test/ERC20/harnesses/LibERC20Harness.sol @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.30; + +import {LibERC20} from "../../../src/token/ERC20/ERC20/LibERC20.sol"; + +/// @title LibERC20Harness +/// @notice Test harness that exposes LibERC20's internal functions as external +/// @dev Required for testing since LibERC20 only has internal functions +contract LibERC20Harness { + /// @notice Initialize the ERC20 token storage + /// @dev Only used for testing + function initialize(string memory _name, string memory _symbol, uint8 _decimals) external { + LibERC20.ERC20Storage storage s = LibERC20.getStorage(); + s.name = _name; + s.symbol = _symbol; + s.decimals = _decimals; + } + + /// @notice Exposes LibERC20.mint as an external function + function mint(address _account, uint256 _value) external { + LibERC20.mint(_account, _value); + } + + /// @notice Exposes LibERC20.burn as an external function + function burn(address _account, uint256 _value) external { + LibERC20.burn(_account, _value); + } + + /// @notice Exposes LibERC20.transferFrom as an external function + function transferFrom(address _from, address _to, uint256 _value) external { + LibERC20.transferFrom(_from, _to, _value); + } + + /// @notice Exposes LibERC20.transfer as an external function + function transfer(address _to, uint256 _value) external { + LibERC20.transfer(_to, _value); + } + + /// @notice Exposes LibERC20.approve as an external function + function approve(address _spender, uint256 _value) external { + LibERC20.approve(_spender, _value); + } + + /// @notice Get storage values for testing + function name() external view returns (string memory) { + return LibERC20.getStorage().name; + } + + function symbol() external view returns (string memory) { + return LibERC20.getStorage().symbol; + } + + function decimals() external view returns (uint8) { + return LibERC20.getStorage().decimals; + } + + function totalSupply() external view returns (uint256) { + return LibERC20.getStorage().totalSupply; + } + + function balanceOf(address _account) external view returns (uint256) { + return LibERC20.getStorage().balanceOf[_account]; + } + + function allowance(address _owner, address _spender) external view returns (uint256) { + return LibERC20.getStorage().allowances[_owner][_spender]; + } +} diff --git a/test/README.md b/test/README.md new file mode 100644 index 00000000..3fcfa5ae --- /dev/null +++ b/test/README.md @@ -0,0 +1,272 @@ +# Compose Test Suite + +This directory contains comprehensive tests for the Compose smart contract library. + +## Overview + +Compose follows strict design principles that ban certain Solidity features (inheritance, modifiers, public/private storage, external library functions, etc.). These constraints require specialized testing patterns to ensure code quality without violating the project's architectural rules. + +## Testing Architecture + +### The Challenge + +Compose's design constraints create unique testing challenges: + +1. **No external functions in libraries** - Libraries like `LibERC20` only expose `internal` functions, which cannot be called directly from tests +2. **No initialization functions** - Facets like `ERC20Facet` have no built-in way to initialize storage (name, symbol, decimals) +3. **No constructors in facets** - Only diamond contracts can have constructors + +### The Solution: Test Harnesses + +Test harnesses are wrapper contracts that make production code testable without modifying it. This is a standard pattern used by OpenZeppelin and other production-grade smart contract projects. + +## Directory Structure + +``` +test/ +├── README.md (this file) +│ +├── ERC20/ +│ ├── ERC20Facet.t.sol # Tests for ERC20Facet (44 tests) +│ ├── LibERC20.t.sol # Tests for LibERC20 library (34 tests) +│ │ +│ └── harnesses/ +│ ├── ERC20FacetHarness.sol # Test harness for ERC20Facet +│ └── LibERC20Harness.sol # Test harness for LibERC20 +``` + +## Test Harnesses Explained + +### ERC20FacetHarness + +**Purpose:** Extends `ERC20Facet` with test-only utilities + +**Why it's needed:** + +- `ERC20Facet` has no way to initialize storage (set token name, symbol, decimals) +- In production, diamonds handle initialization via constructors or init facets +- For testing, we need a way to set up initial state + +**What it adds:** + +```solidity +function initialize(string memory _name, string memory _symbol, uint8 _decimals) +function mint(address _to, uint256 _value) +``` + +**Usage in tests:** + +```solidity +ERC20FacetHarness token = new ERC20FacetHarness(); +token.initialize("Test Token", "TEST", 18); +token.mint(alice, 1000e18); +// Now test transfer, approve, etc. +``` + +### LibERC20Harness + +**Purpose:** Exposes `LibERC20`'s internal functions as external for testing + +**Why it's needed:** + +- `LibERC20` only has `internal` functions (per Compose's rules) +- Internal functions cannot be called from external test contracts +- We need a way to test library functionality in isolation + +**What it does:** + +```solidity +// Wraps each internal library function: +function mint(address _account, uint256 _value) external { + LibERC20.mint(_account, _value); // Calls internal function +} +``` + +**Usage in tests:** + +```solidity +LibERC20Harness harness = new LibERC20Harness(); +harness.mint(alice, 1000e18); +// Test library behavior +``` + +## Running Tests + +### Run all tests + +```bash +forge test +``` + +### Run ERC20 tests only + +```bash +forge test --match-path "test/ERC20/*.t.sol" +``` + +### Run with verbose output + +```bash +forge test --match-path "test/ERC20/*.t.sol" -vv +``` + +### Run specific test file + +```bash +forge test --match-path "test/ERC20/ERC20Facet.t.sol" +``` + +### Run specific test function + +```bash +forge test --match-test "test_Transfer" +``` + +### Generate gas report + +```bash +forge test --gas-report +``` + +## Writing New Tests + +### For New Facets + +1. Create a test harness if needed: + + ```solidity + contract MyFacetHarness is MyFacet { + function initialize(...) external { /* setup storage */ } + function testHelper(...) external { /* test utilities */ } + } + ``` + +2. Create test file: + + ```solidity + import {Test} from "forge-std/Test.sol"; + import {MyFacet} from "../../src/MyFacet.sol"; + import {MyFacetHarness} from "./harnesses/MyFacetHarness.sol"; + + contract MyFacetTest is Test { + MyFacetHarness public facet; + + function setUp() public { + facet = new MyFacetHarness(); + facet.initialize(...); + } + + function test_Functionality() public { /* ... */ } + } + ``` + +### For New Libraries + +1. Create a harness to expose internal functions: + + ```solidity + contract MyLibHarness { + function myInternalFunction(...) external { + MyLib.myInternalFunction(...); + } + + // Add view functions to read storage + function getStorageValue() external view returns (...) { + return MyLib.getStorage().value; + } + } + ``` + +2. Create test file following the same pattern as `LibERC20.t.sol` + +## Testing Best Practices + +1. **Test behavior, not implementation** - Focus on what the contract does, not how +2. **Use descriptive test names** - Follow the pattern `test_FunctionName_Scenario` +3. **Test error conditions** - Use `test_RevertWhen_Condition` naming +4. **Use fuzz testing** - Prefix with `testFuzz_` for property-based tests +5. **Test events** - Use `vm.expectEmit()` to verify event emission +6. **Arrange-Act-Assert** - Structure tests clearly with setup, action, and verification +7. **Keep harnesses minimal** - Only add what's necessary for testing + +## Test Naming Conventions + +- `test_FunctionName()` - Basic happy path test +- `test_FunctionName_Scenario()` - Specific scenario test +- `test_RevertWhen_Condition()` - Tests that verify reverts +- `testFuzz_FunctionName()` - Fuzz tests (property-based) + +## Example Test Pattern + +```solidity +function test_Transfer() public { + // Arrange + uint256 amount = 100e18; + + // Act + vm.prank(alice); + token.transfer(bob, amount); + + // Assert + assertEq(token.balanceOf(bob), amount); +} + +function test_RevertWhen_TransferInsufficientBalance() public { + vm.prank(alice); + vm.expectRevert( + abi.encodeWithSelector( + ERC20Facet.ERC20InsufficientBalance.selector, + alice, + balance, + amount + ) + ); + token.transfer(bob, tooMuchAmount); +} +``` + +## Understanding Test Output + +When tests pass, you'll see: + +``` +Ran 2 test suites: 78 tests passed, 0 failed, 0 skipped (78 total tests) +``` + +Each test shows gas usage: + +``` +[PASS] test_Transfer() (gas: 46819) +``` + +Fuzz tests show number of runs: + +``` +[PASS] testFuzz_Transfer(address,uint256) (runs: 256, μ: 42444, ~: 43179) +``` + +## Contributing + +When adding new features to Compose: + +1. Write test harnesses if the contract needs initialization or has internal functions +2. Follow existing test patterns and naming conventions +3. Aim for comprehensive coverage including error cases +4. Add fuzz tests for functions with numeric parameters +5. Verify events are emitted correctly +6. Run tests before submitting PRs: `forge test` + +## Why This Approach? + +This testing architecture: + +- ✅ Respects Compose's design constraints +- ✅ Keeps production code clean (no test-only modifications) +- ✅ Provides comprehensive coverage +- ✅ Follows industry best practices (OpenZeppelin pattern) +- ✅ Makes internal code testable +- ✅ Enables isolated unit testing + +## Questions? + +If you're unsure about testing patterns or need help writing tests for a new feature, refer to the existing test files in `test/ERC20/` as examples, or ask in the Discord community.