Skip to content

Commit a86e7b2

Browse files
CodesenSysclaude
andcommitted
Complete test suite and fix all errors and warnings
Errors fixed: - Rename Modifiers.owner -> OWNER (SCREAMING_SNAKE_CASE for immutables; resolves token.OWNER() compile error in MintBurn tests) - Remove 158 lines of commented-out legacy code from MintBurn.t.sol Tests fixed/added: - Rewrite ERC20Invariant.t.sol with a proper Handler contract: mint/burn/transfer/approve/transferFrom with ghost-variable actor tracking; 4 invariants verified across 128k calls each - Add test/fuzz/ERC20Fuzz.t.sol: 9 stateless fuzz properties covering supply conservation, allowance accounting, cap enforcement, and round-trip balance symmetry - Fix 3 tests that wrongly asserted reverts for Solady-allowed operations (transfer to zero address, zero-amount transfer, transferFrom to zero); replace with tests documenting actual Solady behaviour - Add assertTrue() on all non-revert transfer/transferFrom call sites; add intentionally-unchecked comments on vm.expectRevert() sites Compiler warnings resolved: - Remove unused Errors import from Transfer.t.sol - Extract _checkOwner() helper in Modifiers.sol (unwrapped-modifier-logic) - Rename Handler immutables to TOKEN / TOKEN_OWNER (screaming-snake-case) Infrastructure: - Add [profile.ci] to foundry.toml (fuzz: 10k runs, invariant: 256x512) - Update README project structure to match actual src/ and test/ layout All 50 tests pass. forge fmt --check passes. forge build --sizes clean. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent a77a87b commit a86e7b2

13 files changed

Lines changed: 480 additions & 276 deletions

File tree

README.md

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -161,15 +161,30 @@ This ensures:
161161

162162
```
163163
src/
164-
├── ERC20.sol # Core ERC20 implementation
164+
├── core/
165+
│ ├── BaseERC20.sol # Abstract ERC20 base; adds zero-address/amount guards
166+
│ ├── Constants.sol # Token metadata, INITIAL_SUPPLY, MAX_SUPPLY
167+
│ ├── Errors.sol # Centralised custom error definitions
168+
│ └── Modifiers.sol # Immutable OWNER and onlyOwner modifier
165169
├── interfaces/
166-
│ └── IERC20.sol # ERC20 interface
167-
└── extensions/ # Future extensions (cap, roles, etc.)
170+
│ └── IERC20.sol # Full EIP-20 interface + mint/burn extensions
171+
├── tokens/
172+
│ └── CodesenSysToken.sol # Main token: owner-gated mint, open burn, cap enforced
173+
└── extensions/ # Reserved for future policy layers (roles, pausing, etc.)
168174
169175
test/
170-
├── ERC20.t.sol # Unit tests
171-
├── ERC20.fuzz.t.sol # Fuzz tests
172-
└── ERC20.invariant.t.sol # Invariant tests
176+
├── unit/
177+
│ ├── BaseTest.t.sol # Shared setUp — deploys token with makeAddr owner
178+
│ ├── Transfer.t.sol # transfer() unit and fuzz tests
179+
│ ├── Allowance.t.sol # approve() / transferFrom() unit and fuzz tests
180+
│ └── MintBurn.t.sol # mint() / burn() unit and fuzz tests
181+
├── fuzz/
182+
│ └── ERC20Fuzz.t.sol # Stateless multi-step fuzz properties
183+
└── invariant/
184+
└── ERC20Invariant.t.sol # Stateful invariant suite with Handler contract
185+
186+
script/
187+
└── Deploy.s.sol # Foundry deployment script
173188
```
174189

175190
---

foundry.toml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,15 @@ remappings = [
88
"@solady-utils/=lib/solady/src/utils/",
99
]
1010

11+
# CI profile — used by .github/workflows/test.yml (FOUNDRY_PROFILE=ci).
12+
# Increases fuzz and invariant runs for more thorough coverage in CI.
13+
[profile.ci]
14+
fuzz = { runs = 10_000 }
15+
invariant = { runs = 256, depth = 512 }
16+
1117
[rpc_endpoints]
1218
sepolia = "${SEPOLIA_RPC_URL}"
13-
19+
1420
[etherscan]
1521
sepolia = { key = "${ETHERSCAN_API_KEY}" }
1622

src/core/Constants.sol

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
// SPDX-License-Identifier: MIT
22
pragma solidity ^0.8.4;
3-
library Constants {
43

4+
library Constants {
55
// Token Metadata
6-
string internal constant TOKEN_NAME = "CodesenSys Token";
7-
string internal constant TOKEN_SYMBOL = "CSS";
8-
uint8 internal constant DECIMALS = 18;
6+
string internal constant TOKEN_NAME = "CodesenSys Token";
7+
string internal constant TOKEN_SYMBOL = "CSS";
8+
uint8 internal constant DECIMALS = 18;
99
uint256 private constant _SCALAR = 10 ** uint256(DECIMALS);
1010
uint256 internal constant INITIAL_SUPPLY = 1_000_000 * _SCALAR;
11-
11+
1212
/// @dev Hard cap of 1,000,000,000 CSS (1 Billion).
1313
uint256 internal constant MAX_SUPPLY = 1_000_000_000 * _SCALAR;
1414
}

src/core/Errors.sol

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,4 @@ library Errors {
1818

1919
/// @dev Thrown when a mint would push totalSupply past MAX_SUPPLY.
2020
error ExceedsCap();
21-
}
21+
}

src/core/Modifiers.sol

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,21 +9,28 @@ import {Errors} from "./Errors.sol";
99
/// onlyOwner modifier. Inheriting contracts pass the owner address
1010
/// through the constructor — no storage slot is used (immutable).
1111
abstract contract Modifiers {
12-
1312
// State
14-
address public immutable owner;
13+
address public immutable OWNER;
1514

1615
// Constructor
1716
constructor(address _owner) {
1817
if (_owner == address(0)) revert Errors.ZeroAddress();
19-
owner = _owner;
18+
OWNER = _owner;
2019
}
2120

2221
// Modifiers
22+
2323
/// @dev Reverts with a custom error when the caller is not the OWNER.
24-
/// Custom errors are cheaper than string reverts (less calldata).
24+
/// Logic is extracted to _checkOwner() so the modifier body is a single
25+
/// internal call, reducing inlined bytecode at every call site.
2526
modifier onlyOwner() {
26-
if (msg.sender != owner) revert Errors.NotAuthorized();
27+
_checkOwner();
2728
_;
2829
}
29-
}
30+
31+
// Internal helpers
32+
33+
function _checkOwner() internal view {
34+
if (msg.sender != OWNER) revert Errors.NotAuthorized();
35+
}
36+
}

src/interfaces/IERC20.sol

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ pragma solidity ^0.8.4;
99
/// this interface lets external contracts and off-chain
1010
/// tooling reference the complete ABI from a single file.
1111
interface IERC20 {
12-
1312
// =========================================================================
1413
// Events (EIP-20 required)
1514
// =========================================================================

src/tokens/CodesenSysToken.sol

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,11 @@ import {Errors} from "../core/Errors.sol";
1010
/// @author CodesenSys
1111
/// @notice ERC-20 token with owner-gated minting, open burning, and a hard 1 billion token supply cap.
1212
contract CodesenSysToken is BaseERC20, Modifiers {
13-
1413
// Constructor
1514

1615
/// @param _owner Receives the initial supply and mint rights.
1716
/// @param initialSupply Tokens minted at deployment (use Constants.INITIAL_SUPPLY).
18-
constructor(address _owner, uint256 initialSupply)
19-
Modifiers(_owner)
20-
{
17+
constructor(address _owner, uint256 initialSupply) Modifiers(_owner) {
2118
_mintInternal(_owner, initialSupply);
2219
}
2320

test/fuzz/ERC20Fuzz.t.sol

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity ^0.8.4;
3+
4+
import {Test} from "forge-std/Test.sol";
5+
import {CodesenSysToken} from "../../src/tokens/CodesenSysToken.sol";
6+
import {Constants} from "../../src/core/Constants.sol";
7+
import {Errors} from "../../src/core/Errors.sol";
8+
9+
/// @notice Stateless fuzz properties for CodesenSysToken.
10+
///
11+
/// @dev Unlike the unit tests (which fix inputs) and the invariant suite
12+
/// (which maintains state across calls), each test here is stateless:
13+
/// the fuzzer supplies fresh random inputs on every run and the test
14+
/// asserts a property that must hold universally.
15+
///
16+
/// Properties covered:
17+
/// - Supply conservation across mint→transfer→burn sequences
18+
/// - Allowance accounting with arbitrary approve/spend pairs
19+
/// - Cap enforcement across the full mintable range
20+
/// - Balance symmetry after round-trip transfers
21+
contract ERC20FuzzTest is Test {
22+
CodesenSysToken public token;
23+
24+
address public owner = makeAddr("owner");
25+
address public alice = makeAddr("alice");
26+
address public bob = makeAddr("bob");
27+
address public carol = makeAddr("carol");
28+
29+
uint256 public constant INITIAL_MINT = Constants.INITIAL_SUPPLY;
30+
31+
function setUp() public {
32+
token = new CodesenSysToken(owner, INITIAL_MINT);
33+
}
34+
35+
// =========================================================================
36+
// Supply conservation
37+
// =========================================================================
38+
39+
/// @notice Minting then burning the same amount leaves totalSupply unchanged.
40+
function testFuzz_Supply_MintThenBurnIsNeutral(uint256 amount) public {
41+
uint256 remaining = Constants.MAX_SUPPLY - token.totalSupply();
42+
amount = bound(amount, 1, remaining);
43+
44+
uint256 supplyBefore = token.totalSupply();
45+
46+
vm.prank(owner);
47+
token.mint(alice, amount);
48+
49+
vm.prank(alice);
50+
token.burn(amount);
51+
52+
assertEq(token.totalSupply(), supplyBefore, "supply changed after mint+burn");
53+
}
54+
55+
/// @notice A chain of transfers never changes totalSupply.
56+
function testFuzz_Supply_ChainedTransfersAreNeutral(uint256 amount) public {
57+
amount = bound(amount, 1, INITIAL_MINT);
58+
59+
uint256 supplyBefore = token.totalSupply();
60+
61+
vm.prank(owner);
62+
assertTrue(token.transfer(alice, amount));
63+
64+
vm.prank(alice);
65+
assertTrue(token.transfer(bob, amount));
66+
67+
vm.prank(bob);
68+
assertTrue(token.transfer(carol, amount));
69+
70+
assertEq(token.totalSupply(), supplyBefore, "supply changed after chained transfers");
71+
}
72+
73+
/// @notice After a full round-trip (owner→alice→owner), both balances are restored.
74+
function testFuzz_Transfer_RoundTripRestoresBalances(uint256 amount) public {
75+
amount = bound(amount, 1, INITIAL_MINT);
76+
77+
uint256 ownerBefore = token.balanceOf(owner);
78+
79+
vm.prank(owner);
80+
assertTrue(token.transfer(alice, amount));
81+
82+
vm.prank(alice);
83+
assertTrue(token.transfer(owner, amount));
84+
85+
assertEq(token.balanceOf(owner), ownerBefore, "owner balance not restored");
86+
assertEq(token.balanceOf(alice), 0, "alice balance not zero after round-trip");
87+
}
88+
89+
// =========================================================================
90+
// Allowance accounting
91+
// =========================================================================
92+
93+
/// @notice After a partial spend, remaining allowance equals approve - spent.
94+
function testFuzz_Allowance_PartialSpendReducesExactly(uint256 approval, uint256 spend) public {
95+
approval = bound(approval, 1, INITIAL_MINT);
96+
spend = bound(spend, 1, approval);
97+
98+
vm.prank(owner);
99+
assertTrue(token.approve(alice, approval));
100+
101+
vm.prank(alice);
102+
assertTrue(token.transferFrom(owner, bob, spend));
103+
104+
assertEq(token.allowance(owner, alice), approval - spend, "allowance not reduced correctly");
105+
}
106+
107+
/// @notice The last approve always wins, regardless of prior value.
108+
function testFuzz_Allowance_OverwriteAlwaysReflectsLastCall(uint256 first, uint256 second) public {
109+
vm.prank(owner);
110+
assertTrue(token.approve(alice, first));
111+
112+
vm.prank(owner);
113+
assertTrue(token.approve(alice, second));
114+
115+
assertEq(token.allowance(owner, alice), second, "allowance not overwritten");
116+
}
117+
118+
/// @notice transferFrom must revert when spend exceeds allowance by any amount.
119+
function testFuzz_Allowance_ExceedingAllowanceAlwaysReverts(uint256 approval, uint256 excess) public {
120+
approval = bound(approval, 0, INITIAL_MINT - 1);
121+
excess = bound(excess, 1, INITIAL_MINT - approval);
122+
123+
vm.prank(owner);
124+
assertTrue(token.approve(alice, approval));
125+
126+
vm.prank(alice);
127+
vm.expectRevert();
128+
token.transferFrom(owner, bob, approval + excess); // reverts — return value intentionally unchecked
129+
}
130+
131+
// =========================================================================
132+
// Cap enforcement
133+
// =========================================================================
134+
135+
/// @notice Any mint that would push totalSupply past MAX_SUPPLY must revert.
136+
function testFuzz_Cap_MintOverCapAlwaysReverts(uint256 overBy) public {
137+
overBy = bound(overBy, 1, type(uint128).max);
138+
139+
uint256 remaining = Constants.MAX_SUPPLY - token.totalSupply();
140+
141+
vm.prank(owner);
142+
vm.expectRevert(Errors.ExceedsCap.selector);
143+
token.mint(alice, remaining + overBy); // reverts — return value intentionally unchecked
144+
}
145+
146+
/// @notice Minting exactly up to MAX_SUPPLY must succeed.
147+
function testFuzz_Cap_MintExactlyToCapSucceeds(uint256 seed) public {
148+
// Deploy a fresh token so we can control the gap to the cap precisely.
149+
uint256 initial = bound(seed, 1, Constants.MAX_SUPPLY - 1);
150+
CodesenSysToken fresh = new CodesenSysToken(owner, initial);
151+
152+
uint256 toMint = Constants.MAX_SUPPLY - initial;
153+
154+
vm.prank(owner);
155+
fresh.mint(alice, toMint);
156+
157+
assertEq(fresh.totalSupply(), Constants.MAX_SUPPLY, "supply did not reach cap");
158+
}
159+
160+
// =========================================================================
161+
// Access control
162+
// =========================================================================
163+
164+
/// @notice Any non-owner address must be unable to mint.
165+
function testFuzz_Access_NonOwnerMintAlwaysReverts(address caller, uint256 amount) public {
166+
vm.assume(caller != owner);
167+
vm.assume(caller != address(0));
168+
amount = bound(amount, 1, Constants.MAX_SUPPLY - token.totalSupply());
169+
170+
vm.prank(caller);
171+
vm.expectRevert(Errors.NotAuthorized.selector);
172+
token.mint(alice, amount); // reverts — return value intentionally unchecked
173+
}
174+
}

0 commit comments

Comments
 (0)