-
-
Notifications
You must be signed in to change notification settings - Fork 1
feat: TokenFactoryV2 with ERC-20 Permit + supply cap + freeze controls #35
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,109 @@ | ||
| // SPDX-License-Identifier: BUSL-1.1 | ||
| pragma solidity 0.8.24; | ||
|
|
||
| import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; | ||
| import {ERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol"; | ||
| import {ERC20Capped} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Capped.sol"; | ||
| import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; | ||
| import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol"; | ||
|
|
||
| /// @title FactoryTokenV2 | ||
| /// @notice Extended ERC-20 deployed by TokenFactoryV2. | ||
| /// Adds EIP-2612 Permit, optional supply cap, and freeze controls. | ||
| /// @dev Combines OpenZeppelin extensions for gasless approvals, | ||
| /// capped supply, and admin-pausable transfers. | ||
| contract FactoryTokenV2 is ERC20, ERC20Permit, ERC20Capped, Ownable, Pausable { | ||
| error FactoryTokenV2__CapExceeded(uint256 cap, uint256 requested); | ||
|
|
||
| uint8 private immutable _decimals; | ||
| bool private immutable _pauseEnabled; | ||
|
|
||
| /// @param _name Token name | ||
| /// @param _symbol Token symbol | ||
| /// @param _decimals_ Token decimals (e.g. 18) | ||
| /// @param _initialSupply Initial amount minted to deployer | ||
| /// @param _cap Maximum total supply (0 = unlimited) | ||
| /// @param _owner Admin address (pause, unpause, ownership) | ||
| /// @param _permitEnabled Whether EIP-2612 permit is active | ||
| /// @param _pauseEnabled Whether admin-pause controls are active | ||
| constructor( | ||
| string memory _name, | ||
| string memory _symbol, | ||
| uint8 _decimals_, | ||
| uint256 _initialSupply, | ||
| uint256 _cap, | ||
| address _owner, | ||
| bool _permitEnabled, | ||
| bool _pauseEnabled | ||
| ) | ||
| ERC20(_name, _symbol) | ||
| ERC20Permit(_permitEnabled ? _name : "") // permit salt must be non-empty when enabled | ||
| ERC20Capped(_cap == 0 ? type(uint256).max : _cap) | ||
| Ownable(_owner) | ||
| { | ||
| _decimals = _decimals_; | ||
| _pauseEnabled = _pauseEnabled; | ||
|
|
||
| // _cap == 0 means unlimited — max uint256 cap is effectively unlimited. | ||
| if (_initialSupply > 0) { | ||
| _mint(_owner, _initialSupply); | ||
| } | ||
| } | ||
|
|
||
| /// @notice Returns the token's configured decimals. | ||
| function decimals() public view override returns (uint8) { | ||
| return _decimals; | ||
| } | ||
|
|
||
| // ─── Pause controls ──────────────────────────────────────────── | ||
|
|
||
| /// @notice Pause all token transfers. Only owner. | ||
| /// @dev Reverts if pause controls were disabled at deploy time. | ||
| function pause() external onlyOwner { | ||
| require(_pauseEnabled, "FactoryTokenV2: PAUSE_DISABLED"); | ||
| _pause(); | ||
| } | ||
|
|
||
| /// @notice Unpause token transfers. Only owner. | ||
| /// @dev Reverts if pause controls were disabled at deploy time. | ||
| function unpause() external onlyOwner { | ||
| require(_pauseEnabled, "FactoryTokenV2: PAUSE_DISABLED"); | ||
| _unpause(); | ||
| } | ||
|
|
||
| // ─── Supply cap override ─────────────────────────────────────── | ||
|
|
||
| /// @notice Mint new tokens up to the supply cap. Only owner. | ||
| /// @dev Reverts if mint would exceed cap. | ||
| function mint(address to, uint256 amount) external onlyOwner { | ||
| _mint(to, amount); | ||
| } | ||
|
|
||
| // ─── Internal overrides ──────────────────────────────────────── | ||
|
|
||
| /// @dev Hook into ERC20 _update: enforce cap + pause on all transfers. | ||
| /// Pausable v5.3.0 uses modifier pattern, not _update override, | ||
| /// so we call _requireNotPaused() manually. | ||
| function _update( | ||
| address from, | ||
| address to, | ||
| uint256 value | ||
| ) | ||
| internal | ||
| override(ERC20, ERC20Capped) | ||
| { | ||
| _requireNotPaused(); | ||
| super._update(from, to, value); | ||
| } | ||
|
|
||
| /// @notice Returns the current supply cap. Max uint256 = unlimited. | ||
| /// @dev Exposed for transparency. Use `isCapped()` to check if a cap is set. | ||
| function cap() public view override(ERC20Capped) returns (uint256) { | ||
| return ERC20Capped.cap(); | ||
| } | ||
|
|
||
| /// @notice Returns true if a supply cap is enforced. | ||
| function isCapped() public view returns (bool) { | ||
| return ERC20Capped.cap() != type(uint256).max; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,65 @@ | ||
| // SPDX-License-Identifier: BUSL-1.1 | ||
| pragma solidity 0.8.24; | ||
|
|
||
| import {FactoryTokenV2} from "./FactoryTokenV2.sol"; | ||
| import {ITokenFactoryV2} from "./interfaces/ITokenFactoryV2.sol"; | ||
|
|
||
| /// @title Sentrix TokenFactoryV2 | ||
| /// @author Sentrix Labs | ||
| /// @notice Deploys extended ERC-20 tokens with Permit, optional supply cap, | ||
| /// and freeze controls. Sits next to TokenFactory (v1), not replacing it. | ||
| /// @dev Tracks deployed-token-by-deployer for discovery. | ||
| contract TokenFactoryV2 is ITokenFactoryV2 { | ||
| uint256 public constant MAX_NAME_LENGTH = 64; | ||
| uint256 public constant MAX_SYMBOL_LENGTH = 16; | ||
|
|
||
| mapping(address => address[]) public deployedTokens; | ||
|
|
||
| /// @notice Deploy a new FactoryTokenV2. | ||
| /// @param name Token name | ||
| /// @param symbol Token symbol | ||
| /// @param decimals_ Token decimals (e.g. 18) | ||
| /// @param initialSupply Amount minted to `msg.sender` on deploy | ||
| /// @param cap Maximum total supply (0 = unlimited) | ||
| /// @param permitEnabled Whether EIP-2612 permit is enabled | ||
| /// @param pauseEnabled Whether admin-pause controls are enabled | ||
| /// @return token Address of the new FactoryTokenV2 | ||
| function deployToken( | ||
| string calldata name, | ||
| string calldata symbol, | ||
| uint8 decimals_, | ||
| uint256 initialSupply, | ||
| uint256 cap, | ||
| bool permitEnabled, | ||
| bool pauseEnabled | ||
| ) external returns (address token) { | ||
| require(bytes(name).length > 0 && bytes(name).length <= MAX_NAME_LENGTH, "TokenFactoryV2: BAD_NAME"); | ||
| require(bytes(symbol).length > 0 && bytes(symbol).length <= MAX_SYMBOL_LENGTH, "TokenFactoryV2: BAD_SYMBOL"); | ||
|
|
||
| // If cap is set, initialSupply must not exceed it. | ||
| if (cap > 0) { | ||
| require(initialSupply <= cap, "TokenFactoryV2: CAP_EXCEEDED"); | ||
| } | ||
|
|
||
| FactoryTokenV2 t = new FactoryTokenV2( | ||
| name, symbol, decimals_, initialSupply, cap, msg.sender, | ||
| permitEnabled, pauseEnabled | ||
| ); | ||
| token = address(t); | ||
| deployedTokens[msg.sender].push(token); | ||
| emit TokenDeployed( | ||
| token, msg.sender, name, symbol, decimals_, | ||
| initialSupply, cap, permitEnabled, pauseEnabled | ||
| ); | ||
| } | ||
|
|
||
| /// @notice All tokens deployed by `owner`. | ||
| function tokensOf(address owner) external view returns (address[] memory) { | ||
| return deployedTokens[owner]; | ||
| } | ||
|
|
||
| /// @notice Number of tokens deployed by `owner`. | ||
| function tokenCount(address owner) external view returns (uint256) { | ||
| return deployedTokens[owner].length; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,139 @@ | ||
| // SPDX-License-Identifier: BUSL-1.1 | ||
| pragma solidity 0.8.24; | ||
|
|
||
| import {ERC4626} from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol"; | ||
| import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; | ||
| import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; | ||
| import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; | ||
| import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; | ||
|
|
||
| /// @title Vault4626 | ||
| /// @notice Minimal canonical ERC-4626 vault for the Sentrix ecosystem. | ||
| /// Accepts an underlying ERC-20 asset and mints vault shares (1:1 start). | ||
| /// @dev OpenZeppelin-derived. No yield strategy — yield sources extend this. | ||
| /// Inflation-attack guard: deploys with a small dead-share mint to make | ||
| /// donation attacks unprofitable (virtual offset pattern). | ||
| contract Vault4626 is ERC4626 { | ||
| using Math for uint256; | ||
|
|
||
| /// @notice Emitted when the vault receives a donation (pure asset transfer, no shares minted). | ||
| event Donation(address indexed donor, uint256 amount); | ||
|
|
||
| error Vault4626__ZeroDeposit(); | ||
|
|
||
| /// @param _asset Underlying ERC-20 token address. | ||
| /// @param _name Vault share token name. | ||
| /// @param _symbol Vault share token symbol. | ||
| constructor(IERC20 _asset, string memory _name, string memory _symbol) | ||
| ERC4626(_asset) | ||
| ERC20(_name, _symbol) | ||
| { | ||
| // Inflation-attack guard: mint 1e3 dead shares to make donation attacks | ||
| // prohibitively expensive. With a 1e3 share floor, an attacker needs | ||
| // 1e3 * share_price of underlying to front-run, making the attack cost | ||
| // exceed any reasonable first-deposit value. | ||
| _mint(address(0xdead), 1e3); | ||
| } | ||
|
|
||
| // ─── Overrides for correct rounding ───────────────────────────── | ||
|
|
||
| /// @inheritdoc ERC4626 | ||
| /// @dev Rounds UP (protects vault). Uses mulDiv with Math.Rounding.Ceil. | ||
| function _convertToShares(uint256 assets, Math.Rounding rounding) | ||
| internal | ||
| view | ||
| override | ||
| returns (uint256) | ||
| { | ||
| uint256 supply = totalSupply(); | ||
| // If no live supply beyond our dead shares, shares = assets (1:1 after offset) | ||
| if (supply == 1e3) return assets; | ||
|
|
||
| return _tryMulDiv(assets, supply - 1e3, totalAssets(), rounding); | ||
| } | ||
|
|
||
| /// @inheritdoc ERC4626 | ||
| /// @dev Rounds DOWN (protects vault). Uses mulDiv with Math.Rounding.Floor. | ||
| function _convertToAssets(uint256 shares, Math.Rounding rounding) | ||
| internal | ||
| view | ||
| override | ||
| returns (uint256) | ||
| { | ||
| uint256 supply = totalSupply(); | ||
| // If only dead shares exist, assets = shares (1:1) | ||
| if (supply == 1e3) return shares; | ||
|
|
||
| return _tryMulDiv(shares, totalAssets(), supply - 1e3, rounding); | ||
| } | ||
|
Comment on lines
+42
to
+68
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Critical: Dead-share accounting allows first depositor to steal donated assets. The conversion logic returns 1:1 when
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 🤖 Prompt for AI Agents |
||
|
|
||
| /// @inheritdoc ERC4626 | ||
| function totalAssets() public view override returns (uint256) { | ||
| return IERC20(asset()).balanceOf(address(this)); | ||
| } | ||
|
|
||
| /// @inheritdoc ERC4626 | ||
| function maxDeposit(address) public view override returns (uint256) { | ||
| return type(uint256).max; | ||
| } | ||
|
|
||
| /// @inheritdoc ERC4626 | ||
| function maxMint(address) public view override returns (uint256) { | ||
| return type(uint256).max; | ||
| } | ||
|
|
||
| // ─── Hook pattern for yield extensions ────────────────────────── | ||
|
|
||
| /// @notice Hook called after shares are minted (deposit/mint). | ||
| /// @dev Override in yield-bearing children to route assets to strategies. | ||
| function _afterDeposit(address caller, uint256 assets, uint256 shares) internal virtual {} | ||
|
|
||
| /// @notice Hook called before shares are burned (withdraw/redeem). | ||
| /// @dev Override in yield-bearing children to pull assets from strategies. | ||
| function _beforeWithdraw(address caller, uint256 assets, uint256 shares) internal virtual {} | ||
|
|
||
| // ─── Donation support ────────────────────────────────────────── | ||
|
|
||
| /// @notice Donate underlying assets to the vault. No shares minted in return. | ||
| /// Donations increase the share price for all existing shareholders. | ||
| function donate(uint256 assets) external { | ||
| if (assets == 0) revert Vault4626__ZeroDeposit(); | ||
| SafeERC20.safeTransferFrom(IERC20(asset()), msg.sender, address(this), assets); | ||
| emit Donation(msg.sender, assets); | ||
| } | ||
|
|
||
| // ─── Internal ────────────────────────────────────────────────── | ||
|
|
||
| /// @dev Override deposit to call the after-hook. | ||
| function _deposit(address caller, address receiver, uint256 assets, uint256 shares) internal override { | ||
| SafeERC20.safeTransferFrom(IERC20(asset()), caller, address(this), assets); | ||
| _mint(receiver, shares); | ||
| _afterDeposit(caller, assets, shares); | ||
| emit Deposit(caller, receiver, assets, shares); | ||
| } | ||
|
Comment on lines
+108
to
+113
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Adding 🤖 Prompt for AI Agents |
||
|
|
||
| /// @dev Override withdraw to call the before-hook. | ||
| function _withdraw(address caller, address receiver, address owner, uint256 assets, uint256 shares) | ||
| internal | ||
| override | ||
| { | ||
| _beforeWithdraw(caller, assets, shares); | ||
| if (caller != owner) { | ||
| _spendAllowance(owner, caller, shares); | ||
| } | ||
| _burn(owner, shares); | ||
| SafeERC20.safeTransfer(IERC20(asset()), receiver, assets); | ||
| emit Withdraw(caller, receiver, owner, assets, shares); | ||
| } | ||
|
|
||
| /// @dev Safe mulDiv that returns 0 when operands overflow, with configurable rounding. | ||
| function _tryMulDiv( | ||
| uint256 x, | ||
| uint256 y, | ||
| uint256 denominator, | ||
| Math.Rounding rounding | ||
| ) internal pure returns (uint256) { | ||
| if (denominator == 0) return 0; | ||
| return x.mulDiv(y, denominator, rounding); | ||
| } | ||
|
Comment on lines
+129
to
+138
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Silent zero return on division-by-zero can cause loss of funds. When 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 |
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| // SPDX-License-Identifier: BUSL-1.1 | ||
| pragma solidity 0.8.24; | ||
|
|
||
| /// @title ITokenFactoryV2 | ||
| /// @author Sentrix Labs | ||
| /// @notice Deploys extended ERC-20 tokens with Permit, optional supply cap, | ||
| /// and freeze controls. | ||
| interface ITokenFactoryV2 { | ||
| event TokenDeployed( | ||
| address indexed token, | ||
| address indexed owner, | ||
| string name, | ||
| string symbol, | ||
| uint8 decimals, | ||
| uint256 initialSupply, | ||
| uint256 cap, | ||
| bool permitEnabled, | ||
| bool pauseEnabled | ||
| ); | ||
|
|
||
| /// @notice Deploy a new FactoryTokenV2 with `initialSupply` minted to caller. | ||
| /// @param name Token name (1-64 chars) | ||
| /// @param symbol Token symbol (1-16 chars) | ||
| /// @param decimals_ Token decimals (e.g. 18) | ||
| /// @param initialSupply Amount minted to `msg.sender` on deploy | ||
| /// @param cap Maximum total supply (0 = unlimited) | ||
| /// @param permitEnabled Whether EIP-2612 permit is enabled | ||
| /// @param pauseEnabled Whether admin-pause is enabled | ||
| /// @return token Address of the new FactoryTokenV2 | ||
| function deployToken( | ||
| string calldata name, | ||
| string calldata symbol, | ||
| uint8 decimals_, | ||
| uint256 initialSupply, | ||
| uint256 cap, | ||
| bool permitEnabled, | ||
| bool pauseEnabled | ||
| ) external returns (address token); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| /// @notice Returns all tokens deployed by `owner`. | ||
| function tokensOf(address owner) external view returns (address[] memory); | ||
|
|
||
| /// @notice Number of tokens deployed by `owner`. | ||
| function tokenCount(address owner) external view returns (uint256); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: sentrix-labs/canonical-contracts
Length of output: 461
🏁 Script executed:
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
namewhich is passed to the EIP712 base contract [1][2]. If you provide an empty string ("") as thenameparameter, 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(""))) is0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470. This value will be used in the domain separator calculation [3]. While this is not a functional error that prevents thepermitfunction from working, it is generally discouraged for the following reasons: 1. Domain Uniqueness: Thenameparameter 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 samenamethat 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 thenamefield 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:
Repository: sentrix-labs/canonical-contracts
Length of output: 484
permitEnabledflag does not disablepermit()in its current implementation.The constructor passes
_permitEnabled ? _name : ""toERC20Permit, but this does not prevent the inheritedpermit()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=falsewill still have permit functionality available.Additionally,
_permitEnabledis 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