Skip to content

feat: TokenFactoryV2 with ERC-20 Permit + supply cap + freeze controls#35

Open
invisiblemonsters wants to merge 3 commits into
sentrix-labs:mainfrom
invisiblemonsters:main
Open

feat: TokenFactoryV2 with ERC-20 Permit + supply cap + freeze controls#35
invisiblemonsters wants to merge 3 commits into
sentrix-labs:mainfrom
invisiblemonsters:main

Conversation

@invisiblemonsters

@invisiblemonsters invisiblemonsters commented May 16, 2026

Copy link
Copy Markdown

Summary

Implements TokenFactoryV2 as described in #30 — a companion factory (next to v1) that deploys extended ERC-20 tokens with three commonly-requested production features.

What's included

FactoryTokenV2.sol

  • EIP-2612 Permit — gasless approvals via ERC20Permit
  • Supply cap — optional mint ceiling via ERC20Capped (pass cap=0 for unlimited)
  • Freeze controls — owner can pause()/unpause() transfers
  • Ownable — admin controls (pause, mint, ownership)

Follows the same coding style as the existing codebase: BUSL-1.1, Solidity 0.8.24, OpenZeppelin v5.3.0 (already in deps).

TokenFactoryV2.sol

  • Deploys FactoryTokenV2 with constructor arg-bundle: name, symbol, initialSupply, cap
  • Tracks deployments per deployer (same pattern as v1)
  • Input validation: name length <= 64, symbol length <= 16, initialSupply <= cap

ITokenFactoryV2.sol

  • Interface matching v1 pattern

Tests

  • 15 tests in TokenFactoryV2.t.sol, all passing
  • Covers: basic deploy, capped deploy, cap enforcement, pause/unpause, EIP-2612 permit, transfer, transferFrom, access control, factory tracking, input validation

Out of scope (per issue)

  • Replacing v1 — v2 sits alongside
  • Governance token features (voting, snapshots)

Verification

forge test --match-contract TokenFactoryV2Test
Suite result: ok. 15 passed; 0 failed; 0 skipped

Closes #30

Summary by CodeRabbit

  • New Features

    • Deploy customizable ERC‑20 tokens with optional caps, EIP‑2612 permit toggle, owner pausing, owner minting, custom decimals, and name/symbol validation; factory emits deployment events and tracks tokens per owner.
    • New ERC‑4626 vault with donate() support, dead‑share protection (minted to 0xdead), adjusted share/asset accounting and hooks.
  • Tests

    • Expanded suites validating factory/token flows (permit, cap, pause, mint, validations) and vault behavior (deposit/withdraw/mint/redeem, donations, edge cases, rounding, access control).

Review Change Stack

- 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 sentrix-labs#30
@coderabbitai

coderabbitai Bot commented May 16, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Adds TokenFactoryV2 + ITokenFactoryV2 and FactoryTokenV2: deploys configurable ERC‑20 tokens with optional EIP‑2612 permit, optional supply cap, pausable controls, and owner minting. Also adds Vault4626, an ERC‑4626 vault that mints permanent dead shares to 0xdead, supports donate(), adjusts conversion math for dead shares, and exposes hooks. Comprehensive Foundry tests are included for factory/token behaviors and vault accounting, share‑price effects, rounding, access control, and donation semantics.

Sequence Diagram(s)

sequenceDiagram
  participant Deployer
  participant TokenFactoryV2
  participant FactoryTokenV2
  Deployer->>TokenFactoryV2: deployToken(name,symbol,decimals,initialSupply,cap,permitEnabled,pauseEnabled)
  TokenFactoryV2->>TokenFactoryV2: validate name/symbol lengths
  alt cap > 0
    TokenFactoryV2->>TokenFactoryV2: require initialSupply <= cap
  end
  TokenFactoryV2->>FactoryTokenV2: new FactoryTokenV2(...)
  TokenFactoryV2->>TokenFactoryV2: deployedTokens[msg.sender].push(token)
  TokenFactoryV2->>Deployer: emit TokenDeployed(token, owner, ...)
Loading
sequenceDiagram
  participant User
  participant Vault4626
  participant AssetToken
  User->>Vault4626: deposit(assets)
  Vault4626->>AssetToken: transferFrom(user -> vault, assets)
  Vault4626->>Vault4626: mint shares (accounting includes deadShares offset)
  Vault4626-->>User: emit Deposit / return shares
  User->>Vault4626: donate(assets)
  Vault4626->>AssetToken: transferFrom(user -> vault, assets)
  Vault4626-->>User: emit Donation (no shares minted)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related issues

  • #27 — Vault4626 overlap: this PR adds Vault4626 and tests, matching the objectives of issue #27 (ERC‑4626 vault with donation/inflation‑attack guard).
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning All code changes directly support the TokenFactoryV2 feature and issue #30 objectives. Additionally, Vault4626 was introduced as a new ERC-4626 vault contract with donation support, which is unrelated to the TokenFactoryV2 and freeze-controls requirements. The Vault4626 contract (contracts/Vault4626.sol and test/Vault4626.t.sol) appears out of scope for issue #30 (TokenFactoryV2). Separate this into a dedicated PR or confirm it belongs in the TokenFactoryV2 feature scope.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main feature additions: TokenFactoryV2 with ERC-20 Permit, supply cap, and freeze controls.
Description check ✅ Passed The description covers scope, lists included contracts and features, provides test verification, and closes the linked issue. However, it does not explicitly check the deploy-impact checkboxes or mention storage-layout review.
Linked Issues check ✅ Passed The PR fully implements issue #30 requirements: TokenFactoryV2 with constructor arg-bundle support (name, symbol, decimals, initialSupply, cap, permitEnabled, pauseEnabled), EIP-2612 Permit, ERC20Capped supply cap, pause/freeze controls, input validation, test coverage, and v1 remains unchanged.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@contracts/interfaces/ITokenFactoryV2.sol`:
- Around line 21-26: The ITokenFactoryV2 interface's deployToken function
currently lacks the per-token configuration fields required by Issue `#30`; update
the function signature for deployToken in ITokenFactoryV2.sol to include the
missing parameters decimals (uint8 or uint256 consistent with your codebase),
bool permitEnabled, and bool pauseEnabled so consumers can opt into those
features during deployment; ensure the same parameter order and types align with
the implementing contract(s) and any callers to avoid mismatches, and update any
related docs/tests that reference ITokenFactoryV2::deployToken.

In `@contracts/TokenFactoryV2.sol`:
- Around line 11-20: Add an explicit interface implementation: import the
ITokenFactoryV2 interface (import { ITokenFactoryV2 } from
"./interfaces/ITokenFactoryV2.sol";) at the top of TokenFactoryV2.sol and change
the contract declaration from "contract TokenFactoryV2 {" to "contract
TokenFactoryV2 is ITokenFactoryV2 {" so the contract implements the
ITokenFactoryV2 interface and the compiler enforces the public API (ensure the
import name matches the interface symbol ITokenFactoryV2 and that all
public/external function signatures in TokenFactoryV2 match the interface).

In `@test/TokenFactoryV2.t.sol`:
- Around line 224-235: Add boundary tests around the name/symbol length limits:
extend the test suite that currently contains test_revert_emptyName and
test_revert_emptySymbol by adding tests that call factory.deployToken with a
64-character name and a 16-character symbol (expect success, no revert) and with
a 65-character name and a 17-character symbol (expect revert with
"TokenFactoryV2: BAD_NAME" and "TokenFactoryV2: BAD_SYMBOL" respectively). Use
vm.prank(deployer) and vm.expectRevert where appropriate, reuse
factory.deployToken for the calls, and add distinct test functions (e.g.,
test_deploy_maxName_and_maxSymbol and test_revert_tooLongName_and_tooLongSymbol)
to cover both success and failure boundaries.
- Around line 10-251: Add Foundry fuzz and invariant tests covering
monetary-impact public functions: create fuzz tests (test_fuzz_deployToken,
test_fuzz_mint, test_fuzz_transfer, test_fuzz_transferFrom, test_fuzz_permit)
that randomly exercise deployToken, mint, transfer, transferFrom, permit with
vm.assume to constrain sane values, and add invariants
(invariant_totalSupply_le_cap, invariant_balances_sum_equals_totalSupply,
invariant_balances_nonnegative, invariant_allowances_nonnegative,
invariant_factory_tracking) that assert for any state the
FactoryTokenV2/TokenFactoryV2 invariants hold: if cap > 0 then totalSupply() <=
cap, sum(balanceOf(...) for tracked holders) == totalSupply(), no negative
balances/allowances, owner-only mint reverts for non-owner, permit actually sets
allowance for approved spender, and factory.tokenCount(deployer) and
tokensOf(deployer) remain consistent; write these as standard Foundry test
functions using existing symbols deployToken, mint, transfer, transferFrom,
permit, cap, totalSupply, balanceOf, allowance, tokenCount, tokensOf and use
vm.prank/vm.sign/vm.expectRevert where appropriate.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 6d031f37-2b61-4847-a85b-0ce5a60cdc83

📥 Commits

Reviewing files that changed from the base of the PR and between fdac736 and 849bd26.

📒 Files selected for processing (4)
  • contracts/FactoryTokenV2.sol
  • contracts/TokenFactoryV2.sol
  • contracts/interfaces/ITokenFactoryV2.sol
  • test/TokenFactoryV2.t.sol

Comment thread contracts/interfaces/ITokenFactoryV2.sol
Comment thread contracts/TokenFactoryV2.sol Outdated
Comment thread test/TokenFactoryV2.t.sol
Comment on lines +10 to +251
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);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

Add invariant + fuzz coverage for value-moving public paths.

This suite is currently example-based only. Add Foundry fuzz/invariant tests for monetary-impact flows (deployToken, mint, transfer, transferFrom, permit) with invariants like capped totalSupply <= cap and balance/supply consistency.

As per coding guidelines test/**/*.sol: Foundry tests. Want invariant + fuzz coverage on every public function with monetary impact.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/TokenFactoryV2.t.sol` around lines 10 - 251, Add Foundry fuzz and
invariant tests covering monetary-impact public functions: create fuzz tests
(test_fuzz_deployToken, test_fuzz_mint, test_fuzz_transfer,
test_fuzz_transferFrom, test_fuzz_permit) that randomly exercise deployToken,
mint, transfer, transferFrom, permit with vm.assume to constrain sane values,
and add invariants (invariant_totalSupply_le_cap,
invariant_balances_sum_equals_totalSupply, invariant_balances_nonnegative,
invariant_allowances_nonnegative, invariant_factory_tracking) that assert for
any state the FactoryTokenV2/TokenFactoryV2 invariants hold: if cap > 0 then
totalSupply() <= cap, sum(balanceOf(...) for tracked holders) == totalSupply(),
no negative balances/allowances, owner-only mint reverts for non-owner, permit
actually sets allowance for approved spender, and factory.tokenCount(deployer)
and tokensOf(deployer) remain consistent; write these as standard Foundry test
functions using existing symbols deployToken, mint, transfer, transferFrom,
permit, cap, totalSupply, balanceOf, allowance, tokenCount, tokensOf and use
vm.prank/vm.sign/vm.expectRevert where appropriate.

Comment thread test/TokenFactoryV2.t.sol Outdated
- 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 sentrix-labs#27

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@contracts/Vault4626.sol`:
- Around line 108-113: Add ReentrancyGuard inheritance and apply nonReentrant to
the external entry points so deposits/withdrawals cannot be reentered: import
OpenZeppelin's ReentrancyGuard, have the contract inherit ReentrancyGuard, and
add the nonReentrant modifier to the public deposit and withdraw functions (the
external entry points that call internal _deposit and _withdraw/_burn logic);
leave internal helpers like _deposit and _afterDeposit unchanged so derived
contracts still run but are protected by the nonReentrant guard at the
boundaries.
- Around line 42-68: The current dead-share shortcut in _convertToShares and
_convertToAssets returns a 1:1 mapping when totalSupply() == 1e3, letting the
first depositor steal donated assets; instead, when supply == 1e3 and
totalAssets() > 0 compute conversions proportionally using the dead-share offset
(i.e., treat the virtual offset as added to both sides): in _convertToShares
replace the `if (supply == 1e3) return assets;` branch with a proportional
calculation like assets * supply / totalAssets (use _tryMulDiv and Math.Rounding
as in the rest), and in _convertToAssets replace the `if (supply == 1e3) return
shares;` branch with shares * totalAssets / supply (again via _tryMulDiv); keep
the special-case zero-totalAssets behavior (if totalAssets() == 0 fall back to
1:1) and reuse the DEAD_SHARES constant (1e3), totalSupply(), totalAssets(),
_convertToShares, and _convertToAssets identifiers to locate changes.
- Around line 129-138: The helper _tryMulDiv currently returns 0 when
denominator == 0 which can silently lose funds; change it to revert instead: in
function _tryMulDiv(uint256 x, uint256 y, uint256 denominator, Math.Rounding
rounding) check denominator and revert (use a clear message or a custom error
like DivisionByZero) if denominator == 0, otherwise return x.mulDiv(y,
denominator, rounding); keep the rest of the logic and signature unchanged so
callers of _tryMulDiv detect the failure instead of receiving a silent zero.

In `@test/Vault4626.t.sol`:
- Around line 47-312: The test suite lacks fuzz and invariant tests for
monetary-impact public methods; add property-based/fuzz tests and invariants
covering deposit, mint, withdraw, redeem, donate and their preview/max helpers
(convertToAssets/convertToShares,
previewDeposit/previewMint/previewWithdraw/previewRedeem,
maxDeposit/maxMint/maxWithdraw/maxRedeem) to exercise adversarial scenarios
(random assets/shares, multiple actors, direct asset transfers) and assert core
invariants (totalAssets vs totalSupply relation, non-negative balances,
dead-share immutability for DEAD/DEAD_SHARES, rounding favors vault,
approvals/allowances behavior) using Foundry’s fuzz and invariant harness APIs
in this test file so each public monetary path is fuzzed and an invariant test
asserts preserved properties across operations.
- Around line 154-183: The test captures attackerShares too early and doesn't
assert any outcome; update the scenario so attackerShares is read after the
attacker deposit (use vault.deposit when charlie deposits 1 wei) and then assert
economic outcomes: record asset balances (or vault.totalAssets()) for charlie
and alice before and after vault.redeem(attackerShares, charlie, charlie) and
assert charlie's redeemed assets do not exceed his legitimate entitlement (e.g.,
his pre-deposit + reasonable expected yield) and that alice's shares/asset loss
matches the inflated-share effect (e.g., aliceShares is very small and alice's
post-deposit asset balance decreased by ~1 ether while vault assets reflect the
direct transfer donation). Refer to attackerShares, vault.deposit, vault.redeem,
aliceShares, charlie and alice when adding these checks so the test fails if the
inflation donation allows theft.
- Around line 239-241: Replace the broad revert assertion with a specific ERC20
allowance error: instead of calling vm.expectRevert() before calling
vault.withdraw(500 ether, bob, alice), use
vm.expectRevert(ERC20.ERC20InsufficientAllowance.selector) so the test asserts
the withdraw call (vault.withdraw) fails for the expected
ERC20InsufficientAllowance error selector; update the assertion that precedes
the vault.withdraw invocation accordingly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 18967d91-159b-40ff-8fd7-a414d6f5a445

📥 Commits

Reviewing files that changed from the base of the PR and between 849bd26 and 3a0372b.

📒 Files selected for processing (2)
  • contracts/Vault4626.sol
  • test/Vault4626.t.sol

Comment thread contracts/Vault4626.sol
Comment on lines +42 to +68
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);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | 🏗️ Heavy lift

Critical: Dead-share accounting allows first depositor to steal donated assets.

The conversion logic returns 1:1 when supply == 1e3 regardless of totalAssets. If assets are donated before the first real deposit, the first depositor can drain them:

  1. After constructor: supply=1000, totalAssets=0
  2. Donation of 10,000 assets: totalAssets=10000, supply=1000
  3. User deposits 1,000: supply==1e3 → user receives 1,000 shares (1:1)
  4. Now: supply=2000, totalAssets=11000
  5. User redeems 1,000 shares: assets = 1000 * 11000 / (2000-1000) = 11000

The user deposited 1,000 but withdraws 11,000—stealing the entire donation.

The fix should use a proper virtual offset that adds to both numerator and denominator rather than subtracting dead shares only from supply. Alternatively, when supply == 1e3, compute shares as assets * 1e3 / totalAssets (if totalAssets > 0) to properly dilute against existing assets.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@contracts/Vault4626.sol` around lines 42 - 68, The current dead-share
shortcut in _convertToShares and _convertToAssets returns a 1:1 mapping when
totalSupply() == 1e3, letting the first depositor steal donated assets; instead,
when supply == 1e3 and totalAssets() > 0 compute conversions proportionally
using the dead-share offset (i.e., treat the virtual offset as added to both
sides): in _convertToShares replace the `if (supply == 1e3) return assets;`
branch with a proportional calculation like assets * supply / totalAssets (use
_tryMulDiv and Math.Rounding as in the rest), and in _convertToAssets replace
the `if (supply == 1e3) return shares;` branch with shares * totalAssets /
supply (again via _tryMulDiv); keep the special-case zero-totalAssets behavior
(if totalAssets() == 0 fall back to 1:1) and reuse the DEAD_SHARES constant
(1e3), totalSupply(), totalAssets(), _convertToShares, and _convertToAssets
identifiers to locate changes.

Comment thread contracts/Vault4626.sol
Comment on lines +108 to +113
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);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | ⚖️ Poor tradeoff

Consider adding ReentrancyGuard for defense-in-depth.

While the current implementation has no obvious reentrancy exploit, the _deposit function performs external calls before state changes (safeTransferFrom before _mint). If the underlying asset has transfer callbacks (ERC-777) and derived contracts add stateful logic in _afterDeposit, unexpected interactions could arise.

Adding nonReentrant to deposit/withdraw entry points would provide defense-in-depth as yield-strategy children extend this contract.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@contracts/Vault4626.sol` around lines 108 - 113, Add ReentrancyGuard
inheritance and apply nonReentrant to the external entry points so
deposits/withdrawals cannot be reentered: import OpenZeppelin's ReentrancyGuard,
have the contract inherit ReentrancyGuard, and add the nonReentrant modifier to
the public deposit and withdraw functions (the external entry points that call
internal _deposit and _withdraw/_burn logic); leave internal helpers like
_deposit and _afterDeposit unchanged so derived contracts still run but are
protected by the nonReentrant guard at the boundaries.

Comment thread contracts/Vault4626.sol
Comment on lines +129 to +138
/// @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);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Silent zero return on division-by-zero can cause loss of funds.

When denominator == 0, this returns 0 rather than reverting. If a depositor calls when totalAssets() == 0 but supply > 1e3 (possible with fee-on-transfer tokens or edge scenarios), they would receive 0 shares for their deposit—silently losing funds.

Prefer reverting on zero denominator to fail-safe rather than fail-silent:

Proposed fix
 function _tryMulDiv(
     uint256 x,
     uint256 y,
     uint256 denominator,
     Math.Rounding rounding
 ) internal pure returns (uint256) {
-    if (denominator == 0) return 0;
+    require(denominator > 0, "Vault4626: zero denominator");
     return x.mulDiv(y, denominator, rounding);
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@contracts/Vault4626.sol` around lines 129 - 138, The helper _tryMulDiv
currently returns 0 when denominator == 0 which can silently lose funds; change
it to revert instead: in function _tryMulDiv(uint256 x, uint256 y, uint256
denominator, Math.Rounding rounding) check denominator and revert (use a clear
message or a custom error like DivisionByZero) if denominator == 0, otherwise
return x.mulDiv(y, denominator, rounding); keep the rest of the logic and
signature unchanged so callers of _tryMulDiv detect the failure instead of
receiving a silent zero.

Comment thread test/Vault4626.t.sol
Comment on lines +47 to +312
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);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

Add fuzz + invariant coverage for monetary paths.

This suite is example-based only; it does not include fuzz/invariant tests for monetary-impact public functions (deposit, mint, withdraw, redeem, donate, and related preview/max behavior), so it misses the required adversarial coverage profile.

As per coding guidelines, test/**/*.sol: Foundry tests. Want invariant + fuzz coverage on every public function with monetary impact.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/Vault4626.t.sol` around lines 47 - 312, The test suite lacks fuzz and
invariant tests for monetary-impact public methods; add property-based/fuzz
tests and invariants covering deposit, mint, withdraw, redeem, donate and their
preview/max helpers (convertToAssets/convertToShares,
previewDeposit/previewMint/previewWithdraw/previewRedeem,
maxDeposit/maxMint/maxWithdraw/maxRedeem) to exercise adversarial scenarios
(random assets/shares, multiple actors, direct asset transfers) and assert core
invariants (totalAssets vs totalSupply relation, non-negative balances,
dead-share immutability for DEAD/DEAD_SHARES, rounding favors vault,
approvals/allowances behavior) using Foundry’s fuzz and invariant harness APIs
in this test file so each public monetary path is fuzzed and an invariant test
asserts preserved properties across operations.

Comment thread test/Vault4626.t.sol
Comment on lines +154 to +183
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);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Inflation-attack test does not prove safety and uses stale share accounting.

attackerShares is captured before the later attacker deposit, and the test has no assertion on attacker profit/loss or victim outcome after redemption. This can pass without validating that the attack is actually blocked.

Suggested tightening
-        vm.prank(charlie);
-        uint256 attackerShares = vault.deposit(1, charlie);
+        // Remove the early standalone attacker deposit; capture shares only in the attack sequence.

         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
+        uint256 attackerShares = vault.deposit(1, charlie); // attacker deposit used in attack
+        uint256 attackerPre = asset.balanceOf(charlie);
         asset.transfer(address(vault), 1_000_000 ether); // donate via direct transfer
         vm.stopPrank();

         vm.prank(alice);
         uint256 aliceShares = vault.deposit(1 ether, alice);
         assertTrue(aliceShares < 1 ether / 100);

         vm.prank(charlie);
-        vault.redeem(attackerShares, charlie, charlie);
+        uint256 redeemed = vault.redeem(attackerShares, charlie, charlie);
+        uint256 attackerPost = asset.balanceOf(charlie);
+        assertLe(attackerPost, attackerPre + redeemed); // no hidden gain path
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/Vault4626.t.sol` around lines 154 - 183, The test captures
attackerShares too early and doesn't assert any outcome; update the scenario so
attackerShares is read after the attacker deposit (use vault.deposit when
charlie deposits 1 wei) and then assert economic outcomes: record asset balances
(or vault.totalAssets()) for charlie and alice before and after
vault.redeem(attackerShares, charlie, charlie) and assert charlie's redeemed
assets do not exceed his legitimate entitlement (e.g., his pre-deposit +
reasonable expected yield) and that alice's shares/asset loss matches the
inflated-share effect (e.g., aliceShares is very small and alice's post-deposit
asset balance decreased by ~1 ether while vault assets reflect the direct
transfer donation). Refer to attackerShares, vault.deposit, vault.redeem,
aliceShares, charlie and alice when adding these checks so the test fails if the
inflation donation allows theft.

Comment thread test/Vault4626.t.sol
Comment on lines +239 to +241
vm.prank(bob);
vm.expectRevert();
vault.withdraw(500 ether, bob, alice);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

# First, find the test file
find . -name "Vault4626.t.sol" -type f

Repository: sentrix-labs/canonical-contracts

Length of output: 99


🏁 Script executed:

# Read the test file to see the context around lines 239-241
wc -l test/Vault4626.t.sol

Repository: sentrix-labs/canonical-contracts

Length of output: 101


🏁 Script executed:

# Read the relevant section with more context
sed -n '230,250p' test/Vault4626.t.sol

Repository: sentrix-labs/canonical-contracts

Length of output: 728


🏁 Script executed:

# Find the Vault4626 contract implementation
find . -name "*.sol" -type f | grep -i vault | grep -v test

Repository: sentrix-labs/canonical-contracts

Length of output: 102


🏁 Script executed:

# Search for Vault4626 contract definition
rg "contract Vault4626" --type sol

Repository: sentrix-labs/canonical-contracts

Length of output: 104


🏁 Script executed:

# Search for Vault4626 contract without file type filter
rg "contract Vault4626" --type-add 'sol:*.sol'

Repository: sentrix-labs/canonical-contracts

Length of output: 186


🏁 Script executed:

# Read the Vault4626 contract to find withdraw function
cat -n contracts/Vault4626.sol | head -100

Repository: sentrix-labs/canonical-contracts

Length of output: 4910


🏁 Script executed:

# Read more of the contract to find the withdraw function
cat -n contracts/Vault4626.sol | sed -n '100,200p'

Repository: sentrix-labs/canonical-contracts

Length of output: 1877


🏁 Script executed:

# Check the OpenZeppelin version
cat contracts/Vault4626.sol | grep -i "openzeppelin"

Repository: sentrix-labs/canonical-contracts

Length of output: 527


🏁 Script executed:

# Check package.json or foundry.toml for OpenZeppelin version
find . -name "foundry.toml" -o -name "package.json" | xargs cat 2>/dev/null

Repository: sentrix-labs/canonical-contracts

Length of output: 2299


🏁 Script executed:

# Check OpenZeppelin ERC20 to see what error _spendAllowance throws
# First find if there's a lib/openzeppelin path
find . -path "*/openzeppelin*" -name "ERC20.sol" | head -1

Repository: sentrix-labs/canonical-contracts

Length of output: 58


🏁 Script executed:

# Search for ERC20InsufficientAllowance error in the codebase
rg "ERC20InsufficientAllowance" --type-add 'sol:*.sol'

Repository: sentrix-labs/canonical-contracts

Length of output: 58


🏁 Script executed:

# Check what errors are defined or used in the test file
rg "expectRevert" test/Vault4626.t.sol --type-add 'sol:*.sol' -A 2 -B 2

Repository: sentrix-labs/canonical-contracts

Length of output: 362


🌐 Web query:

OpenZeppelin ERC20 insufficient allowance error 0.8.24

💡 Result:

The "ERC20: insufficient allowance" error in OpenZeppelin ERC20 contracts occurs when a contract attempts to execute a transferFrom function without having been previously authorized to spend the required amount of tokens on behalf of the token owner [1][2][3]. Key points regarding this error: 1. Mechanism: The transferFrom(address sender, address recipient, uint256 amount) function requires that the caller (the spender) has been granted an allowance by the sender [2][3]. This allowance must be greater than or equal to the amount being transferred [4][2]. 2. Common Cause: This error is frequently encountered when a user or contract calls transferFrom without first calling the approve(address spender, uint256 amount) function on the token contract to authorize the spender [5][1]. 3. Versioning and Errors: - In OpenZeppelin Contracts v4.x and earlier, this error is typically thrown as a require statement with the string "ERC20: insufficient allowance" [6][7]. - In OpenZeppelin Contracts v5.x, the library transitioned to using custom errors (e.g., ERC20InsufficientAllowance) to save gas and provide more structured error data [8][3]. If you are using v5.x, you should expect a custom error rather than a string-based revert [8]. 4. Troubleshooting: - Verify that the approve function was called successfully by the token owner for the spender address before the transferFrom call [1]. - Ensure the allowance amount is sufficient for the intended transfer [5]. - If you are developing a contract that needs to move tokens, remember that the contract itself must be approved to spend the user's tokens, or the user must approve the contract address [1]. If you are seeing this error on a transfer function (rather than transferFrom), it is often a sign that your contract logic is incorrectly calling transferFrom or that there is a misunderstanding of which function is being executed [9]. Always check the stack trace to confirm which function is triggering the revert [9].

Citations:


🏁 Script executed:

# Check for remappings in foundry.toml or remappings.txt
find . -name "remappings.txt" -o -name ".remappings.json" | xargs cat 2>/dev/null

Repository: sentrix-labs/canonical-contracts

Length of output: 212


🏁 Script executed:

# Check lib directory for openzeppelin version
ls -la lib/ 2>/dev/null | head -20

Repository: sentrix-labs/canonical-contracts

Length of output: 318


🏁 Script executed:

# Check if there's a package-lock or pnpm lockfile that shows OZ version
find . -name "package-lock.json" -o -name "pnpm-lock.yaml" | head -1

Repository: sentrix-labs/canonical-contracts

Length of output: 93


🏁 Script executed:

# Check the openzeppelin-contracts version
cat lib/openzeppelin-contracts/package.json | grep version

Repository: sentrix-labs/canonical-contracts

Length of output: 148


🏁 Script executed:

# Look for ERC20 implementation in the lib to see what error it uses
find lib/openzeppelin-contracts -name "ERC20.sol" | xargs cat -n | head -150

Repository: sentrix-labs/canonical-contracts

Length of output: 58


🏁 Script executed:

# Check what's in the openzeppelin-contracts directory
ls -la lib/openzeppelin-contracts/

Repository: sentrix-labs/canonical-contracts

Length of output: 183


🏁 Script executed:

# Look for ERC20.sol in the lib
find lib -name "ERC20.sol" -type f

Repository: sentrix-labs/canonical-contracts

Length of output: 58


🏁 Script executed:

# Read the ERC20 implementation to find the error type
cat lib/openzeppelin-contracts/contracts/token/ERC20/ERC20.sol | grep -A 10 "insufficientAllowance\|InsufficientAllowance"

Repository: sentrix-labs/canonical-contracts

Length of output: 167


🏁 Script executed:

# Check git submodules
git config --file .gitmodules --name-only --get-regexp path

Repository: sentrix-labs/canonical-contracts

Length of output: 147


🏁 Script executed:

# Check what Solidity version is specified and infer OZ version
cat foundry.toml | grep -i solc

Repository: sentrix-labs/canonical-contracts

Length of output: 92


🏁 Script executed:

# Search in the test file for any imports that show OZ error types
rg "import.*ERC20" test/Vault4626.t.sol --type-add 'sol:*.sol'

Repository: sentrix-labs/canonical-contracts

Length of output: 216


🏁 Script executed:

# Check if ERC20InsufficientAllowance is used anywhere in the codebase
rg "ERC20InsufficientAllowance" . --type-add 'sol:*.sol'

Repository: sentrix-labs/canonical-contracts

Length of output: 58


🏁 Script executed:

# Check OpenZeppelin v5.x docs for ERC20InsufficientAllowance error
# First, let's check if there are any other tests using ERC20 errors
rg "Insufficient" test/ --type-add 'sol:*.sol'

Repository: sentrix-labs/canonical-contracts

Length of output: 58


🏁 Script executed:

# Look at the full test file to understand the pattern
cat -n test/Vault4626.t.sol | sed -n '1,50p'

Repository: sentrix-labs/canonical-contracts

Length of output: 2094


🏁 Script executed:

# Check if there are other tests in the file using expectRevert with selectors
cat -n test/Vault4626.t.sol | grep -A 2 "expectRevert"

Repository: sentrix-labs/canonical-contracts

Length of output: 300


Use a specific revert selector for the unauthorized withdraw check.

A bare vm.expectRevert() is too broad and can pass for unrelated failures. The withdraw should revert with ERC20InsufficientAllowance when the caller lacks approval. Match the expected error selector:

vm.expectRevert(ERC20.ERC20InsufficientAllowance.selector);

This follows the pattern already established in the same test file (line 263 with Vault4626.Vault4626__ZeroDeposit.selector).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/Vault4626.t.sol` around lines 239 - 241, Replace the broad revert
assertion with a specific ERC20 allowance error: instead of calling
vm.expectRevert() before calling vault.withdraw(500 ether, bob, alice), use
vm.expectRevert(ERC20.ERC20InsufficientAllowance.selector) so the test asserts
the withdraw call (vault.withdraw) fails for the expected
ERC20InsufficientAllowance error selector; update the assertion that precedes
the vault.withdraw invocation accordingly.

@satyakwok satyakwok left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the PR. Direction looks good, but I don’t want to merge this yet because TokenFactoryV2 is intended to be a canonical contract and the public API/tests need to be tighter.

Please address these before merge:

  1. Update ITokenFactoryV2 so deployToken exposes the full V2 configuration:

    • decimals
    • permitEnabled
    • pauseEnabled
  2. Make TokenFactoryV2 explicitly implement ITokenFactoryV2 so the compiler enforces the public API.

  3. Add boundary tests for name/symbol limits:

    • 64-character name succeeds
    • 65-character name reverts
    • 16-character symbol succeeds
    • 17-character symbol reverts
  4. Add targeted fuzz/invariant tests for deployToken, mint, transfer, transferFrom, permit, cap enforcement, factory tracking, and totalSupply consistency.

  5. Please keep this PR scoped only to TokenFactoryV2. If Vault4626 changes are included, move them to the separate Vault4626 PR.

After changes, please run:

  • forge fmt --check
  • forge build
  • forge test
  • forge test --match-contract TokenFactoryV2Test

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@contracts/FactoryTokenV2.sol`:
- Around line 40-41: Store the _permitEnabled flag in the contract state (e.g.,
a public bool permitEnabled set in the constructor) and explicitly disable
EIP‑2612 by overriding the inherited permit(...) function to revert (or
require(permitEnabled)) when permitEnabled is false; keep calling
ERC20Permit(...) in the constructor for domain setup if you want but rely on the
stored permitEnabled flag and the overridden permit method to actually prevent
use when disabled, and expose the flag via a public getter so callers can query
whether permit was intended to be enabled.

In `@test/TokenFactoryV2.t.sol`:
- Around line 362-374: Extend test_deployPermitDisabled to assert that permit()
is rejected when permitEnabled is false: after deploying via factory.deployToken
and casting to FactoryTokenV2 (t), construct a permit call using t.permit(...)
or the helper that signs and call it via vm.prank(signer) and expectRevert (or
vm.expectRevert) before calling t.permit to ensure the transaction reverts when
permitEnabled is false; reference test_deployPermitDisabled,
factory.deployToken, FactoryTokenV2, and the permit() method so the negative
assertion checks the feature toggle behaviorally.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 82e722a6-3a7d-41c6-9439-5e0f57f7fc07

📥 Commits

Reviewing files that changed from the base of the PR and between 3a0372b and 047273c.

📒 Files selected for processing (4)
  • contracts/FactoryTokenV2.sol
  • contracts/TokenFactoryV2.sol
  • contracts/interfaces/ITokenFactoryV2.sol
  • test/TokenFactoryV2.t.sol

Comment on lines +40 to +41
ERC20Permit(_permitEnabled ? _name : "") // permit salt must be non-empty when enabled
ERC20Capped(_cap == 0 ? type(uint256).max : _cap)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "1) Check how permitEnabled is used in FactoryTokenV2:"
rg -n "permitEnabled|ERC20Permit\\(|function permit\\(" contracts/FactoryTokenV2.sol

echo
echo "2) Inspect OpenZeppelin ERC20Permit constructor + permit function:"
PERMIT_FILE="$(fd -i 'ERC20Permit.sol' | head -n1 || true)"
if [ -z "${PERMIT_FILE:-}" ]; then
  echo "ERC20Permit.sol not found in-repo (dependency may be external)."
  exit 0
fi
echo "Found: $PERMIT_FILE"
rg -n "constructor\\(|function permit\\(|function DOMAIN_SEPARATOR\\(" "$PERMIT_FILE"

Repository: sentrix-labs/canonical-contracts

Length of output: 461


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== 1) Read full FactoryTokenV2 constructor and permit-related code ==="
cat -n contracts/FactoryTokenV2.sol | head -100

echo
echo "=== 2) Check for any permit overrides in FactoryTokenV2 ==="
rg -n "override.*permit|function permit" contracts/FactoryTokenV2.sol || echo "No permit overrides found"

echo
echo "=== 3) Search for tests referencing permit behavior ==="
fd -e test.ts -e test.sol -e spec.ts -o -path '*test*' -name '*FactoryToken*' | head -5

Repository: sentrix-labs/canonical-contracts

Length of output: 4966


🌐 Web query:

OpenZeppelin ERC20Permit constructor empty name parameter behavior domain separator permit function

💡 Result:

In OpenZeppelin's ERC20Permit implementation, the constructor accepts a string parameter name which is passed to the EIP712 base contract [1][2]. If you provide an empty string ("") as the name parameter, the contract will function technically, but it will result in a domain separator that is derived from an empty name [1][3]. Specifically, the EIP712 domain separator is calculated using the hash of the name, the version, the chain ID, and the contract address [4][3][5]. If the name is an empty string, its hash (keccak256(bytes(""))) is 0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470. This value will be used in the domain separator calculation [3]. While this is not a functional error that prevents the permit function from working, it is generally discouraged for the following reasons: 1. Domain Uniqueness: The name parameter is intended to provide a human-readable identifier for the signing domain [4][6]. Using an empty string makes the domain separator less unique and potentially confusing for users or wallet interfaces that display the signing domain [6][5]. 2. Best Practices: OpenZeppelin documentation explicitly recommends using the same name that is defined as the ERC-20 token name [1][2]. This ensures that the domain separator is tied to the specific identity of the token, which is a core purpose of EIP-712 domain separation [5]. 3. Wallet Compatibility: Wallets and other EIP-712-compliant tools often use the name field to present the signing context to the user [6][5]. An empty name may lead to poor user experience or security warnings in some wallet implementations that expect a descriptive name [6]. In summary, passing an empty string will not cause the contract to revert or fail to generate a domain separator, but it will result in a non-descriptive domain separator that deviates from standard practices and may negatively impact user experience and security clarity [1][6][5].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Search for test files referencing FactoryTokenV2 or permit ==="
find . -type f \( -name "*.test.ts" -o -name "*.spec.ts" -o -name "*.test.js" -o -name "*.test.sol" \) 2>/dev/null | head -20

echo
echo "=== Search for permit-related tests in test directory ==="
if [ -d "test" ] || [ -d "tests" ]; then
  rg -l "permit|FactoryTokenV2" --type ts --type js test* 2>/dev/null | head -10 || echo "No matches"
fi

echo
echo "=== Verify _permitEnabled is stored as immutable or used elsewhere ==="
rg -n "_permitEnabled" contracts/FactoryTokenV2.sol

Repository: sentrix-labs/canonical-contracts

Length of output: 484


permitEnabled flag does not disable permit() in its current implementation.

The constructor passes _permitEnabled ? _name : "" to ERC20Permit, but this does not prevent the inherited permit() function from being called and working normally. An empty domain name still produces a valid (though non-descriptive) domain separator. This breaks the advertised feature toggle: users who deploy with _permitEnabled=false will still have permit functionality available.

Additionally, _permitEnabled is read once during construction and never stored, making it impossible to query whether permit was intended to be enabled.

Suggested fix
 contract FactoryTokenV2 is ERC20, ERC20Permit, ERC20Capped, Ownable, Pausable {
     error FactoryTokenV2__CapExceeded(uint256 cap, uint256 requested);
+    error FactoryTokenV2__PermitDisabled();

     uint8 private immutable _decimals;
     bool  private immutable _pauseEnabled;
+    bool  private immutable _permitEnabledFlag;
@@
     {
         _decimals = _decimals_;
         _pauseEnabled = _pauseEnabled;
+        _permitEnabledFlag = _permitEnabled;
@@
     }
+
+    function permit(
+        address owner,
+        address spender,
+        uint256 value,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public override {
+        if (!_permitEnabledFlag) revert FactoryTokenV2__PermitDisabled();
+        super.permit(owner, spender, value, deadline, v, r, s);
+    }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@contracts/FactoryTokenV2.sol` around lines 40 - 41, Store the _permitEnabled
flag in the contract state (e.g., a public bool permitEnabled set in the
constructor) and explicitly disable EIP‑2612 by overriding the inherited
permit(...) function to revert (or require(permitEnabled)) when permitEnabled is
false; keep calling ERC20Permit(...) in the constructor for domain setup if you
want but rely on the stored permitEnabled flag and the overridden permit method
to actually prevent use when disabled, and expose the flag via a public getter
so callers can query whether permit was intended to be enabled.

Comment thread test/TokenFactoryV2.t.sol
Comment on lines +362 to +374
/// @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)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Add a negative permit test for permitEnabled = false.

This test currently proves deployment only; it should also assert that permit() is rejected when the feature is disabled, so the toggle is behaviorally enforced.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/TokenFactoryV2.t.sol` around lines 362 - 374, Extend
test_deployPermitDisabled to assert that permit() is rejected when permitEnabled
is false: after deploying via factory.deployToken and casting to FactoryTokenV2
(t), construct a permit call using t.permit(...) or the helper that signs and
call it via vm.prank(signer) and expectRevert (or vm.expectRevert) before
calling t.permit to ensure the transaction reverts when permitEnabled is false;
reference test_deployPermitDisabled, factory.deployToken, FactoryTokenV2, and
the permit() method so the negative assertion checks the feature toggle
behaviorally.

@satyakwok satyakwok self-assigned this Jun 1, 2026
@satyakwok

Copy link
Copy Markdown
Member

Thanks for the PR. I reviewed this locally and found a couple of blockers before this can be merged.

Test result:

forge test -vvv
157 passed, 1 failed
Failing test: TokenFactoryV2Test::test_pauseUnpause()

Blockers:

  1. pauseEnabled is currently broken because the constructor parameter shadows the immutable state variable:
_pauseEnabled = _pauseEnabled;

This self-assigns the parameter, so the contract-level _pauseEnabled remains the default value. As a result, pause() reverts with FactoryTokenV2: PAUSE_DISABLED even when the token is created with pause enabled. The compiler also emits a shadowing warning for this.

  1. permitEnabled=false does not actually disable permit behavior. FactoryTokenV2 always inherits ERC20Permit, so permit() remains callable. Passing an empty name to ERC20Permit changes the EIP-712 domain, but it does not disable the function. Either the flag should be enforced explicitly, or the PR/docs should state that permit is always available.

Also, this PR appears to include the ERC-4626 vault changes from PR #36. Please rebase/split this after #36 is resolved so the TokenFactoryV2 diff stays focused.

Suggested next steps:

  • Fix the _pauseEnabled shadowing/self-assignment.
  • Add/adjust tests proving pauseEnabled=true allows pause/unpause and pauseEnabled=false rejects pause.
  • Decide whether permitEnabled should truly disable permit(); if yes, enforce it with tests.
  • Rebase this branch so it does not carry unrelated Vault4626 files.

Holding off on merge until these are resolved.

@satyakwok satyakwok removed their assignment Jun 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: TokenFactory v2 with ERC-20 Permit + optional supply cap + freeze controls

2 participants