// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import {ERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol";
import {ERC20PermitUpgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20PermitUpgradeable.sol";
import {ERC20VotesUpgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20VotesUpgradeable.sol";
import {NoncesUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/NoncesUpgradeable.sol";
import {ReentrancyGuardUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardUpgradeable.sol";
contract CAPToken is
Initializable,
ERC20Upgradeable,
ERC20PermitUpgradeable,
ERC20VotesUpgradeable,
UUPSUpgradeable,
ReentrancyGuardUpgradeable
{
// Governance address - the only privileged address (typically an Aragon DAO)
address public governance;
// Constants
uint256 public constant BASIS_POINTS_DENOMINATOR = 10_000; // 100% = 10000 bp
uint256 public constant MAX_TAX_BP = 500; // 5% per individual tax
uint256 public constant MAX_TOTAL_TAX_BP = 1000; // 10% global cap for any combination of all three taxes
uint256 public constant INITIAL_SUPPLY = 1_000_000 ether; // 1M tokens
uint256 public constant MAX_SUPPLY = 42_000_000 ether; // 42M max supply cap
uint256 public constant TAX_CHANGE_DELAY = 24 hours; // Timelock delay for tax changes
uint256 public constant MINT_DELAY = 7 days; // Timelock delay for minting operations (7 days)
uint256 public constant MINT_CAP_PER_PERIOD = 10_000_000 ether; // Max 10M tokens per 30-day period
uint256 public constant MINT_PERIOD = 30 days; // Rolling 30-day period for mint cap
// Tax parameters (in basis points)
// transferTaxBp: applied to non-pool transfers only (user โ user)
// NOTE: Tax calculation uses integer division which causes precision loss for small amounts.
// For 18-decimal tokens: amounts < 10^(18 - floor(log10(taxBp))) may round down to zero tax.
// Example with 1% (100bp): transfers < 10^16 wei (0.01 tokens) will have zero tax due to rounding.
// This is acceptable behavior for standard ERC20 tokens and protects against dust attacks.
uint256 public transferTaxBp;
uint256 public sellTaxBp; // additional tax when user -> pool
uint256 public buyTaxBp; // applied when pool -> user (set to 0 by default)
// Pending tax changes (timelock)
uint256 public pendingTransferTaxBp;
uint256 public pendingSellTaxBp;
uint256 public pendingBuyTaxBp;
uint256 public taxChangeTimestamp; // When pending taxes can be applied
// Minting controls (timelock + rate limiting)
address public pendingMintTo;
uint256 public pendingMintAmount;
uint256 public mintTimestamp; // When pending mint can be executed
uint256 public lastMintPeriodStart; // Start of current 30-day period
uint256 public mintedInCurrentPeriod; // Amount minted in current period
address public feeRecipient; // zero address means burn mode (burns collected taxes)
mapping(address => bool) public isPool; // AMM pair addresses
// Events
event GovernanceTransferred(address indexed previousGovernance, address indexed newGovernance);
event PoolAdded(address indexed pool);
event PoolRemoved(address indexed pool);
event TaxChangeProposed(uint256 transferTaxBp, uint256 sellTaxBp, uint256 buyTaxBp, uint256 effectiveTime);
event TaxChangeCancelled(uint256 cancelledTransferTaxBp, uint256 cancelledSellTaxBp, uint256 cancelledBuyTaxBp);
event TaxesUpdated(uint256 transferTaxBp, uint256 sellTaxBp, uint256 buyTaxBp);
event FeeRecipientUpdated(address indexed oldRecipient, address indexed newRecipient);
event TaxBurned(address indexed from, address indexed to, uint256 grossAmount, uint256 taxAmount);
event TaxCollected(address indexed from, address indexed to, uint256 grossAmount, uint256 taxAmount, address indexed recipient);
event MintProposed(address indexed to, uint256 amount, uint256 effectiveTime);
event MintCancelled(address indexed to, uint256 amount);
event TokensMinted(address indexed to, uint256 amount);
/// @notice Modifier to restrict access to governance address only
modifier onlyGovernance() {
require(msg.sender == governance, "ONLY_GOVERNANCE");
_;
}
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers();
}
/// @notice Initialize the token contract
/// @param _governance The initial governance address (deployer initially, then transfer to Aragon DAO)
/// @param _feeRecipient The fee recipient address. Use address(0) to enable burn mode where taxes are burned instead of collected
/// @dev SECURITY: After deployment, call setGovernance() to transfer control to the Aragon DAO
function initialize(address _governance, address _feeRecipient) public initializer {
require(_governance != address(0), "ZERO_GOVERNANCE");
__ERC20_init("Cyberia", "CAP");
__ERC20Permit_init("Cyberia");
__ERC20Votes_init();
__UUPSUpgradeable_init();
__ReentrancyGuard_init();
// Set governance address (initially deployer, then transferred to DAO)
governance = _governance;
// Default taxes per spec
transferTaxBp = 100; // 1%
sellTaxBp = 100; // 1%
buyTaxBp = 0; // 0%
feeRecipient = _feeRecipient; // recommended to be DAO safe; zero address enables burn mode
// Initialize minting period tracking
lastMintPeriodStart = block.timestamp;
mintedInCurrentPeriod = 0;
// Initial supply to governance address
_mint(_governance, INITIAL_SUPPLY);
}
/// @notice Transfer governance to a new address (typically an Aragon DAO)
/// @param _newGovernance The new governance address
/// @dev Only callable by current governance. Use this to transfer control to the DAO after deployment.
function setGovernance(address _newGovernance) external onlyGovernance {
require(_newGovernance != address(0), "ZERO_GOVERNANCE");
address oldGovernance = governance;
governance = _newGovernance;
emit GovernanceTransferred(oldGovernance, _newGovernance);
}
// Admin functions
/// @notice Propose new tax rates (requires timelock delay before applying)
/// @dev Only callable by governance (Aragon DAO via DAO.execute())
function proposeTaxChange(uint256 _transferTaxBp, uint256 _sellTaxBp, uint256 _buyTaxBp) external onlyGovernance {
require(_transferTaxBp <= MAX_TAX_BP, "TRANSFER_TAX_TOO_HIGH");
require(_sellTaxBp <= MAX_TAX_BP, "SELL_TAX_TOO_HIGH");
require(_buyTaxBp <= MAX_TAX_BP, "BUY_TAX_TOO_HIGH");
// Global cap: prevent sum of all taxes from being too high
require(_transferTaxBp + _sellTaxBp + _buyTaxBp <= MAX_TOTAL_TAX_BP, "TOTAL_TAX_TOO_HIGH");
pendingTransferTaxBp = _transferTaxBp;
pendingSellTaxBp = _sellTaxBp;
pendingBuyTaxBp = _buyTaxBp;
taxChangeTimestamp = block.timestamp + TAX_CHANGE_DELAY;
emit TaxChangeProposed(_transferTaxBp, _sellTaxBp, _buyTaxBp, taxChangeTimestamp);
}
/// @notice Apply pending tax changes after timelock delay
/// @dev Only callable by governance (Aragon DAO via DAO.execute())
function applyTaxChange() external onlyGovernance {
require(taxChangeTimestamp != 0, "NO_PENDING_CHANGE");
require(block.timestamp >= taxChangeTimestamp, "TIMELOCK_NOT_EXPIRED");
transferTaxBp = pendingTransferTaxBp;
sellTaxBp = pendingSellTaxBp;
buyTaxBp = pendingBuyTaxBp;
// Reset pending state
taxChangeTimestamp = 0;
emit TaxesUpdated(transferTaxBp, sellTaxBp, buyTaxBp);
}
/// @notice Cancel a pending tax change before it takes effect
/// @dev Only callable by governance (Aragon DAO via DAO.execute())
function cancelTaxChange() external onlyGovernance {
require(taxChangeTimestamp != 0, "NO_PENDING_CHANGE");
// Store the cancelled values for the event
uint256 cancelledTransfer = pendingTransferTaxBp;
uint256 cancelledSell = pendingSellTaxBp;
uint256 cancelledBuy = pendingBuyTaxBp;
// Reset pending state
pendingTransferTaxBp = 0;
pendingSellTaxBp = 0;
pendingBuyTaxBp = 0;
taxChangeTimestamp = 0;
emit TaxChangeCancelled(cancelledTransfer, cancelledSell, cancelledBuy);
}
/// @notice Update the fee recipient address
/// @param _feeRecipient The new fee recipient address. Use address(0) to enable burn mode where taxes are burned instead of collected
/// @dev Only callable by governance (Aragon DAO via DAO.execute()). Cannot be set to contract address (security)
/// @dev SECURITY: Prevents accidental locking of funds by setting contract as its own fee recipient
function setFeeRecipient(address _feeRecipient) external onlyGovernance {
require(_feeRecipient != address(this), "FEE_RECIPIENT_CANNOT_BE_CONTRACT");
address oldRecipient = feeRecipient;
feeRecipient = _feeRecipient; // zero address enables burn mode
emit FeeRecipientUpdated(oldRecipient, _feeRecipient);
}
/// @notice Add a pool address for tax calculations
/// @dev Only callable by governance (Aragon DAO via DAO.execute())
function addPool(address pool) external onlyGovernance {
require(pool != address(0), "ZERO_ADDR");
require(!isPool[pool], "EXISTS");
isPool[pool] = true;
emit PoolAdded(pool);
}
/// @notice Remove a pool address from tax calculations
/// @dev Only callable by governance (Aragon DAO via DAO.execute())
function removePool(address pool) external onlyGovernance {
require(isPool[pool], "NOT_POOL");
delete isPool[pool];
emit PoolRemoved(pool);
}
function burn(uint256 amount) external {
_burn(_msgSender(), amount);
}
function burnFrom(address account, uint256 amount) external {
_spendAllowance(account, _msgSender(), amount);
_burn(account, amount);
}
/// @notice Propose minting new tokens (requires 7-day timelock before executing)
/// @dev Only callable by governance (Aragon DAO via DAO.execute()). Subject to 30-day rolling window rate limiting (max 100M per period)
/// @param to Address to receive minted tokens
/// @param amount Amount of tokens to mint (subject to MINT_CAP_PER_PERIOD limit and MAX_SUPPLY cap)
function proposeMint(address to, uint256 amount) external onlyGovernance {
require(to != address(0), "MINT_TO_ZERO");
require(totalSupply() + amount <= MAX_SUPPLY, "EXCEEDS_MAX_SUPPLY");
// Reset period if needed (use increment to enforce rolling window)
if (block.timestamp >= lastMintPeriodStart + MINT_PERIOD) {
// Calculate how many periods have elapsed and advance by that many periods
uint256 elapsedPeriods = (block.timestamp - lastMintPeriodStart) / MINT_PERIOD;
lastMintPeriodStart += elapsedPeriods * MINT_PERIOD;
mintedInCurrentPeriod = 0;
}
// Check rate limiting
require(mintedInCurrentPeriod + amount <= MINT_CAP_PER_PERIOD, "EXCEEDS_MINT_CAP_PER_PERIOD");
pendingMintTo = to;
pendingMintAmount = amount;
mintTimestamp = block.timestamp + MINT_DELAY;
emit MintProposed(to, amount, mintTimestamp);
}
/// @notice Execute pending mint after timelock delay
/// @dev Only callable by governance (Aragon DAO via DAO.execute())
function executeMint() external onlyGovernance {
require(mintTimestamp != 0, "NO_PENDING_MINT");
require(block.timestamp >= mintTimestamp, "MINT_TIMELOCK_NOT_EXPIRED");
require(pendingMintTo != address(0), "MINT_TO_ZERO");
address to = pendingMintTo;
uint256 amount = pendingMintAmount;
// Reset period if needed (use increment to enforce rolling window)
if (block.timestamp >= lastMintPeriodStart + MINT_PERIOD) {
// Calculate how many periods have elapsed and advance by that many periods
uint256 elapsedPeriods = (block.timestamp - lastMintPeriodStart) / MINT_PERIOD;
lastMintPeriodStart += elapsedPeriods * MINT_PERIOD;
mintedInCurrentPeriod = 0;
}
// Final checks
require(totalSupply() + amount <= MAX_SUPPLY, "EXCEEDS_MAX_SUPPLY");
require(mintedInCurrentPeriod + amount <= MINT_CAP_PER_PERIOD, "EXCEEDS_MINT_CAP_PER_PERIOD");
// Update tracking
mintedInCurrentPeriod += amount;
// Reset pending state
pendingMintTo = address(0);
pendingMintAmount = 0;
mintTimestamp = 0;
_mint(to, amount);
emit TokensMinted(to, amount);
}
/// @notice Cancel a pending mint before it takes effect
/// @dev Only callable by governance (Aragon DAO via DAO.execute())
function cancelMint() external onlyGovernance {
require(mintTimestamp != 0, "NO_PENDING_MINT");
address to = pendingMintTo;
uint256 amount = pendingMintAmount;
// Reset pending state
pendingMintTo = address(0);
pendingMintAmount = 0;
mintTimestamp = 0;
emit MintCancelled(to, amount);
}
// Internal tax logic applied on transfers
function _update(address from, address to, uint256 value) internal override(ERC20Upgradeable, ERC20VotesUpgradeable) nonReentrant {
// No tax on mints/burns
if (from == address(0) || to == address(0)) {
super._update(from, to, value);
return;
}
uint256 taxBp = 0;
bool toIsPool = isPool[to];
bool fromIsPool = isPool[from];
if (fromIsPool && toIsPool) {
// Pool-to-pool transfer: no tax (liquidity migration, AMM operations)
taxBp = 0;
} else if (fromIsPool && !toIsPool) {
// Buy: pool -> user
taxBp = buyTaxBp;
} else if (!fromIsPool && toIsPool) {
// Sell: user -> pool (sell tax only, no transfer tax)
taxBp = sellTaxBp;
} else {
// Regular transfer: user -> user (non-pool on both sides)
taxBp = transferTaxBp;
}
uint256 taxAmount = (value * taxBp) / BASIS_POINTS_DENOMINATOR;
uint256 sendAmount = value - taxAmount;
if (taxAmount > 0) {
if (feeRecipient == address(0)) {
// Burn mode: reduce supply and emit Transfer(from, 0x0, taxAmount)
super._update(from, address(0), taxAmount);
emit TaxBurned(from, to, value, taxAmount);
} else {
// Transfer fee to recipient
super._update(from, feeRecipient, taxAmount);
emit TaxCollected(from, to, value, taxAmount, feeRecipient);
}
}
// Transfer net amount to recipient
super._update(from, to, sendAmount);
}
// UUPS authorization
/// @dev Only callable by governance (Aragon DAO via DAO.execute())
function _authorizeUpgrade(address newImplementation) internal override onlyGovernance {}
// Required overrides for Solidity
function nonces(address owner) public view override(ERC20PermitUpgradeable, NoncesUpgradeable) returns (uint256) {
return super.nonces(owner);
}
function _maxSupply() internal pure override returns (uint256) {
return type(uint224).max; // use Votes default
}
/**
* @dev Storage gap for future upgrades
* This reserves storage slots for adding new state variables in future contract upgrades
* without causing storage collisions. Current usage: 0/40 slots.
*
* IMPORTANT: When adding new state variables in upgrades:
* 1. Add the variable BEFORE the gap
* 2. Reduce gap size by the number of slots used (e.g., uint256[39] for 1 slot used)
*/
uint256[40] private __gap;
}