diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 4bad658..0000000 Binary files a/.DS_Store and /dev/null differ diff --git a/foundry.toml b/foundry.toml index b5fe6f2..5707efe 100644 --- a/foundry.toml +++ b/foundry.toml @@ -2,7 +2,7 @@ src = "src" out = "out" libs = ["lib"] -solc_version = "0.8.22" +solc_version = "0.8.24" remappings = [ "@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/", "forge-std/=lib/forge-std/src/", diff --git a/script/Deploy.s.sol b/script/Deploy.s.sol index 811b8a5..e94d8f6 100644 --- a/script/Deploy.s.sol +++ b/script/Deploy.s.sol @@ -6,10 +6,12 @@ import {SmartnodesCore} from "../src/SmartnodesCore.sol"; import {SmartnodesERC20} from "../src/SmartnodesERC20.sol"; import {SmartnodesCoordinator} from "../src/SmartnodesCoordinator.sol"; import {SmartnodesDAO} from "../src/SmartnodesDAO.sol"; +import {TimelockController} from "@openzeppelin/contracts/governance/TimelockController.sol"; -uint256 constant DAO_VOTING_PERIOD = 7 days; -uint256 constant DEPLOYMENT_MULTIPLIER = 1; -uint256 constant INTERVAL_SECONDS = 1 hours; +// DAO Configuration +uint256 constant TIMELOCK_DELAY = 2 days; +uint128 constant BASE_UPDATE_TIME = uint128(8 hours); +uint8 constant PROPOSAL_THRESHOLD_PERCENTAGE = 66; contract Deploy is Script { address[] genesis; @@ -18,41 +20,53 @@ contract Deploy is Script { function run() external { genesis.push(msg.sender); genesis.push(0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266); + address[] memory proposers = new address[](0); + address[] memory executors = new address[](0); vm.startBroadcast(); - SmartnodesERC20 token = new SmartnodesERC20( - DEPLOYMENT_MULTIPLIER, - genesis - ); - SmartnodesDAO dao = new SmartnodesDAO( - address(token), - DAO_VOTING_PERIOD, - 1000 + SmartnodesERC20 token = new SmartnodesERC20(genesis); + TimelockController timelock = new TimelockController( + TIMELOCK_DELAY, + proposers, + executors, + msg.sender // Temporary admin to set up roles ); + SmartnodesDAO dao = new SmartnodesDAO(token, timelock); SmartnodesCore core = new SmartnodesCore(address(token)); SmartnodesCoordinator coordinator = new SmartnodesCoordinator( - uint128(INTERVAL_SECONDS * DEPLOYMENT_MULTIPLIER), - 66, + BASE_UPDATE_TIME, + PROPOSAL_THRESHOLD_PERCENTAGE, address(core), address(token), initialActiveNodes ); token.setSmartnodes(address(core), address(coordinator)); - - token.setDAO(address(dao)); + token.setDAO(address(timelock)); core.setCoordinator(address(coordinator)); - bytes32 publicKeyHash = vm.envBytes32("PUBLIC_KEY_HASH"); + // Configure timelock roles + bytes32 PROPOSER_ROLE = timelock.PROPOSER_ROLE(); + bytes32 EXECUTOR_ROLE = timelock.EXECUTOR_ROLE(); + bytes32 CANCELLER_ROLE = timelock.CANCELLER_ROLE(); + bytes32 DEFAULT_ADMIN_ROLE = timelock.DEFAULT_ADMIN_ROLE(); + // Grant DAO the proposer and canceller roles + timelock.grantRole(PROPOSER_ROLE, address(dao)); + timelock.grantRole(CANCELLER_ROLE, address(dao)); + timelock.grantRole(EXECUTOR_ROLE, address(0)); + timelock.renounceRole(DEFAULT_ADMIN_ROLE, msg.sender); + + bytes32 publicKeyHash = vm.envBytes32("PUBLIC_KEY_HASH"); core.createValidator(publicKeyHash); coordinator.addValidator(); console.log("Token:", address(token)); + console.log("Timelock:", address(timelock)); + console.log("DAO:", address(dao)); console.log("Core:", address(core)); console.log("Coordinator:", address(coordinator)); - console.log("DAO:", address(dao)); vm.stopBroadcast(); } diff --git a/src/SmartnodesCoordinator.sol b/src/SmartnodesCoordinator.sol index 03f6336..5889f46 100644 --- a/src/SmartnodesCoordinator.sol +++ b/src/SmartnodesCoordinator.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.22; +pragma solidity ^0.8.24; import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import {ISmartnodesCore} from "./interfaces/ISmartnodesCore.sol"; @@ -59,10 +59,7 @@ contract SmartnodesCoordinator is ReentrancyGuard { mapping(address => uint256) public validatorVote; mapping(address => uint8) public hasSubmittedProposal; mapping(address => uint256) public validatorLastActiveRound; // Track validator activity - - // Enhanced round management - uint256 public currentRoundNumber; - uint256 private roundSeed; // Used for deterministic but unpredictable validator selection + uint256 private roundSeed; // ============= Events ============== event ProposalCreated( @@ -150,7 +147,6 @@ contract SmartnodesCoordinator is ReentrancyGuard { } // Initialize round management - currentRoundNumber = 1; roundSeed = uint256( keccak256( abi.encode(block.timestamp, block.prevrandao, _genesisNodes) @@ -206,7 +202,7 @@ contract SmartnodesCoordinator is ReentrancyGuard { // mark as active hasSubmittedProposal[sender] = proposalNum; - validatorLastActiveRound[sender] = currentRoundNumber; + validatorLastActiveRound[sender] = nextProposalId; emit ProposalCreated(proposalNum, proposalHash, sender); } @@ -228,7 +224,7 @@ contract SmartnodesCoordinator is ReentrancyGuard { Proposal storage proposal = currentProposals[proposalId - 1]; validatorVote[msg.sender] = proposalId; - validatorLastActiveRound[msg.sender] = currentRoundNumber; // Mark as active + validatorLastActiveRound[msg.sender] = nextProposalId; // Mark as active proposal.votes++; } @@ -281,7 +277,7 @@ contract SmartnodesCoordinator is ReentrancyGuard { } // Mark executor as active - validatorLastActiveRound[msg.sender] = currentRoundNumber; + validatorLastActiveRound[msg.sender] = nextProposalId; // Optimized batch validator removal if (validatorsToRemove.length > 0) { @@ -341,7 +337,7 @@ contract SmartnodesCoordinator is ReentrancyGuard { validators.push(validator); isValidator[validator] = true; - validatorLastActiveRound[validator] = currentRoundNumber; // Mark as active from start + validatorLastActiveRound[validator] = nextProposalId; // Mark as active from start emit ValidatorAdded(validator); } @@ -438,7 +434,6 @@ contract SmartnodesCoordinator is ReentrancyGuard { function _updateRound() internal { _resetValidatorStates(); - currentRoundNumber++; _selectNewRoundValidators(); delete currentProposals; // Clear proposals for new round timeConfig.lastExecutionTime = uint128(block.timestamp); @@ -492,7 +487,7 @@ contract SmartnodesCoordinator is ReentrancyGuard { roundSeed, block.timestamp, block.prevrandao, - currentRoundNumber, + nextProposalId, blockhash(block.number - 1) ) ) @@ -511,8 +506,8 @@ contract SmartnodesCoordinator is ReentrancyGuard { // Prioritize active validators (those who participated in recent rounds) uint256 selectedCount = 0; - uint256 inactivityThreshold = currentRoundNumber > 3 - ? currentRoundNumber - 3 + uint256 inactivityThreshold = nextProposalId > 3 + ? nextProposalId - 3 : 0; // First, try to select active validators @@ -552,7 +547,7 @@ contract SmartnodesCoordinator is ReentrancyGuard { } } - emit NewRoundStarted(currentRoundNumber, currentRoundValidators); + emit NewRoundStarted(nextProposalId, currentRoundValidators); } function _computeProposalHash( @@ -628,7 +623,8 @@ contract SmartnodesCoordinator is ReentrancyGuard { function _isCurrentRoundExpired() internal view returns (bool) { TimeConfig memory tc = timeConfig; - return block.timestamp > tc.lastExecutionTime + (tc.updateTime << 1); + return + block.timestamp > tc.lastExecutionTime + ((tc.updateTime * 5) / 4); } // ============= View Functions ============= @@ -726,7 +722,7 @@ contract SmartnodesCoordinator is ReentrancyGuard { ) { return ( - currentRoundNumber, + nextProposalId, currentRoundValidators.length, _calculateRoundValidatorCount(), _calculateRequiredVotes() diff --git a/src/SmartnodesCore.sol b/src/SmartnodesCore.sol index d9ba784..786f58c 100644 --- a/src/SmartnodesCore.sol +++ b/src/SmartnodesCore.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.22; +pragma solidity ^0.8.24; import {ISmartnodesCoordinator} from "./interfaces/ISmartnodesCoordinator.sol"; import {ISmartnodesERC20, PaymentAmounts} from "./interfaces/ISmartnodesERC20.sol"; @@ -19,6 +19,7 @@ contract SmartnodesCore { error Core__NotValidatorMultisig(); error Core__NotToken(); error Core__NodeExists(); + error Core__NodeDoesNotExist(); // ============= Events ============== enum JobState { @@ -127,6 +128,29 @@ contract SmartnodesCore { i_tokenContract.lockTokens(userAddress, false); } + function unlockValidator() external { + address nodeAddress = msg.sender; + Node storage validator = validators[nodeAddress]; + + if (!validator.exists || !validator.locked) + revert Core__NodeDoesNotExist(); + + validator.locked = false; + + i_tokenContract.unlockTokens(nodeAddress, true); + } + + function unlockUser() external { + address nodeAddress = msg.sender; + Node storage user = users[nodeAddress]; + + if (!user.exists || !user.locked) revert Core__NodeDoesNotExist(); + + user.locked = false; + + i_tokenContract.unlockTokens(nodeAddress, true); + } + /** * @notice Requests a new job to be created with a form of payment * @param _userId Unique identifier associated with P2P node for the requesting user diff --git a/src/SmartnodesDAO.sol b/src/SmartnodesDAO.sol index e91a61e..7ca41ee 100644 --- a/src/SmartnodesDAO.sol +++ b/src/SmartnodesDAO.sol @@ -1,449 +1,118 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.22; - -import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +pragma solidity ^0.8.24; + +import {Governor} from "@openzeppelin/contracts/governance/Governor.sol"; +import {GovernorCountingSimple} from "@openzeppelin/contracts/governance/extensions/GovernorCountingSimple.sol"; +import {GovernorVotes} from "@openzeppelin/contracts/governance/extensions/GovernorVotes.sol"; +import {GovernorVotesQuorumFraction} from "@openzeppelin/contracts/governance/extensions/GovernorVotesQuorumFraction.sol"; +import {GovernorTimelockControl} from "@openzeppelin/contracts/governance/extensions/GovernorTimelockControl.sol"; +import {TimelockController} from "@openzeppelin/contracts/governance/TimelockController.sol"; +import {IVotes} from "@openzeppelin/contracts/governance/utils/IVotes.sol"; import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; -contract SmartnodesDAO is ReentrancyGuard { - using SafeERC20 for IERC20; - - // Constants - uint256 public constant MAX_TARGETS = 3; - uint256 public constant MIN_VOTING_PERIOD = 1 days; - uint256 public constant MAX_VOTING_PERIOD = 30 days; - uint256 public constant TIMELOCK_DELAY = 2 days; - uint256 public constant GRACE_PERIOD = 14 days; - uint256 public constant MINIMUM_PROPOSAL_THRESHOLD = 1000e18; - - // Custom errors - error SmartnodesDAO__ZeroAddress(); - error SmartnodesDAO__InvalidProposalId(); - error SmartnodesDAO__TooManyTargets(); - error SmartnodesDAO__InvalidVotingPeriod(); - error SmartnodesDAO__InsufficientProposalThreshold(); - error SmartnodesDAO__AlreadyVoted(); - error SmartnodesDAO__ProposalNotActive(); - error SmartnodesDAO__ProposalAlreadyExecuted(); - error SmartnodesDAO__ProposalNotQueued(); - error SmartnodesDAO__TimelockNotMet(); - error SmartnodesDAO__GracePeriodExpired(); - error SmartnodesDAO__ExecutionFailed(uint256 idx); - error SmartnodesDAO__NoLockedTokens(); - error SmartnodesDAO__QuorumNotReached(); - error SmartnodesDAO__ProposalDidNotPass(); - error SmartnodesDAO__MismatchedArrays(); - error SmartnodesDAO__InsufficientTokens(); - error SmartnodesDAO__InvalidQuorumPercentage(); - error SmartnodesDAO__NotProposer(); - - IERC20 public immutable token; - - uint256 public proposalCount; - uint256 public votingPeriod; - uint256 public quorumPercentage; // Basis points (e.g., 1000 = 10%) - - enum ProposalState { - Pending, - Active, - Canceled, - Defeated, - Succeeded, - Queued, - Expired, - Executed - } - - struct Proposal { - uint128 id; - uint128 startTime; - uint128 endTime; - uint128 queueTime; - uint128 forVotes; - uint128 againstVotes; - address proposer; - bool executed; - bool canceled; - bool queued; - address[] targets; - bytes[] calldatas; - uint256[] values; - string description; - } - - // Mappings - mapping(uint256 => Proposal) public proposals; - mapping(uint256 => mapping(address => bool)) public hasVoted; - mapping(uint256 => mapping(address => uint256)) public tokensLocked; - mapping(address => uint256) public totalTokensLockedBy; - - // Events - event ProposalCreated( - uint256 indexed id, - address indexed proposer, - address[] targets, - uint256 startTime, - uint256 endTime, - string description - ); - - event Voted( - uint256 indexed proposalId, - address indexed voter, - bool support, - uint256 votes, - uint256 tokensStaked - ); - - event ProposalQueued(uint256 indexed proposalId, uint256 executionTime); - event ProposalExecuted(uint256 indexed proposalId); - event ProposalCanceled(uint256 indexed proposalId); - - event RefundClaimed( - uint256 indexed proposalId, - address indexed voter, - uint256 amount - ); - - modifier validProposal(uint256 proposalId) { - if (proposalId == 0 || proposalId > proposalCount) { - revert SmartnodesDAO__InvalidProposalId(); - } - _; - } - +contract SmartnodesDAO is + ReentrancyGuard, + Governor, + GovernorCountingSimple, + GovernorVotes, + GovernorVotesQuorumFraction, + GovernorTimelockControl +{ constructor( - address _token, - uint256 _votingPeriod, - uint256 _quorumPercentage - ) { - if (_token == address(0)) revert SmartnodesDAO__ZeroAddress(); - if ( - _votingPeriod < MIN_VOTING_PERIOD || - _votingPeriod > MAX_VOTING_PERIOD - ) { - revert SmartnodesDAO__InvalidVotingPeriod(); - } - if (_quorumPercentage == 0 || _quorumPercentage > 10000) { - revert SmartnodesDAO__InvalidQuorumPercentage(); - } + IVotes _token, + TimelockController _timelock + ) + Governor("SmartnodesDAO") + GovernorVotes(_token) + GovernorVotesQuorumFraction(10) + GovernorTimelockControl(_timelock) + {} - token = IERC20(_token); - votingPeriod = _votingPeriod; - quorumPercentage = _quorumPercentage; + function votingDelay() public pure override returns (uint256) { + return 7200; // 1 day } - // ---------- Proposal Functions ---------- - - function propose( - address[] calldata targets, - bytes[] calldata calldatas, - uint256[] calldata values, - string calldata description - ) external returns (uint256) { - uint256 targetsLength = targets.length; - - // Input validation - if (targetsLength == 0 || targetsLength > MAX_TARGETS) { - revert SmartnodesDAO__TooManyTargets(); - } - if (calldatas.length != targetsLength) { - revert SmartnodesDAO__MismatchedArrays(); - } - if (token.balanceOf(msg.sender) < MINIMUM_PROPOSAL_THRESHOLD) { - revert SmartnodesDAO__InsufficientProposalThreshold(); - } - - // Validate targets - for (uint256 i = 0; i < targetsLength; ++i) { - if (targets[i] == address(0)) revert SmartnodesDAO__ZeroAddress(); - } - - unchecked { - ++proposalCount; - } - uint256 id = proposalCount; - - Proposal storage p = proposals[id]; - p.id = uint128(id); - p.proposer = msg.sender; - p.startTime = uint128(block.timestamp); - p.endTime = uint128(block.timestamp + votingPeriod); - - p.targets = targets; - p.calldatas = calldatas; - p.values = values; - p.description = description; - - emit ProposalCreated( - id, - msg.sender, - p.targets, - p.startTime, - p.endTime, - description - ); - - return id; + function votingPeriod() public pure override returns (uint256) { + return 1 weeks; // 1 week } - /** - * @dev Vote on a proposal with 1:1 token voting power - * @param proposalId The proposal to vote on - * @param support Whether to vote for (true) or against (false) - * @param tokensToLock The amount of tokens to lock for this vote - */ - function vote( - uint256 proposalId, - bool support, - uint256 tokensToLock - ) external nonReentrant validProposal(proposalId) { - Proposal storage p = proposals[proposalId]; - - if (block.timestamp < p.startTime || block.timestamp >= p.endTime) { - revert SmartnodesDAO__ProposalNotActive(); - } - if (hasVoted[proposalId][msg.sender]) { - revert SmartnodesDAO__AlreadyVoted(); - } - if (tokensToLock == 0) { - revert SmartnodesDAO__InsufficientTokens(); - } - - // Transfer tokens (will revert if insufficient balance/approval) - token.safeTransferFrom(msg.sender, address(this), tokensToLock); - - // Update state - hasVoted[proposalId][msg.sender] = true; - tokensLocked[proposalId][msg.sender] = tokensToLock; - totalTokensLockedBy[msg.sender] += tokensToLock; - - // Convert to vote count (1:1 token to vote ratio) - uint256 votes = tokensToLock; - - if (support) { - p.forVotes += uint128(votes); - } else { - p.againstVotes += uint128(votes); - } - - emit Voted(proposalId, msg.sender, support, votes, tokensToLock); + function proposalThreshold() public pure override returns (uint256) { + return 0; } - function queue( - uint256 proposalId - ) external nonReentrant validProposal(proposalId) { - Proposal storage p = proposals[proposalId]; - ProposalState currentState = state(proposalId); - - if (currentState != ProposalState.Succeeded) { - revert SmartnodesDAO__ProposalDidNotPass(); - } - - p.queued = true; - p.queueTime = uint128(block.timestamp + TIMELOCK_DELAY); - - emit ProposalQueued(proposalId, p.queueTime); - } + // The functions below are overrides required by Solidity. - function execute( + function state( uint256 proposalId - ) external nonReentrant validProposal(proposalId) { - Proposal storage p = proposals[proposalId]; - ProposalState currentState = state(proposalId); - - if (currentState != ProposalState.Queued) { - revert SmartnodesDAO__ProposalNotQueued(); - } - if (block.timestamp < p.queueTime) { - revert SmartnodesDAO__TimelockNotMet(); - } - if (block.timestamp > p.queueTime + GRACE_PERIOD) { - revert SmartnodesDAO__GracePeriodExpired(); - } - - p.executed = true; - - // Execute all calls - uint256 targetsLength = p.targets.length; - for (uint256 i = 0; i < targetsLength; ++i) { - (bool success, bytes memory returnData) = p.targets[i].call{ - value: p.values[i] - }(p.calldatas[i]); - - if (!success) { - // Handle revert reason - if (returnData.length > 0) { - assembly { - revert(add(32, returnData), mload(returnData)) - } - } else { - revert SmartnodesDAO__ExecutionFailed(i); - } - } - } - - emit ProposalExecuted(proposalId); - } - - function cancel(uint256 proposalId) external validProposal(proposalId) { - Proposal storage p = proposals[proposalId]; - - // Only proposer can cancel their own proposal - if (msg.sender != p.proposer) { - revert SmartnodesDAO__NotProposer(); - } - - ProposalState currentState = state(proposalId); - if (currentState == ProposalState.Executed) { - revert SmartnodesDAO__ProposalAlreadyExecuted(); - } - - p.canceled = true; - emit ProposalCanceled(proposalId); + ) + public + view + override(Governor, GovernorTimelockControl) + returns (ProposalState) + { + return super.state(proposalId); } - // ---------- Refund Functions ---------- - - function claimRefund( + function proposalNeedsQueuing( uint256 proposalId - ) external nonReentrant validProposal(proposalId) { - ProposalState currentState = state(proposalId); - - // Can only claim after proposal is no longer active - if ( - currentState == ProposalState.Active || - currentState == ProposalState.Pending - ) { - revert SmartnodesDAO__ProposalNotActive(); - } - - uint256 locked = tokensLocked[proposalId][msg.sender]; - if (locked == 0) revert SmartnodesDAO__NoLockedTokens(); - - // Clear state before transfer - tokensLocked[proposalId][msg.sender] = 0; - - // Update total safely - uint256 currentTotal = totalTokensLockedBy[msg.sender]; - totalTokensLockedBy[msg.sender] = currentTotal >= locked - ? currentTotal - locked - : 0; - - token.safeTransfer(msg.sender, locked); - emit RefundClaimed(proposalId, msg.sender, locked); - } - - function batchClaimRefunds( - uint256[] calldata proposalIds - ) external nonReentrant { - uint256 totalRefund = 0; - uint256 proposalIdsLength = proposalIds.length; - - for (uint256 i = 0; i < proposalIdsLength; ++i) { - uint256 proposalId = proposalIds[i]; - if (proposalId == 0 || proposalId > proposalCount) continue; - - ProposalState currentState = state(proposalId); - if ( - currentState == ProposalState.Active || - currentState == ProposalState.Pending - ) { - continue; - } - - uint256 locked = tokensLocked[proposalId][msg.sender]; - if (locked > 0) { - tokensLocked[proposalId][msg.sender] = 0; - totalRefund += locked; - emit RefundClaimed(proposalId, msg.sender, locked); - } - } - - if (totalRefund > 0) { - // Update total safely - uint256 currentTotal = totalTokensLockedBy[msg.sender]; - totalTokensLockedBy[msg.sender] = currentTotal >= totalRefund - ? currentTotal - totalRefund - : 0; - - token.safeTransfer(msg.sender, totalRefund); - } - } - - // ---------- View Functions ---------- - - function state(uint256 proposalId) public view returns (ProposalState) { - if (proposalId == 0 || proposalId > proposalCount) { - revert SmartnodesDAO__InvalidProposalId(); - } - - Proposal storage p = proposals[proposalId]; - - if (p.canceled) { - return ProposalState.Canceled; - } - if (p.executed) { - return ProposalState.Executed; - } - if (block.timestamp < p.startTime) { - return ProposalState.Pending; - } - if (block.timestamp < p.endTime) { - return ProposalState.Active; - } - - // Proposal ended, check results - uint256 requiredQuorum = quorumRequired(); - uint256 totalVotes = uint256(p.forVotes) + uint256(p.againstVotes); - - if (totalVotes < requiredQuorum || p.forVotes <= p.againstVotes) { - return ProposalState.Defeated; - } - - if (p.queued) { - if (block.timestamp >= p.queueTime + GRACE_PERIOD) { - return ProposalState.Expired; - } - return ProposalState.Queued; - } - - return ProposalState.Succeeded; + ) + public + view + virtual + override(Governor, GovernorTimelockControl) + returns (bool) + { + return super.proposalNeedsQueuing(proposalId); } - function getProposal( - uint256 proposalId - ) external view returns (Proposal memory) { - return proposals[proposalId]; + function _queueOperations( + uint256 proposalId, + address[] memory targets, + uint256[] memory values, + bytes[] memory calldatas, + bytes32 descriptionHash + ) internal override(Governor, GovernorTimelockControl) returns (uint48) { + return + super._queueOperations( + proposalId, + targets, + values, + calldatas, + descriptionHash + ); + } + + function _executeOperations( + uint256 proposalId, + address[] memory targets, + uint256[] memory values, + bytes[] memory calldatas, + bytes32 descriptionHash + ) internal override(Governor, GovernorTimelockControl) { + super._executeOperations( + proposalId, + targets, + values, + calldatas, + descriptionHash + ); } - function getVotesOf( - uint256 proposalId, - address voter - ) external view returns (bool voted, uint256 lockedTokens) { - voted = hasVoted[proposalId][voter]; - lockedTokens = tokensLocked[proposalId][voter]; + function _cancel( + address[] memory targets, + uint256[] memory values, + bytes[] memory calldatas, + bytes32 descriptionHash + ) internal override(Governor, GovernorTimelockControl) returns (uint256) { + return super._cancel(targets, values, calldatas, descriptionHash); } - function getProposalVotes( - uint256 proposalId - ) - external + function _executor() + internal view - returns (uint256 forVotes, uint256 againstVotes, uint256 totalVotes) + override(Governor, GovernorTimelockControl) + returns (address) { - Proposal storage p = proposals[proposalId]; - forVotes = p.forVotes; - againstVotes = p.againstVotes; - totalVotes = forVotes + againstVotes; - } - - function quorumRequired() public view returns (uint256) { - uint256 totalSupply = token.totalSupply(); - return (totalSupply * quorumPercentage) / 10000; - } - - receive() external payable { - // Accept ETH deposits for execution costs + return super._executor(); } } diff --git a/src/SmartnodesERC20.sol b/src/SmartnodesERC20.sol index 9631500..2bc97d5 100644 --- a/src/SmartnodesERC20.sol +++ b/src/SmartnodesERC20.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.22; +pragma solidity ^0.8.24; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import {ISmartnodesCore} from "./interfaces/ISmartnodesCore.sol"; @@ -14,7 +14,6 @@ import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol * @title Payment and Governance Token for the Smartnodes Network * @dev Non-upgradeable ERC20 token for job payments, node rewards, and node collateral. * @dev Rewards are distributed perioidcally by the SmartnodesCore and SmartnodesCoordinator. - * @dev Rewards can be claimed bu * @dev Reward distribution undergoes yearly 40% reductions until a tail emission is reached. * @dev Uses simple DAO-based access control system to control staking requirements and upgrades * @dev to SmartnodesCore and SmartnodesCoordinator. @@ -32,20 +31,22 @@ contract SmartnodesERC20 is ERC20, ERC20Permit, ERC20Votes, ReentrancyGuard { error Token__CoreAlreadySet(); error Token__InvalidMerkleRoot(); error Token__InvalidMerkleProof(); - error Token__DistributionNotActive(); error Token__RewardsAlreadyClaimed(); - error Token__InvalidValidatorLength(); + error Token__InvalidArrayLength(); error Token__ETHTransferFailed(); error Token__OnlyDAO(); error Token__DAOAlreadySet(); error Token__DistributionTooEarly(); error Token__InvalidInterval(); + error Token__DistributionTooOld(); + error Token__NotValidator(); // Token lock struct for both validators and users struct LockedTokens { bool locked; bool isValidator; uint128 unlockTime; + uint256 lockedAmount; } // Job payments and rewards can be SNO or ETH @@ -65,21 +66,23 @@ contract SmartnodesERC20 is ERC20, ERC20Permit, ERC20Votes, ReentrancyGuard { bytes32 merkleRoot; PaymentAmounts workerReward; uint256 totalCapacity; - bool active; uint256 timestamp; + uint256 nonce; } /** Constants */ uint8 private constant VALIDATOR_REWARD_PERCENTAGE = 10; - uint8 private constant DAO_REWARD_PERCENTAGE = 3; - uint256 private constant BASE_EMISSION_RATE = 5832e18; // Base hourly emission rate - uint256 private constant TAIL_EMISSION = 420e18; // Base hourly tail emission + uint8 private constant DAO_REWARD_PERCENTAGE = 5; + uint256 private constant BASE_EMISSION_RATE = 5625e18; // Base 1 hour emission rate + uint256 private constant TAIL_EMISSION = 420e18; // Base 1 hour tail emission uint256 private constant REWARD_PERIOD = 365 days; - uint256 private constant UNLOCK_PERIOD = 14 days; - uint256 private constant BASE_INTERVAL = 1 hours; // Base interval for emission calculation - - uint256 private immutable i_deploymentTimestamp; - uint256 private immutable i_emissionMultiplier; + uint256 private constant UNLOCK_PERIOD = 30 days; + uint256 private constant INITIAL_INTERVAL = 8; // Start with 128 hour interval for emission calculation + uint256 private constant MAX_INTERVAL = 256; // Minimum and maximum distribution intervals (# hours) + uint256 private constant MIN_INTERVAL = 1; + uint256 private constant MAX_BATCH_SIZE = 100; // Batch reward claim limit for a single call + uint256 private constant MAX_DISTRIBUTIONS = 500; + uint256 public immutable i_deploymentTimestamp; /** State Variables */ ISmartnodesCore public s_smartnodesCore; @@ -105,8 +108,8 @@ contract SmartnodesERC20 is ERC20, ERC20Permit, ERC20Votes, ReentrancyGuard { mapping(uint256 => MerkleDistribution) public s_distributions; mapping(uint256 => mapping(address => bool)) public s_claimed; - mapping(address => LockedTokens) private s_lockedTokens; - mapping(address => PaymentAmounts) private s_escrowedPayments; + mapping(address => LockedTokens) public s_lockedTokens; + mapping(address => PaymentAmounts) public s_escrowedPayments; /** Modifiers */ modifier onlySmartnodesCore() { @@ -142,7 +145,16 @@ contract SmartnodesERC20 is ERC20, ERC20Permit, ERC20Votes, ReentrancyGuard { ); event UnlockInitiated(address indexed user, uint256 unlockTime); event EthRewardsClaimed(address indexed user, uint256 amount); - event TokenRewardsClaimed(address indexed user, uint256 amount); + event TokenRewardsClaimed( + uint256 indexed distributionId, + address indexed user, + uint256 amount + ); + event ClaimFailed( + uint256 indexed distributionId, + address indexed user, + string reason + ); event SmartnodesSet( address indexed coreContract, address indexed coordinatorContract @@ -171,25 +183,30 @@ contract SmartnodesERC20 is ERC20, ERC20Permit, ERC20Votes, ReentrancyGuard { event ETHDeposited(address indexed from, uint256 amount); event ETHWithdrawn(address indexed to, uint256 amount); event DistributionIntervalUpdated(uint256 oldInterval, uint256 newInterval); + event DistributionExpired(uint256 distributionId); + event ValidatorSlashed( + address indexed validator, + uint256 slashedAmount, + uint256 remainingAmount + ); + event SlashedTokensBurned(uint256 amount); constructor( - uint256 emissionMultiplier, address[] memory _genesisNodes - ) ERC20("SmartnodesToken", "SNO") ERC20Permit("SmartnodesToken") { + ) ERC20("Smartnodes", "SNO") ERC20Permit("Smartnodes") { i_deploymentTimestamp = block.timestamp; - i_emissionMultiplier = emissionMultiplier; s_coreSet = false; s_daoSet = false; // Set initial distribution interval based on emission multiplier - s_distributionInterval = BASE_INTERVAL * emissionMultiplier; + s_distributionInterval = INITIAL_INTERVAL; s_validatorLockAmount = 1_000_000e18; s_userLockAmount = 100e18; // Mint initial tokens to genesis nodes uint256 gensisNodesLength = _genesisNodes.length; for (uint256 i = 0; i < gensisNodesLength; i++) { - _mint(_genesisNodes[i], (s_validatorLockAmount * 4) / 2); + _mint(_genesisNodes[i], (s_validatorLockAmount * 3)); } } @@ -217,68 +234,6 @@ contract SmartnodesERC20 is ERC20, ERC20Permit, ERC20Votes, ReentrancyGuard { emit DAOSet(_dao); } - // ============ DAO-Controlled Functions ============ - /** - * @dev Sets the Smartnodes contract addresses. Can only be called by DAO. - * @param _smartnodesCore Address of the deployed SmartnodesCore contract - * @param _smartnodesCoordinator Address of the deployed SmartnodesCoordinator contract - */ - function setSmartnodes( - address _smartnodesCore, - address _smartnodesCoordinator - ) external onlyDAO { - if (s_coreSet) { - revert Token__CoreAlreadySet(); - } - if (_smartnodesCore == address(0)) { - revert Token__InvalidAddress(); - } - - s_smartnodesCore = ISmartnodesCore(_smartnodesCore); - s_smartnodesCoordinator = ISmartnodesCoordinator( - _smartnodesCoordinator - ); - s_coreSet = true; - - emit SmartnodesSet(_smartnodesCore, _smartnodesCoordinator); - } - - /** - * @dev Sets validator lock amount. Can only be called by DAO. - * @param _newAmount New validator lock amount - */ - function setValidatorLockAmount(uint256 _newAmount) external onlyDAO { - uint256 oldAmount = s_validatorLockAmount; - s_validatorLockAmount = _newAmount; - emit ValidatorLockAmountUpdated(oldAmount, _newAmount); - } - - /** - * @dev Sets user lock amount. Can only be called by DAO. - * @param _newAmount New user lock amount - */ - function setUserLockAmount(uint256 _newAmount) external onlyDAO { - uint256 oldAmount = s_userLockAmount; - s_userLockAmount = _newAmount; - emit UserLockAmountUpdated(oldAmount, _newAmount); - } - - /** - * @notice Convenience function to halve the distribution interval - */ - function halveDistributionInterval() external onlyDAO nonReentrant { - s_distributionInterval /= 2; - s_smartnodesCoordinator.updateTiming(s_distributionInterval); - } - - /** - * @notice Convenience function to double the distribution interval - */ - function doubleDistributionInterval() external onlyDAO nonReentrant { - s_distributionInterval *= 2; - s_smartnodesCoordinator.updateTiming(s_distributionInterval); - } - // ============ Emissions & Halving ============ /** @@ -350,14 +305,12 @@ contract SmartnodesERC20 is ERC20, ERC20Permit, ERC20Votes, ReentrancyGuard { if (snoAmount > 0) { _mint(validator, snoAmount); - emit TokenRewardsClaimed(validator, uint128(snoAmount)); } if (ethAmount > 0) { s_totalETHWithdrawn += ethAmount; (bool sent, ) = validator.call{value: ethAmount}(""); if (!sent) revert Token__ETHTransferFailed(); - emit EthRewardsClaimed(validator, uint128(ethAmount)); } } @@ -380,7 +333,17 @@ contract SmartnodesERC20 is ERC20, ERC20Permit, ERC20Votes, ReentrancyGuard { address _dustValidator ) external onlySmartnodesCore nonReentrant { uint8 _nValidators = uint8(_approvedValidators.length); - if (_nValidators == 0) revert Token__InvalidValidatorLength(); + if (_nValidators == 0) revert Token__InvalidArrayLength(); + + if (s_lastDistributionTime != 0) { + if ( + block.timestamp < + s_lastDistributionTime + s_distributionInterval + ) { + revert Token__DistributionTooEarly(); + } + } + s_lastDistributionTime = block.timestamp; // Total rewards to be distributed PaymentAmounts memory totalReward = PaymentAmounts({ @@ -404,27 +367,18 @@ contract SmartnodesERC20 is ERC20, ERC20Permit, ERC20Votes, ReentrancyGuard { (uint256(totalReward.eth) * DAO_REWARD_PERCENTAGE) / 100 ) }); - PaymentAmounts memory validatorReward; - if (_totalCapacity == 0) { - // If no workers, just give to validators - validatorReward = PaymentAmounts({ - sno: totalReward.sno - daoReward.sno, - eth: totalReward.eth - daoReward.eth - }); - } else { - // Split validator/worker share from the remaining pool - validatorReward = PaymentAmounts({ - sno: uint128( - (uint256(totalReward.sno) * VALIDATOR_REWARD_PERCENTAGE) / - 100 - ), - eth: uint128( - (uint256(totalReward.eth) * VALIDATOR_REWARD_PERCENTAGE) / - 100 - ) - }); + // Split validator/worker share from the remaining pool + PaymentAmounts memory validatorReward = PaymentAmounts({ + sno: uint128( + (uint256(totalReward.sno) * VALIDATOR_REWARD_PERCENTAGE) / 100 + ), + eth: uint128( + (uint256(totalReward.eth) * VALIDATOR_REWARD_PERCENTAGE) / 100 + ) + }); + if (_totalCapacity != 0) { PaymentAmounts memory workerReward = PaymentAmounts({ sno: totalReward.sno - validatorReward.sno - daoReward.sno, eth: totalReward.eth - validatorReward.eth - daoReward.eth @@ -435,8 +389,8 @@ contract SmartnodesERC20 is ERC20, ERC20Permit, ERC20Votes, ReentrancyGuard { merkleRoot: _merkleRoot, workerReward: workerReward, totalCapacity: _totalCapacity, - active: true, - timestamp: block.timestamp + timestamp: block.timestamp, + nonce: distributionId }); // Update total unclaimed (only worker rewards) @@ -451,6 +405,12 @@ contract SmartnodesERC20 is ERC20, ERC20Permit, ERC20Votes, ReentrancyGuard { block.timestamp ); } + + // Clean up oldest distribution if we exceed MAX_DISTRIBUTIONS + if (distributionId > MAX_DISTRIBUTIONS) { + _cleanupOldestDistribution(); + } + // Distribute DAO rewards _payAccount(s_dao, daoReward.sno, daoReward.eth); emit DAORewardsDistributed( @@ -469,75 +429,17 @@ contract SmartnodesERC20 is ERC20, ERC20Permit, ERC20Votes, ReentrancyGuard { } /** - * @notice Internal helper function to process a single reward claim - * @param _user Address of the user claiming rewards - * @param _distributionId The ID of the distribution to claim from - * @param _capacity Worker capacity associated with rewards claim - * @param _merkleProof Merkle proof validating the claim + * @notice Internal function to clean up the oldest distribution when we exceed MAX_DISTRIBUTIONS + * @dev This function is called automatically when creating a new distribution */ - function _processClaim( - address _user, - uint256 _distributionId, - uint256 _capacity, - bytes32[] calldata _merkleProof - ) internal { - MerkleDistribution memory distribution = s_distributions[ - _distributionId - ]; - - if (!distribution.active) { - revert Token__DistributionNotActive(); - } - - if (s_claimed[_distributionId][_user]) { - revert Token__RewardsAlreadyClaimed(); - } - - // Verify Merkle proof - bytes32 leaf = keccak256(abi.encode(_user, _capacity)); - if (!MerkleProof.verify(_merkleProof, distribution.merkleRoot, leaf)) { - revert Token__InvalidMerkleProof(); - } - - // Mark as claimed - s_claimed[_distributionId][_user] = true; - - uint128 eth; - uint128 sno; - - // Calculate worker rewards - uint256 totalWorkerCapacity = distribution.totalCapacity; - - eth = uint128( - (distribution.workerReward.eth * _capacity) / totalWorkerCapacity - ); - sno = uint128( - (distribution.workerReward.sno * _capacity) / totalWorkerCapacity - ); - - // Check ETH balance before transfer - if (eth > 0 && address(this).balance < eth) { - revert Token__InsufficientBalance(); - } + function _cleanupOldestDistribution() internal { + uint256 currentId = s_currentDistributionId; - // Update total unclaimed - s_totalUnclaimed.eth -= eth; - s_totalUnclaimed.sno -= sno; + if (currentId > MAX_DISTRIBUTIONS) { + uint256 oldestId = currentId - MAX_DISTRIBUTIONS; - // Mint SNO tokens - if (sno > 0) { - _mint(_user, sno); - emit TokenRewardsClaimed(_user, sno); - } - - // Transfer ETH - if (eth > 0) { - s_totalETHWithdrawn += eth; - (bool sent, ) = _user.call{value: eth}(""); - if (!sent) { - revert Token__ETHTransferFailed(); - } - emit EthRewardsClaimed(_user, eth); + delete s_distributions[oldestId]; + emit DistributionExpired(oldestId); } } @@ -553,7 +455,17 @@ contract SmartnodesERC20 is ERC20, ERC20Permit, ERC20Votes, ReentrancyGuard { uint256 _capacity, bytes32[] calldata _merkleProof ) external nonReentrant { - _processClaim(msg.sender, _distributionId, _capacity, _merkleProof); + // Create single-element arrays and delegate to batch function + uint256[] memory distributionIds = new uint256[](1); + uint256[] memory capacities = new uint256[](1); + bytes32[][] memory merkleProofs = new bytes32[][](1); + + distributionIds[0] = _distributionId; + capacities[0] = _capacity; + merkleProofs[0] = _merkleProof; + + // Remove nonReentrant from this function since batch function has it + _batchClaimMerkleRewards(distributionIds, capacities, merkleProofs); } /** @@ -568,19 +480,126 @@ contract SmartnodesERC20 is ERC20, ERC20Permit, ERC20Votes, ReentrancyGuard { uint256[] calldata _capacities, bytes32[][] calldata _merkleProofs ) external nonReentrant { + // Convert calldata to memory and delegate to internal function uint256 length = _distributionIds.length; + uint256[] memory distributionIds = new uint256[](length); + uint256[] memory capacities = new uint256[](length); + bytes32[][] memory merkleProofs = new bytes32[][](length); + + for (uint256 i; i < length; ) { + distributionIds[i] = _distributionIds[i]; + capacities[i] = _capacities[i]; + merkleProofs[i] = _merkleProofs[i]; + unchecked { + ++i; + } + } - if (length != _capacities.length || length != _merkleProofs.length) { - revert Token__InvalidAddress(); // Reusing existing error for array length mismatch + _batchClaimMerkleRewards(distributionIds, capacities, merkleProofs); + } + + function _batchClaimMerkleRewards( + uint256[] memory _distributionIds, + uint256[] memory _capacities, + bytes32[][] memory _merkleProofs + ) internal { + uint256 length = _distributionIds.length; + if ( + length != _capacities.length || + length != _merkleProofs.length || + length > MAX_BATCH_SIZE + ) { + revert Token__InvalidArrayLength(); } - for (uint256 i = 0; i < length; i++) { - _processClaim( - msg.sender, - _distributionIds[i], - _capacities[i], - _merkleProofs[i] + uint256 currentDistribution = s_currentDistributionId; + + // Accumulate all rewards before any storage writes + uint256 totalSnoReward; + uint256 totalEthReward; + + // Pre-validate all claims and calculate totals + for (uint256 i; i < length; ) { + uint256 distributionId = _distributionIds[i]; + MerkleDistribution storage distribution = s_distributions[ + distributionId + ]; + + // Pack validation checks + if ( + currentDistribution > MAX_DISTRIBUTIONS || + s_claimed[distributionId][msg.sender] + ) { + // Use specific reverts based on which condition failed + if (s_claimed[distributionId][msg.sender]) + revert Token__RewardsAlreadyClaimed(); + if (distributionId < currentDistribution - MAX_DISTRIBUTIONS) + revert Token__DistributionTooOld(); + } + + // Verify merkle proof + bytes32 leaf = keccak256( + abi.encode(msg.sender, _capacities[i], distribution.nonce) ); + if ( + !MerkleProof.verify( + _merkleProofs[i], + distribution.merkleRoot, + leaf + ) + ) { + revert Token__InvalidMerkleProof(); + } + + // Calculate rewards and accumulate + uint256 capacity = _capacities[i]; + uint256 totalCapacity = distribution.totalCapacity; + + // Use unchecked math where safe to save gas + unchecked { + totalSnoReward += + (distribution.workerReward.sno * capacity) / + totalCapacity; + totalEthReward += + (distribution.workerReward.eth * capacity) / + totalCapacity; + ++i; + } + } + + // Batch update all claimed statuses in single loop + for (uint256 i; i < length; ) { + s_claimed[_distributionIds[i]][msg.sender] = true; + unchecked { + ++i; + } + } + + // Single storage update for total unclaimed + if (totalSnoReward > 0) { + s_totalUnclaimed.sno -= uint128(totalSnoReward); + _mint(msg.sender, totalSnoReward); + + // Emit single event for all SNO rewards + emit TokenRewardsClaimed(0, msg.sender, totalSnoReward); // Use 0 for batch claims + } + + if (totalEthReward > 0) { + if (address(this).balance < totalEthReward) + revert Token__InsufficientBalance(); + + s_totalUnclaimed.eth -= uint128(totalEthReward); + s_totalETHWithdrawn += totalEthReward; + + // Assembly for gas-efficient ETH transfer + assembly { + let success := call(gas(), caller(), totalEthReward, 0, 0, 0, 0) + if iszero(success) { + mstore(0x00, 0x90b8ec18) // Token__ETHTransferFailed() + revert(0x1c, 0x04) + } + } + emit EthRewardsClaimed(msg.sender, totalEthReward); } } @@ -603,7 +622,7 @@ contract SmartnodesERC20 is ERC20, ERC20Permit, ERC20Votes, ReentrancyGuard { // initial * (0.6)^era uint256 emission = BASE_EMISSION_RATE; for (uint256 i = 0; i < era; i++) { - emission = (emission * 6000) / 10000; + emission = (emission * 300) / 500; if (emission <= TAIL_EMISSION) return TAIL_EMISSION; } @@ -617,7 +636,7 @@ contract SmartnodesERC20 is ERC20, ERC20Permit, ERC20Votes, ReentrancyGuard { function getEmissionRate() public view returns (uint256) { uint256 era = _currentEra(); uint256 baseEmission = _emissionForEra(era); - return (baseEmission * s_distributionInterval) / BASE_INTERVAL; + return (baseEmission * s_distributionInterval); } /** @@ -628,6 +647,120 @@ contract SmartnodesERC20 is ERC20, ERC20Permit, ERC20Votes, ReentrancyGuard { return _currentEra(); } + // ============ DAO-Controlled Functions ============ + + /** + * @dev Sets the Smartnodes contract addresses. Can only be called by DAO. + * @param _smartnodesCore Address of the deployed SmartnodesCore contract + * @param _smartnodesCoordinator Address of the deployed SmartnodesCoordinator contract + */ + function setSmartnodes( + address _smartnodesCore, + address _smartnodesCoordinator + ) external onlyDAO { + if (s_coreSet) { + revert Token__CoreAlreadySet(); + } + if (_smartnodesCore == address(0)) { + revert Token__InvalidAddress(); + } + + s_smartnodesCore = ISmartnodesCore(_smartnodesCore); + s_smartnodesCoordinator = ISmartnodesCoordinator( + _smartnodesCoordinator + ); + s_coreSet = true; + + emit SmartnodesSet(_smartnodesCore, _smartnodesCoordinator); + } + + /** + * @dev Sets validator lock amount. Can only be called by DAO. + * @param _newAmount New validator lock amount + */ + function setValidatorLockAmount(uint256 _newAmount) external onlyDAO { + uint256 oldAmount = s_validatorLockAmount; + s_validatorLockAmount = _newAmount; + emit ValidatorLockAmountUpdated(oldAmount, _newAmount); + } + + /** + * @dev Sets user lock amount. Can only be called by DAO. + * @param _newAmount New user lock amount + */ + function setUserLockAmount(uint256 _newAmount) external onlyDAO { + uint256 oldAmount = s_userLockAmount; + s_userLockAmount = _newAmount; + emit UserLockAmountUpdated(oldAmount, _newAmount); + } + + /** + * @notice Convenience function to halve the distribution interval + */ + function halveDistributionInterval() external onlyDAO nonReentrant { + uint256 oldInterval = s_distributionInterval; + if (oldInterval <= MIN_INTERVAL) { + revert Token__InvalidInterval(); + } + s_distributionInterval = oldInterval / 2; + + s_smartnodesCoordinator.updateTiming(s_distributionInterval); + emit DistributionIntervalUpdated(oldInterval, s_distributionInterval); + } + + /** + * @notice Convenience function to double the distribution interval + */ + function doubleDistributionInterval() external onlyDAO nonReentrant { + uint256 oldInterval = s_distributionInterval; + if (oldInterval >= MAX_INTERVAL) { + revert Token__InvalidInterval(); + } + s_distributionInterval = oldInterval * 2; + s_smartnodesCoordinator.updateTiming(s_distributionInterval); + emit DistributionIntervalUpdated(oldInterval, s_distributionInterval); + } + + /** + * @notice Slash a validator's locked tokens and immediately unlock them + * @param _validator The address of the validator to slash + * @param _slashAmount The amount of tokens to slash (burn) + * @dev Can only be called by DAO. Slashed tokens are burned and remaining tokens are returned to validator. + * @dev This function combines slashing with timed unlock to prevent further misbehavior. + */ + function slashAndUnlockValidator( + address _validator, + uint256 _slashAmount + ) external onlyDAO nonReentrant { + if (_validator == address(0)) { + revert Token__InvalidAddress(); + } + + LockedTokens storage locked = s_lockedTokens[_validator]; + + if (!locked.locked || !locked.isValidator) { + revert Token__NotValidator(); + } + + uint256 lockedAmount = locked.lockedAmount; + uint256 remainingAmount = lockedAmount - _slashAmount; + + // Update total locked tracking + s_totalLocked -= _slashAmount; + locked.lockedAmount = remainingAmount; + + // Initiate the unlock process + _initiateUnlock(_validator, locked); + + // Burn the slashed tokens + if (_slashAmount > 0) { + _burn(address(this), _slashAmount); + emit SlashedTokensBurned(_slashAmount); + } + + emit ValidatorSlashed(_validator, _slashAmount, remainingAmount); + } + // ============ Token Locking for Validators ============ /** @@ -642,13 +775,9 @@ contract SmartnodesERC20 is ERC20, ERC20Permit, ERC20Votes, ReentrancyGuard { revert Token__InvalidAddress(); } - uint256 lockAmount; - - if (_isValidator) { - lockAmount = s_validatorLockAmount; - } else { - lockAmount = s_userLockAmount; - } + uint256 lockAmount = _isValidator + ? s_validatorLockAmount + : s_userLockAmount; if (balanceOf(_user) < lockAmount) { revert Token__InsufficientBalance(); @@ -659,6 +788,7 @@ contract SmartnodesERC20 is ERC20, ERC20Permit, ERC20Votes, ReentrancyGuard { locked.locked = true; locked.isValidator = _isValidator; + locked.lockedAmount = lockAmount; // Update total locked tracking s_totalLocked += lockAmount; @@ -692,12 +822,7 @@ contract SmartnodesERC20 is ERC20, ERC20Permit, ERC20Votes, ReentrancyGuard { } // Finalize the unlock process - uint256 lockAmount; - if (locked.isValidator) { - lockAmount = s_validatorLockAmount; - } else { - lockAmount = s_userLockAmount; - } + uint256 lockAmount = locked.lockedAmount; // Update total locked tracking s_totalLocked -= lockAmount; @@ -707,26 +832,17 @@ contract SmartnodesERC20 is ERC20, ERC20Permit, ERC20Votes, ReentrancyGuard { emit TokensUnlocked(_user, locked.isValidator, lockAmount); } else { // If locked is true, initiate the unlock process - locked.locked = false; - locked.unlockTime = uint128(block.timestamp); - emit UnlockInitiated(_user, locked.unlockTime); + _initiateUnlock(_user, locked); } } - /** - * @dev Check if user has locked tokens - */ - function isLocked(address user) external view returns (bool) { - return s_lockedTokens[user].locked; - } - - /** - * @dev Get user's lock information - */ - function getLockInfo( - address user - ) external view returns (LockedTokens memory) { - return s_lockedTokens[user]; + function _initiateUnlock( + address _user, + LockedTokens storage locked + ) internal { + locked.locked = false; + locked.unlockTime = uint128(block.timestamp); + emit UnlockInitiated(_user, locked.unlockTime); } /** @@ -829,6 +945,38 @@ contract SmartnodesERC20 is ERC20, ERC20Permit, ERC20Votes, ReentrancyGuard { // ============ View Functions ============ + /** + * @dev Check if user has locked tokens + */ + function isLocked(address user) external view returns (bool) { + return s_lockedTokens[user].locked; + } + + /** + * @dev Get user's lock information + */ + function getLockInfo( + address user + ) + external + view + returns ( + bool locked, + bool isValidator, + uint256 timestamp, + uint256 lockAmount + ) + { + LockedTokens storage lock = s_lockedTokens[user]; + + return ( + lock.locked, + lock.isValidator, + lock.unlockTime, + lock.lockedAmount + ); + } + /** * @dev Get escrowed payments for a specific address */ @@ -892,31 +1040,6 @@ contract SmartnodesERC20 is ERC20, ERC20Permit, ERC20Votes, ReentrancyGuard { } // ============ Required Overrides ============ - - /** - * @dev Override to prevent voting with locked tokens - */ - function _getVotingUnits( - address account - ) internal view virtual override returns (uint256) { - uint256 balance = balanceOf(account); - - // Subtract locked tokens from voting power - if (s_lockedTokens[account].locked) { - uint256 lockAmount = s_lockedTokens[account].isValidator - ? s_validatorLockAmount - : s_userLockAmount; - - if (balance >= lockAmount) { - balance -= lockAmount; - } else { - balance = 0; - } - } - - return balance; - } - /** * @dev Override required by Solidity for multiple inheritance */ diff --git a/test/BaseTest.sol b/test/BaseTest.sol index 19a1a2f..fc21fa1 100644 --- a/test/BaseTest.sol +++ b/test/BaseTest.sol @@ -1,28 +1,28 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.22; +pragma solidity ^0.8.24; import {Test, console} from "forge-std/Test.sol"; import {SmartnodesERC20} from "../src/SmartnodesERC20.sol"; import {SmartnodesCore} from "../src/SmartnodesCore.sol"; import {SmartnodesCoordinator} from "../src/SmartnodesCoordinator.sol"; import {SmartnodesDAO} from "../src/SmartnodesDAO.sol"; +import {TimelockController} from "@openzeppelin/contracts/governance/TimelockController.sol"; /** * @title BaseSmartnodesTest * @notice Base test contract with common setup for all Smartnodes tests */ abstract contract BaseSmartnodesTest is Test { - uint256 constant DEPLOYMENT_MULTIPLIER = 1; - uint128 constant INTERVAL_SECONDS = 1 minutes; + uint128 constant UPDATE_TIME = 8 hours; uint256 constant VALIDATOR_REWARD_PERCENTAGE = 10; - uint256 constant DAO_REWARD_PERCENTAGE = 3; + uint256 constant DAO_REWARD_PERCENTAGE = 5; uint256 constant ADDITIONAL_SNO_PAYMENT = 1000e18; uint256 constant ADDITIONAL_ETH_PAYMENT = 5 ether; - uint256 constant INITIAL_EMISSION_RATE = 5832e18; - uint256 constant TAIL_EMISSION = 420e18; + uint256 constant INITIAL_EMISSION_RATE = 45_000e18; + uint256 constant TAIL_EMISSION = 3_360e18; uint256 constant VALIDATOR_LOCK_AMOUNT = 1_000_000e18; uint256 constant USER_LOCK_AMOUNT = 100e18; - uint256 constant UNLOCK_PERIOD = 14 days; + uint256 constant UNLOCK_PERIOD = 30 days; uint256 constant REWARD_PERIOD = 365 days; uint256 constant DAO_VOTING_PERIOD = 7 days; @@ -38,6 +38,7 @@ abstract contract BaseSmartnodesTest is Test { SmartnodesCore public core; SmartnodesCoordinator public coordinator; SmartnodesDAO public dao; + TimelockController public timelock; // Test addresses address public deployerAddr = makeAddr("deployer"); @@ -79,14 +80,22 @@ abstract contract BaseSmartnodesTest is Test { // genesisNodes.push(worker1); // genesisNodes.push(worker2); // genesisNodes.push(worker3); - - token = new SmartnodesERC20(DEPLOYMENT_MULTIPLIER, genesisNodes); - dao = new SmartnodesDAO(address(token), DAO_VOTING_PERIOD, 500); + address[] memory proposers = new address[](0); + address[] memory executors = new address[](0); + + token = new SmartnodesERC20(genesisNodes); + timelock = new TimelockController( + 2 days, + proposers, + executors, + deployerAddr + ); + dao = new SmartnodesDAO(token, timelock); core = new SmartnodesCore(address(token)); // Deploy coordinator coordinator = new SmartnodesCoordinator( - INTERVAL_SECONDS, + UPDATE_TIME, 66, address(core), address(token), @@ -96,7 +105,7 @@ abstract contract BaseSmartnodesTest is Test { // Set DAO in token (can only be done once) token.setSmartnodes(address(core), address(coordinator)); - token.setDAO(address(dao)); + token.setDAO(address(timelock)); core.setCoordinator(address(coordinator)); vm.stopPrank(); @@ -119,37 +128,6 @@ abstract contract BaseSmartnodesTest is Test { coordinator.addValidator(); } - // Helper function for tests that need to create DAO proposals - function createDAOProposal( - address[] memory targets, - bytes[] memory calldatas, - uint256[] memory values, - string memory description - ) internal returns (uint256 proposalId) { - proposalId = dao.propose(targets, calldatas, values, description); - } - - // Helper function to vote on DAO proposals in tests - function voteOnProposal( - uint256 proposalId, - address voter, - uint256 votes, - bool support - ) internal { - vm.startPrank(voter); - token.approve(address(dao), votes); - dao.vote(proposalId, support, votes); - vm.stopPrank(); - } - - // Helper function to execute DAO proposals in tests - function executeProposal(uint256 proposalId) internal { - vm.warp(block.timestamp + DAO_VOTING_PERIOD + 1); - dao.queue(proposalId); - vm.warp(block.timestamp + dao.TIMELOCK_DELAY()); - dao.execute(proposalId); - } - // ============= Helper Functions ============= function createTestUser(address user, bytes32 pubkey) internal { vm.prank(user); @@ -186,7 +164,10 @@ abstract contract BaseSmartnodesTest is Test { } } - function createBasicProposal(address validator) internal returns (uint8) { + function createBasicProposal( + address validator, + uint256 distributionId + ) internal returns (uint8) { // Helper to create a basic proposal for testing bytes32[] memory jobHashes = new bytes32[](1); jobHashes[0] = JOB_ID_1; @@ -202,7 +183,7 @@ abstract contract BaseSmartnodesTest is Test { Participant[] memory participants, uint256 totalCapacity ) = _setupTestParticipants(jobWorkers.length, false); - bytes32[] memory leaves = _generateLeaves(participants); + bytes32[] memory leaves = _generateLeaves(participants, distributionId); bytes32 merkleRoot = _buildMerkleTree(leaves); bytes32 proposalHash = keccak256( @@ -227,13 +208,18 @@ abstract contract BaseSmartnodesTest is Test { * @notice Generate Merkle tree leaves from participants */ function _generateLeaves( - Participant[] memory participants + Participant[] memory participants, + uint256 distributionId ) internal pure returns (bytes32[] memory) { bytes32[] memory leaves = new bytes32[](participants.length); for (uint256 i = 0; i < participants.length; i++) { leaves[i] = keccak256( - abi.encode(participants[i].addr, participants[i].capacity) + abi.encode( + participants[i].addr, + participants[i].capacity, + distributionId + ) ); } diff --git a/test/CoordinatorTest.t.sol b/test/CoordinatorTest.t.sol index 6ff3b10..040e786 100644 --- a/test/CoordinatorTest.t.sol +++ b/test/CoordinatorTest.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.22; +pragma solidity ^0.8.24; import {Test, console} from "forge-std/Test.sol"; import {SmartnodesCoordinator} from "../src/SmartnodesCoordinator.sol"; @@ -31,7 +31,8 @@ contract SmartnodesCoordinatorTest is BaseSmartnodesTest { Participant[] memory participants, uint256 totalCapacity ) = _setupTestParticipants(numWorkers, false); - bytes32[] memory leaves = _generateLeaves(participants); + uint256 distributionId = token.s_currentDistributionId(); + bytes32[] memory leaves = _generateLeaves(participants, distributionId); bytes32 merkleRoot = _buildMerkleTree(leaves); bytes32[] memory jobHashes = new bytes32[](1); @@ -92,18 +93,12 @@ contract SmartnodesCoordinatorTest is BaseSmartnodesTest { bytes32 storedRoot, SmartnodesERC20.PaymentAmounts memory workerReward, uint256 storedCapacity, - bool active, - uint256 timestamp + uint256 timestamp, + uint256 _distributionId ) = token.s_distributions(newDistributionId); assertEq(storedRoot, merkleRoot, "Stored merkle root should match"); assertEq(storedCapacity, totalCapacity, "Stored capacity should match"); - if (numWorkers == 0) { - assertFalse(active, "Empty distribution should not be active"); - } else { - assertTrue(active, "Distribution should be active"); - } - console.log("Merkle distribution created successfully"); console.log("Distribution ID:", newDistributionId); console.log("Worker reward SNO:", workerReward.sno / 1e18); @@ -127,7 +122,8 @@ contract SmartnodesCoordinatorTest is BaseSmartnodesTest { Participant[] memory participants, uint256 totalCapacity ) = _setupTestParticipants(100, false); - bytes32[] memory leaves = _generateLeaves(participants); + uint256 distributionId = token.s_currentDistributionId(); + bytes32[] memory leaves = _generateLeaves(participants, distributionId); bytes32 merkleRoot = _buildMerkleTree(leaves); bytes32[] memory jobHashes = new bytes32[](1); @@ -194,8 +190,8 @@ contract SmartnodesCoordinatorTest is BaseSmartnodesTest { bytes32 storedRoot, SmartnodesERC20.PaymentAmounts memory workerReward, uint256 storedCapacity, - bool active, - uint256 timestamp + uint256 timestamp, + uint256 _distributionId ) = token.s_distributions(newDistributionId); assertEq(storedRoot, merkleRoot, "Stored merkle root should match"); @@ -216,7 +212,8 @@ contract SmartnodesCoordinatorTest is BaseSmartnodesTest { uint256 totalCapacity ) = _setupTestParticipants(numWorkers, false); - bytes32[] memory leaves = _generateLeaves(participants); + uint256 distributionId = token.s_currentDistributionId(); + bytes32[] memory leaves = _generateLeaves(participants, distributionId); bytes32 merkleRoot = _buildMerkleTree(leaves); bytes32[] memory jobHashes = new bytes32[](1); @@ -265,7 +262,10 @@ contract SmartnodesCoordinatorTest is BaseSmartnodesTest { Participant[] memory participants, uint256 totalCapacity ) = _setupTestParticipants(numWorkers, false); - bytes32 merkleRoot = _buildMerkleTree(_generateLeaves(participants)); + uint256 distributionId = token.s_currentDistributionId(); + bytes32 merkleRoot = _buildMerkleTree( + _generateLeaves(participants, distributionId) + ); bytes32[] memory jobHashes = new bytes32[](1); jobHashes[0] = JOB_ID_1; @@ -301,4 +301,91 @@ contract SmartnodesCoordinatorTest is BaseSmartnodesTest { console.log("Voting successful. Total votes:", proposal.votes); } + + function testExecuteProposal200Rounds() public { + // Add more validators so proposals can consistently pass voting + vm.prank(validator2); + core.createValidator(VALIDATOR2_PUBKEY); + vm.prank(validator2); + coordinator.addValidator(); + vm.prank(validator3); + core.createValidator(VALIDATOR3_PUBKEY); + vm.prank(validator3); + coordinator.addValidator(); + + (uint128 updateTime, ) = coordinator.timeConfig(); + vm.warp(block.timestamp + updateTime); + + for (uint256 i = 0; i < 200; i++) { + vm.warp(block.timestamp + updateTime); + + ( + Participant[] memory participants, + uint256 totalCapacity + ) = _setupTestParticipants(100, false); + uint256 distributionId = token.s_currentDistributionId(); + bytes32[] memory leaves = _generateLeaves( + participants, + distributionId + ); + bytes32 merkleRoot = _buildMerkleTree(leaves); + + bytes32[] memory jobHashes = new bytes32[](1); + jobHashes[0] = JOB_ID_1; + + address[] memory workers = new address[](100); + uint256[] memory capacities = new uint256[](100); + for (uint256 i = 0; i < 100; i++) { + workers[i] = address(uint160(0x3000 + i)); + capacities[i] = 10e18 + (i * 5e18); + } + + bytes32 workersHash = keccak256(abi.encode(workers)); + bytes32 capacitiesHash = keccak256(abi.encode(capacities)); + + address[] memory validatorsToRemove = new address[](0); + + bytes32 proposalHash = keccak256( + abi.encode( + merkleRoot, + validatorsToRemove, + jobHashes, + workersHash, + capacitiesHash + ) + ); + + address proposer; + try coordinator.currentRoundValidators(0) returns (address v) { + proposer = v; + } catch { + proposer = validator1; + } + + vm.prank(proposer); + coordinator.createProposal(proposalHash); + + uint8 proposalId = coordinator.getNumProposals(); + + vm.prank(validator1); + coordinator.voteForProposal(proposalId); + + vm.prank(validator2); + coordinator.voteForProposal(proposalId); + + vm.prank(validator3); + coordinator.voteForProposal(proposalId); + + vm.prank(proposer); + coordinator.executeProposal( + proposalId, + merkleRoot, + totalCapacity, + validatorsToRemove, + jobHashes, + workersHash, + capacitiesHash + ); + } + } } diff --git a/test/CoreTest.t.sol b/test/CoreTest.t.sol index 4f7caa7..c8f327a 100644 --- a/test/CoreTest.t.sol +++ b/test/CoreTest.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.22; +pragma solidity ^0.8.24; import {BaseSmartnodesTest} from "./BaseTest.sol"; import {SmartnodesCore} from "../src/SmartnodesCore.sol"; diff --git a/test/DAOTest.sol b/test/DAOTest.sol deleted file mode 100644 index c9d0274..0000000 --- a/test/DAOTest.sol +++ /dev/null @@ -1,633 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.22; - -import {BaseSmartnodesTest} from "./BaseTest.sol"; -import {SmartnodesDAO} from "../src/SmartnodesDAO.sol"; -import {console} from "forge-std/Test.sol"; - -/** - * @title DAOTest - * @notice Test contract for DAO governance functionality - */ -contract DAOTest is BaseSmartnodesTest { - address public projectAddress1; - address public projectAddress2; - address public projectAddress3; - - function setUp() public override { - super.setUp(); - - projectAddress1 = makeAddr("project1"); - projectAddress2 = makeAddr("project2"); - projectAddress3 = makeAddr("project3"); - - // Debug: Check token balances after setup - console.log("Validator1 balance:", token.balanceOf(validator1) / 1e18); - console.log("Validator2 balance:", token.balanceOf(validator2) / 1e18); - console.log("User1 balance:", token.balanceOf(user1) / 1e18); - console.log("Total supply:", token.totalSupply() / 1e18); - console.log("Quorum required:", dao.quorumRequired() / 1e18); - } - - // ====== Functionality ====== - - /** - * @notice Test DAO proposal to set validator lock amount - */ - function testDAOSetValidatorLockAmount() public { - uint256 newLockAmount = 2_000_000e18; // 2M tokens - uint256 oldLockAmount = token.s_validatorLockAmount(); - - console.log("Old validator lock amount:", oldLockAmount / 1e18); - console.log("New validator lock amount:", newLockAmount / 1e18); - - // Create proposal - address[] memory targets = new address[](1); - targets[0] = address(token); - - bytes[] memory calldatas = new bytes[](1); - calldatas[0] = abi.encodeWithSignature( - "setValidatorLockAmount(uint256)", - newLockAmount - ); - - uint256[] memory values = new uint256[](1); - values[0] = 0; - - vm.prank(validator1); - uint256 proposalId = createDAOProposal( - targets, - calldatas, - values, - "Update validator lock amount to 2M SNO" - ); - - uint256 voteAmount = 100_000e18; - voteOnProposal(proposalId, validator1, voteAmount, true); - voteOnProposal(proposalId, validator2, voteAmount, true); - voteOnProposal(proposalId, validator3, voteAmount, true); - voteOnProposal(proposalId, user1, voteAmount, true); - voteOnProposal(proposalId, user2, voteAmount, true); - - // Check if we have enough votes for quorum - (uint256 forVotes, uint256 againstVotes, uint256 totalVotes) = dao - .getProposalVotes(proposalId); - uint256 quorumRequired = dao.quorumRequired(); - console.log("For votes:", forVotes); - console.log("Against votes:", againstVotes); - console.log("Total votes:", totalVotes); - console.log("Quorum required:", quorumRequired); - - // Execute proposal - executeProposal(proposalId); - - // Verify the change - assertEq( - token.s_validatorLockAmount(), - newLockAmount, - "Validator lock amount not updated" - ); - console.log("Successfully updated validator lock amount via DAO"); - } - - /** - * @notice Test DAO proposal to set user lock amount - */ - function testDAOSetUserLockAmount() public { - uint256 newLockAmount = 200e18; // 200 tokens - uint256 oldLockAmount = token.s_userLockAmount(); - - console.log("Old user lock amount:", oldLockAmount / 1e18); - console.log("New user lock amount:", newLockAmount / 1e18); - - // Create proposal - address[] memory targets = new address[](1); - targets[0] = address(token); - - bytes[] memory calldatas = new bytes[](1); - calldatas[0] = abi.encodeWithSignature( - "setUserLockAmount(uint256)", - newLockAmount - ); - - uint256[] memory values = new uint256[](1); - values[0] = 0; - - vm.prank(validator1); - uint256 proposalId = createDAOProposal( - targets, - calldatas, - values, - "Update user lock amount to 200 SNO" - ); - - uint256 voteAmount = 100_000e18; - voteOnProposal(proposalId, validator1, voteAmount, true); - voteOnProposal(proposalId, validator2, voteAmount, true); - voteOnProposal(proposalId, validator3, voteAmount, true); - voteOnProposal(proposalId, user1, voteAmount, true); - voteOnProposal(proposalId, user2, voteAmount, true); - - // Execute proposal - executeProposal(proposalId); - - // Verify the change - assertEq( - token.s_userLockAmount(), - newLockAmount, - "User lock amount not updated" - ); - console.log("Successfully updated user lock amount via DAO"); - } - - /** - * @notice Test DAO proposal to halve distribution interval - */ - function testDAOHalveDistributionInterval() public { - uint256 oldInterval = token.s_distributionInterval(); - uint256 expectedNewInterval = oldInterval / 2; - - console.log("Old distribution interval:", oldInterval / 3600, "hours"); - console.log( - "Expected new interval:", - expectedNewInterval / 3600, - "hours" - ); - - // Create proposal - address[] memory targets = new address[](1); - targets[0] = address(token); - - bytes[] memory calldatas = new bytes[](1); - calldatas[0] = abi.encodeWithSignature("halveDistributionInterval()"); - - uint256[] memory values = new uint256[](1); - values[0] = 0; - - vm.prank(validator1); - uint256 proposalId = createDAOProposal( - targets, - calldatas, - values, - "Halve the distribution interval" - ); - - // Vote on proposal - uint256 voteAmount = 100_000e18; - voteOnProposal(proposalId, validator1, voteAmount, true); - voteOnProposal(proposalId, validator2, voteAmount, true); - voteOnProposal(proposalId, validator3, voteAmount, true); - voteOnProposal(proposalId, user1, voteAmount, true); - voteOnProposal(proposalId, user2, voteAmount, true); - - // Execute proposal - executeProposal(proposalId); - - // Verify the change - assertEq( - token.s_distributionInterval(), - expectedNewInterval, - "Distribution interval not halved" - ); - console.log("Successfully halved distribution interval via DAO"); - } - - /** - * @notice Test DAO proposal to double distribution interval - */ - function testDAODoubleDistributionInterval() public { - uint256 oldInterval = token.s_distributionInterval(); - uint256 expectedNewInterval = oldInterval * 2; - - console.log("Old distribution interval:", oldInterval / 3600, "hours"); - console.log( - "Expected new interval:", - expectedNewInterval / 3600, - "hours" - ); - - // Create proposal - address[] memory targets = new address[](1); - targets[0] = address(token); - - bytes[] memory calldatas = new bytes[](1); - calldatas[0] = abi.encodeWithSignature("doubleDistributionInterval()"); - - uint256[] memory values = new uint256[](1); - values[0] = 0; - - vm.prank(validator1); - uint256 proposalId = createDAOProposal( - targets, - calldatas, - values, - "Double the distribution interval" - ); - - uint256 voteAmount = 100_000e18; - voteOnProposal(proposalId, validator1, voteAmount, true); - voteOnProposal(proposalId, validator2, voteAmount, true); - voteOnProposal(proposalId, validator3, voteAmount, true); - voteOnProposal(proposalId, user1, voteAmount, true); - voteOnProposal(proposalId, user2, voteAmount, true); - - // Wait for voting to end - vm.warp(block.timestamp + DAO_VOTING_PERIOD + 1); - - // Verify proposal succeeded before queueing - SmartnodesDAO.ProposalState currentState = dao.state(proposalId); - console.log("Proposal state after voting:", uint8(currentState)); - assertEq( - uint8(currentState), - uint8(SmartnodesDAO.ProposalState.Succeeded), - "Proposal should have succeeded" - ); - - // Queue the proposal - dao.queue(proposalId); - - // Wait for timelock delay (2 days) - vm.warp(block.timestamp + dao.TIMELOCK_DELAY()); - - // Execute the proposal - dao.execute(proposalId); - - // Verify the change - assertEq( - token.s_distributionInterval(), - expectedNewInterval, - "Distribution interval not doubled" - ); - console.log("Successfully doubled distribution interval via DAO"); - } - - /** - * @notice Test DAO proposal to fund a single project with SNO tokens - */ - function testDAOFundProjectWithSNO() public { - uint256 fundingAmount = 50_000e18; // 50k SNO tokens - - console.log("=== Testing Single SNO Project Funding ==="); - console.log("Project address:", projectAddress1); - console.log("Funding amount:", fundingAmount / 1e18, "SNO"); - - // Create proposal to transfer SNO tokens to project - address[] memory targets = new address[](1); - targets[0] = address(token); - - bytes[] memory calldatas = new bytes[](1); - calldatas[0] = abi.encodeWithSignature( - "transfer(address,uint256)", - projectAddress1, - fundingAmount - ); - - uint256[] memory values = new uint256[](1); - values[0] = 0; - - vm.prank(validator1); - uint256 proposalId = createDAOProposal( - targets, - calldatas, - values, - "Fund Project 1 with 50k SNO tokens for development" - ); - - // Vote on proposal with sufficient votes for quorum - uint256 voteAmount = 100_000e18; - voteOnProposal(proposalId, validator1, voteAmount, true); - voteOnProposal(proposalId, validator2, voteAmount, true); - voteOnProposal(proposalId, validator3, voteAmount, true); - voteOnProposal(proposalId, user1, voteAmount, true); - voteOnProposal(proposalId, user2, voteAmount, true); - - // Check votes - (uint256 forVotes, uint256 againstVotes, uint256 totalVotes) = dao - .getProposalVotes(proposalId); - console.log("For votes:", forVotes / 1e18); - console.log("Against votes:", againstVotes / 1e18); - console.log("Total votes:", totalVotes / 1e18); - console.log("Quorum required:", dao.quorumRequired() / 1e18); - - // Record balances before execution - uint256 daoBalanceBefore = token.balanceOf(address(dao)); - uint256 projectBalanceBefore = token.balanceOf(projectAddress1); - - // Execute proposal - executeProposal(proposalId); - - // Verify transfers - uint256 daoBalanceAfter = token.balanceOf(address(dao)); - uint256 projectBalanceAfter = token.balanceOf(projectAddress1); - - assertEq( - daoBalanceAfter, - daoBalanceBefore - fundingAmount, - "DAO balance incorrect" - ); - assertEq( - projectBalanceAfter, - projectBalanceBefore + fundingAmount, - "Project balance incorrect" - ); - - console.log("Successfully funded project with SNO tokens"); - console.log("DAO balance after:", daoBalanceAfter / 1e18); - console.log("Project balance after:", projectBalanceAfter / 1e18); - } - - /** - * @notice Test DAO proposal to fund a project with ETH - */ - function testDAOFundProjectWithETH() public { - uint256 fundingAmount = 2 ether; - - console.log("=== Testing Single ETH Project Funding ==="); - console.log("Project address:", projectAddress2); - console.log("Funding amount:", fundingAmount / 1e18, "ETH"); - - // Create proposal to transfer ETH to project - address[] memory targets = new address[](1); - targets[0] = projectAddress2; - - bytes[] memory calldatas = new bytes[](1); - calldatas[0] = ""; // Empty calldata for simple ETH transfer - - uint256[] memory values = new uint256[](1); - values[0] = fundingAmount; - - vm.prank(validator1); - uint256 proposalId = createDAOProposal( - targets, - calldatas, - values, - "Fund Project 2 with 2 ETH for infrastructure" - ); - - // Vote on proposal - uint256 voteAmount = 100_000e18; - voteOnProposal(proposalId, validator1, voteAmount, true); - voteOnProposal(proposalId, validator2, voteAmount, true); - voteOnProposal(proposalId, validator3, voteAmount, true); - voteOnProposal(proposalId, user1, voteAmount, true); - voteOnProposal(proposalId, user2, voteAmount, true); - - // Record balances before execution - uint256 daoEthBefore = address(dao).balance; - uint256 projectEthBefore = address(projectAddress2).balance; - - console.log("DAO ETH before:", daoEthBefore / 1e18); - console.log("Project ETH before:", projectEthBefore / 1e18); - - // Wait for voting period to end - vm.warp(block.timestamp + DAO_VOTING_PERIOD + 1); - - // Queue the proposal - dao.queue(proposalId); - - // Wait for timelock delay - vm.warp(block.timestamp + dao.TIMELOCK_DELAY()); - - // For this test, we'll use a low-level call approach - // In practice, you'd want to add a helper function to the DAO - vm.expectRevert(); // This will fail because DAO can't send ETH with empty calldata - dao.execute(proposalId); - - console.log("ETH transfer failed as expected (need helper function)"); - } - - // ====== Logistic Checks ====== - - /** - * @notice Test DAO proposal failure due to insufficient votes - */ - function testDAOProposalFailsWithInsufficientVotes() public { - uint256 newLockAmount = 500_000e18; - - // Create proposal - address[] memory targets = new address[](1); - targets[0] = address(token); - - bytes[] memory calldatas = new bytes[](1); - calldatas[0] = abi.encodeWithSignature( - "setValidatorLockAmount(uint256)", - newLockAmount - ); - - uint256[] memory values = new uint256[](1); - values[0] = 0; - - vm.prank(validator1); - uint256 proposalId = createDAOProposal( - targets, - calldatas, - values, - "This proposal should fail" - ); - - // Vote with only a small amount (insufficient for quorum) - // With 5% quorum on ~6.5M total supply, we need ~325k votes minimum - // 10 votes = 100 tokens, way below quorum - voteOnProposal(proposalId, validator1, 10, true); - - // Wait for voting period to end - vm.warp(block.timestamp + DAO_VOTING_PERIOD + 1); - - // Check state - should be defeated due to insufficient quorum - SmartnodesDAO.ProposalState currentState = dao.state(proposalId); - assertEq( - uint8(currentState), - uint8(SmartnodesDAO.ProposalState.Defeated), - "Proposal should be defeated due to insufficient quorum" - ); - - // Try to queue (should fail) - vm.expectRevert("SmartnodesDAO__ProposalDidNotPass()"); - dao.queue(proposalId); - - console.log("Proposal correctly failed due to insufficient quorum"); - } - - /** - * @notice Test DAO proposal failure when against votes exceed for votes - */ - function testDAOProposalFailsWhenRejected() public { - uint256 newLockAmount = 500_000e18; - - // Create proposal - address[] memory targets = new address[](1); - targets[0] = address(token); - - bytes[] memory calldatas = new bytes[](1); - calldatas[0] = abi.encodeWithSignature( - "setValidatorLockAmount(uint256)", - newLockAmount - ); - - uint256[] memory values = new uint256[](1); - values[0] = 0; - - vm.prank(validator1); - uint256 proposalId = createDAOProposal( - targets, - calldatas, - values, - "This proposal should be rejected" - ); - - // Vote against the proposal with sufficient votes for quorum but more against than for - uint256 voteAmount = 400; // This should provide enough total votes for quorum - voteOnProposal(proposalId, validator1, voteAmount, false); // Against - voteOnProposal(proposalId, validator2, voteAmount, false); // Against - voteOnProposal(proposalId, validator3, voteAmount, false); // Against - voteOnProposal(proposalId, user1, voteAmount, true); // For - voteOnProposal(proposalId, user2, voteAmount, true); // For - - // Total: 1200 against, 800 for = 2000 total votes (should meet quorum) - - // Wait for voting period to end - vm.warp(block.timestamp + DAO_VOTING_PERIOD + 1); - - // Check state - should be defeated because against > for - SmartnodesDAO.ProposalState currentState = dao.state(proposalId); - assertEq( - uint8(currentState), - uint8(SmartnodesDAO.ProposalState.Defeated), - "Proposal should be defeated due to more against votes" - ); - - // Try to queue (should fail) - vm.expectRevert("SmartnodesDAO__ProposalDidNotPass()"); - dao.queue(proposalId); - - console.log( - "Proposal correctly failed due to more against votes than for votes" - ); - } - - /** - * @notice Test refund mechanism after voting - */ - function testRefundMechanism() public { - uint256 newLockAmount = 500_000e18; - - // Create proposal - address[] memory targets = new address[](1); - targets[0] = address(token); - - bytes[] memory calldatas = new bytes[](1); - calldatas[0] = abi.encodeWithSignature( - "setValidatorLockAmount(uint256)", - newLockAmount - ); - - uint256[] memory values = new uint256[](1); - values[0] = 0; - - vm.prank(validator1); - uint256 proposalId = createDAOProposal( - targets, - calldatas, - values, - "Test refund mechanism" - ); - - // Record initial balances - uint256 validator1BalanceBefore = token.balanceOf(validator1); - uint256 user1BalanceBefore = token.balanceOf(user1); - - // Vote on proposal - uint256 votes = 100; - uint256 expectedCost = votes * votes; // 10,000 tokens - - voteOnProposal(proposalId, validator1, votes, true); - voteOnProposal(proposalId, user1, votes, false); - - // Wait for voting period to end - vm.warp(block.timestamp + DAO_VOTING_PERIOD + 1); - - // Claim refunds - vm.prank(validator1); - dao.claimRefund(proposalId); - - vm.prank(user1); - dao.claimRefund(proposalId); - - // Check balances are restored - assertEq( - token.balanceOf(validator1), - validator1BalanceBefore, - "Validator1 tokens not fully refunded" - ); - assertEq( - token.balanceOf(user1), - user1BalanceBefore, - "User1 tokens not fully refunded" - ); - - console.log("Refund mechanism working correctly"); - } - - /** - * @notice Test multiple proposals can exist simultaneously - */ - function testMultipleProposals() public { - // Create first proposal - address[] memory targets1 = new address[](1); - targets1[0] = address(token); - bytes[] memory calldatas1 = new bytes[](1); - calldatas1[0] = abi.encodeWithSignature( - "setValidatorLockAmount(uint256)", - 2_000_000e18 - ); - - uint256[] memory values = new uint256[](1); - values[0] = 0; - - vm.prank(validator1); - uint256 proposalId1 = createDAOProposal( - targets1, - calldatas1, - values, - "Proposal 1" - ); - - // Create second proposal - address[] memory targets2 = new address[](1); - targets2[0] = address(token); - bytes[] memory calldatas2 = new bytes[](1); - calldatas2[0] = abi.encodeWithSignature( - "setUserLockAmount(uint256)", - 200e18 - ); - - vm.prank(validator2); - uint256 proposalId2 = createDAOProposal( - targets2, - calldatas2, - values, - "Proposal 2" - ); - - // Vote on both proposals - voteOnProposal(proposalId1, validator1, 300, true); - voteOnProposal(proposalId1, user1, 300, true); - voteOnProposal(proposalId1, user2, 300, true); - - voteOnProposal(proposalId2, validator2, 300, true); - voteOnProposal(proposalId2, validator3, 300, true); - voteOnProposal(proposalId2, user1, 300, false); // Vote against second proposal - - // Check both proposals exist and have correct states - assertEq( - uint8(dao.state(proposalId1)), - uint8(SmartnodesDAO.ProposalState.Active) - ); - assertEq( - uint8(dao.state(proposalId2)), - uint8(SmartnodesDAO.ProposalState.Active) - ); - - console.log("Multiple proposals created and voted on successfully"); - } -} diff --git a/test/DAOTest.t.sol b/test/DAOTest.t.sol new file mode 100644 index 0000000..8d51644 --- /dev/null +++ b/test/DAOTest.t.sol @@ -0,0 +1,1011 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {BaseSmartnodesTest} from "./BaseTest.sol"; +import {SmartnodesDAO} from "../src/SmartnodesDAO.sol"; +import {IGovernor} from "@openzeppelin/contracts/governance/IGovernor.sol"; +import {console} from "forge-std/Test.sol"; + +/** + * @title DAOTest - Fixed Version + * @notice Enhanced test contract with proper role management and setup + */ +contract DAOTest is BaseSmartnodesTest { + address public projectAddress1; + address public projectAddress2; + address public projectAddress3; + bytes32 public constant PROPOSER_ROLE = keccak256("PROPOSER_ROLE"); + bytes32 public constant EXECUTOR_ROLE = keccak256("EXECUTOR_ROLE"); + + function setUp() public override { + super.setUp(); + + projectAddress1 = makeAddr("project1"); + projectAddress2 = makeAddr("project2"); + projectAddress3 = makeAddr("project3"); + + // Setup voting power, delegate to self for all participants + _setupVotingPower(); + _setupDAOPermissions(); + _fundTimelock(); + + // Check token balances after setup + console.log("=== Initial Setup ==="); + console.log("Validator1 balance:", token.balanceOf(validator1) / 1e18); + console.log("Validator2 balance:", token.balanceOf(validator2) / 1e18); + console.log("User1 balance:", token.balanceOf(user1) / 1e18); + console.log("Total supply:", token.totalSupply() / 1e18); + console.log("DAO balance:", token.balanceOf(address(dao)) / 1e18); + console.log( + "Timelock balance:", + token.balanceOf(address(timelock)) / 1e18 + ); + } + + function _setupVotingPower() internal { + // All participants need to delegate to themselves for voting power + address[] memory participants = new address[](5); + participants[0] = validator1; + participants[1] = validator2; + participants[2] = validator3; + participants[3] = user1; + participants[4] = user2; + + for (uint i = 0; i < participants.length; i++) { + vm.prank(participants[i]); + token.delegate(participants[i]); + } + + // Send worker1 a bit of tokens for small voting tests + vm.prank(user2); + token.transfer(worker1, 1000e18); + vm.prank(worker1); + token.delegate(worker1); + + // Move forward one block so delegation takes effect + vm.roll(block.number + 1); + } + + function _setupDAOPermissions() internal { + bytes32 proposerRole = timelock.PROPOSER_ROLE(); + bytes32 executorRole = timelock.EXECUTOR_ROLE(); + bytes32 cancellerRole = timelock.CANCELLER_ROLE(); + bytes32 adminRole = timelock.DEFAULT_ADMIN_ROLE(); + + vm.startPrank(deployerAddr); + // Give DAO proposer + executor + timelock.grantRole(proposerRole, address(dao)); + timelock.grantRole(executorRole, address(dao)); + timelock.grantRole(cancellerRole, address(dao)); + timelock.grantRole(executorRole, address(0)); + + // Handoff admin to DAO + timelock.grantRole(adminRole, address(dao)); + timelock.revokeRole(adminRole, deployerAddr); + vm.stopPrank(); + + // Verify + assertTrue( + timelock.hasRole(proposerRole, address(dao)), + "DAO missing PROPOSER_ROLE" + ); + assertTrue( + timelock.hasRole(executorRole, address(dao)), + "DAO missing EXECUTOR_ROLE" + ); + assertTrue( + timelock.hasRole(adminRole, address(dao)), + "DAO missing ADMIN_ROLE" + ); + } + + function _fundTimelock() internal { + // Transfer tokens to timelock for testing funding proposals + vm.prank(validator3); + token.transfer(address(timelock), 100_000e18); + console.log("Funded timelock with 100k tokens"); + } + + /** + * @notice Enhanced proposal creation that uses base functionality + */ + function createDAOProposal( + address[] memory targets, + uint256[] memory values, + bytes[] memory calldatas, + string memory description + ) internal returns (uint256) { + uint256 proposalId = dao.propose( + targets, + values, + calldatas, + description + ); + console.log("Created proposal ID:", proposalId); + console.log("Proposal description:", description); + + return proposalId; + } + + /** + * @notice Enhanced vote function with proper state management + */ + function voteOnProposal( + uint256 proposalId, + address voter, + uint8 support + ) internal { + vm.startPrank(voter); + + // Get current proposal state and snapshot + uint256 snapshot = dao.proposalSnapshot(proposalId); + uint256 deadline = dao.proposalDeadline(proposalId); + + console.log("Current block:", block.number); + console.log("Proposal snapshot:", snapshot); + console.log("Proposal deadline:", deadline); + console.log("Voting delay:", dao.votingDelay()); + + // Move to voting period if needed (after delay) + uint256 votingStartBlock = snapshot + 1; + if (block.number <= votingStartBlock) { + vm.roll(votingStartBlock + 1); + console.log("Moved to voting block:", block.number); + } + + // Check voting power + uint256 votingPower = token.getVotes(voter); + console.log("Voter:", voter); + console.log("Voting power:", votingPower / 1e18); + + // Cast vote + dao.castVote(proposalId, support); + console.log("Vote cast successfully"); + vm.stopPrank(); + } + + /** + * @notice Enhanced execution with proper timelock handling + */ + function executeProposalEnhanced( + address[] memory targets, + uint256[] memory values, + bytes[] memory calldatas, + string memory description + ) internal { + bytes32 descriptionHash = keccak256(abi.encodePacked(description)); + + // Move past voting period + vm.roll(block.number + dao.votingPeriod() + 1); + + // Queue the proposal + dao.queue(targets, values, calldatas, descriptionHash); + console.log("Proposal queued"); + + // Wait for timelock delay + vm.warp(block.timestamp + timelock.getMinDelay()); + console.log("Waited for timelock delay"); + + // Execute + dao.execute(targets, values, calldatas, descriptionHash); + console.log("Proposal executed"); + } + + /** + * @notice Test DAO proposal to slash validator with enhanced checks - FIXED + */ + function testEnhancedDAOSlashValidator() public { + console.log("=== Enhanced Validator Slashing Test ==="); + + // Get initial state + uint256 lockedBefore = token.s_validatorLockAmount(); // 1M tokens + uint256 slashAmount = lockedBefore / 4; // Slash 25% = 250k tokens + + console.log("Validator1 locked before:", lockedBefore / 1e18); + console.log("Slash amount:", slashAmount / 1e18); + + // Create DAO proposal to slash validator1 + address[] memory targets = new address[](1); + bytes[] memory calldatas = new bytes[](1); + uint256[] memory values = new uint256[](1); + + targets[0] = address(token); + calldatas[0] = abi.encodeWithSignature( + "slashAndUnlockValidator(address,uint256)", + validator1, + slashAmount + ); + values[0] = 0; + + // Propose as validator2 + vm.prank(validator2); + uint256 proposalId = createDAOProposal( + targets, + values, + calldatas, + "Slash validator1 by 25% of locked tokens for misconduct" + ); + + // Vote on proposal - majority for + voteOnProposal(proposalId, validator1, 1); // For + voteOnProposal(proposalId, validator2, 1); // For + voteOnProposal(proposalId, validator3, 1); // For + voteOnProposal(proposalId, user1, 1); // For + voteOnProposal(proposalId, user2, 1); // For + + // Execute the proposal + executeProposalEnhanced( + targets, + values, + calldatas, + "Slash validator1 by 25% of locked tokens for misconduct" + ); + + // Verify execution + ( + bool locked, + bool isValidator, + uint256 timestamp, + uint256 lockAmount + ) = token.getLockInfo(validator1); + uint256 balanceAfterSlash = token.balanceOf(validator1); + + // Complete unlock process + vm.warp(block.timestamp + 30 days); + vm.prank(address(core)); + token.unlockTokens(validator1); + + console.log("Validator1 locked after:", lockAmount / 1e18); + console.log( + "Validator1 balance after slash:", + balanceAfterSlash / 1e18 + ); + + // Verify the slash worked correctly + uint256 expectedRemainingLocked = lockedBefore - slashAmount; + assertEq( + lockAmount, + expectedRemainingLocked, + "Incorrect remaining locked amount" + ); + assertEq(locked, false, "Validator should be in unlock state"); + + uint256 balanceAfterUnlock = token.balanceOf(validator1); + console.log("Final balance after unlock:", balanceAfterUnlock / 1e18); + + console.log("Enhanced validator slashing completed successfully"); + } + + /** + * @notice Test DAO proposal with mixed voting - FIXED + */ + function testDAOProposalWithMixedVoting() public { + console.log("=== Mixed Voting Test ==="); + + uint256 newLockAmount = 1_500_000e18; // 1.5M tokens + + address[] memory targets = new address[](1); + targets[0] = address(token); + bytes[] memory calldatas = new bytes[](1); + calldatas[0] = abi.encodeWithSignature( + "setValidatorLockAmount(uint256)", + newLockAmount + ); + uint256[] memory values = new uint256[](1); + values[0] = 0; + + vm.prank(validator1); + uint256 proposalId = createDAOProposal( + targets, + values, + calldatas, + "Change validator lock amount to 1.5M" + ); + + // Mixed voting + voteOnProposal(proposalId, validator1, 1); // For + voteOnProposal(proposalId, validator2, 0); // Against + voteOnProposal(proposalId, validator3, 1); // For + voteOnProposal(proposalId, user1, 1); // For + voteOnProposal(proposalId, user2, 2); // Abstain + + // Check final vote tally + (uint256 againstVotes, uint256 forVotes, uint256 abstainVotes) = dao + .proposalVotes(proposalId); + + uint256 snapshotBlock = dao.proposalSnapshot(proposalId); + uint256 quorumRequired = dao.quorum(snapshotBlock); + + console.log("Final vote tally:"); + console.log("Against:", againstVotes / 1e18); + console.log("For:", forVotes / 1e18); + console.log("Abstain:", abstainVotes / 1e18); + console.log("Quorum required:", quorumRequired / 1e18); + + // Move past voting period to get final state + vm.roll(block.number + dao.votingPeriod() + 1); + + // Execute if passed (for > against AND for + abstain >= quorum) + IGovernor.ProposalState finalState = dao.state(proposalId); + console.log("Final proposal state:", uint8(finalState)); + + if (uint8(finalState) == 4) { + // Succeeded + executeProposalEnhanced( + targets, + values, + calldatas, + "Change validator lock amount to 1.5M" + ); + + assertEq( + token.s_validatorLockAmount(), + newLockAmount, + "Lock amount should be updated" + ); + console.log("Proposal passed and executed"); + } else { + console.log("Proposal failed - insufficient support or quorum"); + } + } + + /** + * @notice Test DAO proposal that fails due to insufficient quorum - FIXED + */ + function testDAOProposalFailsInsufficientQuorum() public { + console.log("=== Insufficient Quorum Test ==="); + + uint256 newLockAmount = 500_000e18; + + address[] memory targets = new address[](1); + targets[0] = address(token); + bytes[] memory calldatas = new bytes[](1); + calldatas[0] = abi.encodeWithSignature( + "setValidatorLockAmount(uint256)", + newLockAmount + ); + uint256[] memory values = new uint256[](1); + values[0] = 0; + + vm.prank(validator1); + uint256 proposalId = createDAOProposal( + targets, + values, + calldatas, + "This proposal should fail due to low participation" + ); + + voteOnProposal(proposalId, worker1, 1); // worker 1 only has a few tokens + + // Check if proposal meets quorum + (uint256 againstVotes, uint256 forVotes, uint256 abstainVotes) = dao + .proposalVotes(proposalId); + uint256 snapshotBlock = dao.proposalSnapshot(proposalId); + uint256 quorumRequired = dao.quorum(snapshotBlock); + + console.log( + "Votes - Against:", + againstVotes / 1e18, + "For:", + forVotes / 1e18 + ); + console.log("Quorum required:", quorumRequired / 1e18); + console.log("Total votes:", (forVotes + abstainVotes) / 1e18); + + // Wait for voting period to end + vm.roll(block.number + dao.votingPeriod() + 1); + + // Check final state + IGovernor.ProposalState finalState = dao.state(proposalId); + console.log("Final proposal state:", uint8(finalState)); + + // Should be defeated (state 3) due to insufficient quorum + assertTrue( + uint8(finalState) == 3, + "Proposal should be defeated due to insufficient quorum" + ); + + bytes32 descriptionHash = keccak256( + abi.encodePacked( + "This proposal should fail due to low participation" + ) + ); + vm.expectRevert(); + dao.queue(targets, values, calldatas, descriptionHash); + + console.log("Proposal correctly failed due to insufficient quorum"); + } + + /** + * @notice Test multiple simultaneous proposals - FIXED + */ + function testMultipleSimultaneousProposals() public { + console.log("=== Multiple Proposals Test ==="); + + // Create first proposal + address[] memory targets1 = new address[](1); + targets1[0] = address(token); + bytes[] memory calldatas1 = new bytes[](1); + calldatas1[0] = abi.encodeWithSignature( + "setValidatorLockAmount(uint256)", + 2_000_000e18 + ); + uint256[] memory values1 = new uint256[](1); + values1[0] = 0; + + vm.prank(validator1); + uint256 proposalId1 = createDAOProposal( + targets1, + values1, + calldatas1, + "Increase validator lock amount to 2M SNO" + ); + + // Create second proposal + address[] memory targets2 = new address[](1); + targets2[0] = address(token); + bytes[] memory calldatas2 = new bytes[](1); + calldatas2[0] = abi.encodeWithSignature( + "setUserLockAmount(uint256)", + 200e18 + ); + uint256[] memory values2 = new uint256[](1); + values2[0] = 0; + + vm.prank(validator2); + uint256 proposalId2 = createDAOProposal( + targets2, + values2, + calldatas2, + "Increase user lock amount to 200 SNO" + ); + + // Vote on both proposals differently + voteOnProposal(proposalId1, validator1, 1); // For proposal 1 + voteOnProposal(proposalId1, validator2, 1); + voteOnProposal(proposalId1, validator3, 1); + voteOnProposal(proposalId1, user1, 1); + voteOnProposal(proposalId1, user2, 1); + + voteOnProposal(proposalId2, validator1, 0); // Against proposal 2 + voteOnProposal(proposalId2, validator2, 0); + voteOnProposal(proposalId2, validator3, 0); + voteOnProposal(proposalId2, user1, 0); + voteOnProposal(proposalId2, user2, 1); + + // Wait for voting to end + vm.roll(block.number + dao.votingPeriod() + 1); + + // Execute proposal 1 (should pass) + IGovernor.ProposalState state1 = dao.state(proposalId1); + console.log("Proposal 1 final state:", uint8(state1)); + if (uint8(state1) == 4) { + // Succeeded + executeProposalEnhanced( + targets1, + values1, + calldatas1, + "Increase validator lock amount to 2M SNO" + ); + assertEq( + token.s_validatorLockAmount(), + 2_000_000e18, + "Proposal 1 should have executed" + ); + console.log("Proposal 1 executed successfully"); + } + + // Check proposal 2 state + IGovernor.ProposalState state2 = dao.state(proposalId2); + console.log("Proposal 2 final state:", uint8(state2)); + + // Get vote counts for proposal 2 + (uint256 againstVotes2, uint256 forVotes2, uint256 abstainVotes2) = dao + .proposalVotes(proposalId2); + console.log( + "Proposal 2 - Against:", + againstVotes2 / 1e18, + "For:", + forVotes2 / 1e18 + ); + + if (uint8(state2) == 3) { + // Defeated + console.log("Proposal 2 correctly defeated"); + } else if (uint8(state2) == 4) { + // Succeeded + console.log("Proposal 2 passed (unexpected but valid)"); + } + + console.log("Multiple proposals handled correctly"); + } + + /** + * @notice Test DAO funding project with enhanced checks - FIXED + */ + function testEnhancedDAOFundProject() public { + console.log("=== Enhanced Project Funding Test ==="); + + uint256 fundingAmount = 100_000e18; // 100k SNO tokens + uint256 timelockBalanceBefore = token.balanceOf(address(timelock)); + uint256 projectBalanceBefore = token.balanceOf(projectAddress1); + + console.log("Timelock balance before:", timelockBalanceBefore / 1e18); + console.log("Project balance before:", projectBalanceBefore / 1e18); + + // Create proposal to fund project + address[] memory targets = new address[](1); + targets[0] = address(token); + bytes[] memory calldatas = new bytes[](1); + calldatas[0] = abi.encodeWithSignature( + "transfer(address,uint256)", + projectAddress1, + fundingAmount + ); + uint256[] memory values = new uint256[](1); + values[0] = 0; + + vm.prank(validator1); + uint256 proposalId = createDAOProposal( + targets, + values, + calldatas, + "Fund promising DeFi project with 100k SNO tokens" + ); + + // Unanimous support + voteOnProposal(proposalId, validator1, 1); + voteOnProposal(proposalId, validator2, 1); + voteOnProposal(proposalId, validator3, 1); + voteOnProposal(proposalId, user1, 1); + voteOnProposal(proposalId, user2, 1); + + executeProposalEnhanced( + targets, + values, + calldatas, + "Fund promising DeFi project with 100k SNO tokens" + ); + + // Verify transfers + uint256 timelockBalanceAfter = token.balanceOf(address(timelock)); + uint256 projectBalanceAfter = token.balanceOf(projectAddress1); + + console.log("Timelock balance after:", timelockBalanceAfter / 1e18); + console.log("Project balance after:", projectBalanceAfter / 1e18); + + assertEq( + projectBalanceAfter, + projectBalanceBefore + fundingAmount, + "Project should receive funding" + ); + assertEq( + timelockBalanceAfter, + timelockBalanceBefore - fundingAmount, + "Timelock should lose funding" + ); + + console.log("Project funding completed successfully"); + } + + /** + * @notice Test proposal cancellation + */ + function testProposalCancellation() public { + console.log("=== Proposal Cancellation Test ==="); + + address[] memory targets = new address[](1); + targets[0] = address(token); + bytes[] memory calldatas = new bytes[](1); + calldatas[0] = abi.encodeWithSignature( + "setValidatorLockAmount(uint256)", + 999_999e18 + ); + uint256[] memory values = new uint256[](1); + values[0] = 0; + + vm.prank(validator1); + uint256 proposalId = createDAOProposal( + targets, + values, + calldatas, + "This proposal will be cancelled" + ); + + // Check initial state + console.log("Initial proposal state:", uint8(dao.state(proposalId))); + + // Cancel the proposal (can be done by proposer) + vm.prank(validator1); + dao.cancel( + targets, + values, + calldatas, + keccak256(abi.encodePacked("This proposal will be cancelled")) + ); + + // Verify cancellation + IGovernor.ProposalState cancelledState = dao.state(proposalId); + assertEq(uint8(cancelledState), 2, "Proposal should be cancelled"); // Canceled + + console.log("Final proposal state:", uint8(cancelledState)); + console.log("Proposal cancellation successful"); + } + + /** + * @notice Test proposal execution timing edge cases + */ + function testProposalExecutionTimingEdgeCases() public { + console.log("=== Execution Timing Edge Cases ==="); + + address[] memory targets = new address[](1); + targets[0] = address(token); + bytes[] memory calldatas = new bytes[](1); + calldatas[0] = abi.encodeWithSignature( + "setValidatorLockAmount(uint256)", + 1_100_000e18 + ); + uint256[] memory values = new uint256[](1); + values[0] = 0; + + vm.prank(validator1); + uint256 proposalId = createDAOProposal( + targets, + values, + calldatas, + "Test timing edge case" + ); + + // Vote and pass + voteOnProposal(proposalId, validator1, 1); + voteOnProposal(proposalId, validator2, 1); + voteOnProposal(proposalId, validator3, 1); + + vm.roll(block.number + dao.votingPeriod() + 1); + + bytes32 descriptionHash = keccak256( + abi.encodePacked("Test timing edge case") + ); + dao.queue(targets, values, calldatas, descriptionHash); + + // Try to execute too early (should fail) + vm.expectRevert(); + dao.execute(targets, values, calldatas, descriptionHash); + + // Execute at exactly the right time + vm.warp(block.timestamp + timelock.getMinDelay()); + dao.execute(targets, values, calldatas, descriptionHash); + + console.log("Timing edge case handled correctly"); + } + + /** + * @notice Test delegation changes during voting period + */ + function testDelegationChangeDuringVoting() public { + console.log("=== Delegation Change During Voting ==="); + + // Create a new address to test delegation + address delegatee = makeAddr("delegatee"); + + address[] memory targets = new address[](1); + targets[0] = address(token); + bytes[] memory calldatas = new bytes[](1); + calldatas[0] = abi.encodeWithSignature( + "setValidatorLockAmount(uint256)", + 1_200_000e18 + ); + uint256[] memory values = new uint256[](1); + values[0] = 0; + + vm.prank(validator1); + uint256 proposalId = createDAOProposal( + targets, + values, + calldatas, + "Test delegation during voting" + ); + + // Move to voting period + vm.roll(dao.proposalSnapshot(proposalId) + 2); + + // Check initial voting power + uint256 initialPower = token.getVotes(user1); + console.log("User1 initial voting power:", initialPower / 1e18); + + // User1 votes + vm.prank(user1); + dao.castVote(proposalId, 1); + + // User1 changes delegation mid-voting (shouldn't affect this proposal) + vm.prank(user1); + token.delegate(delegatee); + vm.roll(block.number + 1); // Make delegation effective + + // Check that delegatee got the power but it doesn't affect the proposal + uint256 delegateePower = token.getVotes(delegatee); + uint256 user1PowerAfter = token.getVotes(user1); + + console.log("User1 power after delegation:", user1PowerAfter / 1e18); + console.log("Delegatee power:", delegateePower / 1e18); + + // Verify vote was still counted with original power + (uint256 againstVotes, uint256 forVotes, uint256 abstainVotes) = dao + .proposalVotes(proposalId); + assertTrue( + forVotes >= initialPower, + "Vote should be counted with snapshot power" + ); + + console.log("Delegation change during voting handled correctly"); + } + + /** + * @notice Test maximum proposal limit edge case + */ + function testMaxProposalLimit() public { + console.log("=== Max Proposal Limit Test ==="); + + // Create multiple proposals to test any limits + for (uint i = 0; i < 5; i++) { + address[] memory targets = new address[](1); + targets[0] = address(token); + bytes[] memory calldatas = new bytes[](1); + calldatas[0] = abi.encodeWithSignature( + "setValidatorLockAmount(uint256)", + 1_000_000e18 + (i * 10_000e18) + ); + uint256[] memory values = new uint256[](1); + values[0] = 0; + + vm.prank(validator1); + string memory description = string( + abi.encodePacked("Proposal number ", vm.toString(i)) + ); + uint256 proposalId = createDAOProposal( + targets, + values, + calldatas, + description + ); + + console.log("Created proposal", i, "with ID:", proposalId); + } + + console.log("Multiple proposals created successfully"); + } + + /** + * @notice Test proposal with zero value but ETH transfer + */ + function testProposalWithETHTransfer() public { + console.log("=== ETH Transfer Proposal Test ==="); + + // Fund timelock with ETH + vm.deal(address(timelock), 10 ether); + console.log("Timelock ETH balance:", address(timelock).balance / 1e18); + + address[] memory targets = new address[](1); + targets[0] = projectAddress1; + bytes[] memory calldatas = new bytes[](1); + calldatas[0] = ""; // Empty calldata for simple ETH transfer + uint256[] memory values = new uint256[](1); + values[0] = 1 ether; + + vm.prank(validator1); + uint256 proposalId = createDAOProposal( + targets, + values, + calldatas, + "Send 1 ETH to project" + ); + + // Vote and execute + voteOnProposal(proposalId, validator1, 1); + voteOnProposal(proposalId, validator2, 1); + voteOnProposal(proposalId, validator3, 1); + + uint256 projectBalanceBefore = projectAddress1.balance; + executeProposalEnhanced( + targets, + values, + calldatas, + "Send 1 ETH to project" + ); + + uint256 projectBalanceAfter = projectAddress1.balance; + assertEq( + projectBalanceAfter, + projectBalanceBefore + 1 ether, + "Project should receive ETH" + ); + + console.log("ETH transfer proposal executed successfully"); + } + + /** + * @notice Test proposal with multiple operations in single transaction + */ + function testMultiOperationProposal() public { + console.log("=== Multi-Operation Proposal Test ==="); + + address[] memory targets = new address[](3); + targets[0] = address(token); + targets[1] = address(token); + targets[2] = address(token); + + bytes[] memory calldatas = new bytes[](3); + calldatas[0] = abi.encodeWithSignature( + "setValidatorLockAmount(uint256)", + 1_100_000e18 + ); + calldatas[1] = abi.encodeWithSignature( + "setUserLockAmount(uint256)", + 150e18 + ); + calldatas[2] = abi.encodeWithSignature( + "transfer(address,uint256)", + projectAddress2, + 50_000e18 + ); + + uint256[] memory values = new uint256[](3); + values[0] = 0; + values[1] = 0; + values[2] = 0; + + vm.prank(validator1); + uint256 proposalId = createDAOProposal( + targets, + values, + calldatas, + "Multi-operation: Update locks and fund project" + ); + + // Vote and execute + voteOnProposal(proposalId, validator1, 1); + voteOnProposal(proposalId, validator2, 1); + voteOnProposal(proposalId, validator3, 1); + voteOnProposal(proposalId, user1, 1); + + uint256 projectBalanceBefore = token.balanceOf(projectAddress2); + + executeProposalEnhanced( + targets, + values, + calldatas, + "Multi-operation: Update locks and fund project" + ); + + // Verify all operations executed + assertEq( + token.s_validatorLockAmount(), + 1_100_000e18, + "Validator lock should be updated" + ); + assertEq( + token.s_userLockAmount(), + 150e18, + "User lock should be updated" + ); + assertEq( + token.balanceOf(projectAddress2), + projectBalanceBefore + 50_000e18, + "Project should receive tokens" + ); + + console.log("Multi-operation proposal executed successfully"); + } + + /** + * @notice Test proposal state transitions edge cases + */ + function testProposalStateTransitions() public { + console.log("=== Proposal State Transitions ==="); + + address[] memory targets = new address[](1); + targets[0] = address(token); + bytes[] memory calldatas = new bytes[](1); + calldatas[0] = abi.encodeWithSignature( + "setValidatorLockAmount(uint256)", + 1_050_000e18 + ); + uint256[] memory values = new uint256[](1); + values[0] = 0; + + vm.prank(validator1); + uint256 proposalId = createDAOProposal( + targets, + values, + calldatas, + "Test state transitions" + ); + + // Check state: Pending + assertEq(uint8(dao.state(proposalId)), 0, "Should be Pending"); + console.log("State 0 - Pending"); + + // Move to Active + vm.roll(dao.proposalSnapshot(proposalId) + 1); + assertEq(uint8(dao.state(proposalId)), 1, "Should be Active"); + console.log("State 1 - Active"); + + // Vote and check various states + voteOnProposal(proposalId, validator1, 1); + voteOnProposal(proposalId, validator2, 1); + voteOnProposal(proposalId, validator3, 1); + + // Move past voting period + vm.roll(block.number + dao.votingPeriod() + 1); + assertEq(uint8(dao.state(proposalId)), 4, "Should be Succeeded"); + console.log("State 4 - Succeeded"); + + // Queue it + bytes32 descriptionHash = keccak256( + abi.encodePacked("Test state transitions") + ); + dao.queue(targets, values, calldatas, descriptionHash); + assertEq(uint8(dao.state(proposalId)), 5, "Should be Queued"); + console.log("State 5 - Queued"); + + // Execute it + vm.warp(block.timestamp + timelock.getMinDelay()); + dao.execute(targets, values, calldatas, descriptionHash); + assertEq(uint8(dao.state(proposalId)), 7, "Should be Executed"); + console.log("State 7 - Executed"); + + console.log("All state transitions verified successfully"); + } + + /** + * @notice Test proposal that would fail execution due to insufficient funds + */ + function testProposalFailedExecution() public { + console.log("=== Failed Execution Test ==="); + + // Try to transfer more tokens than timelock has + uint256 timelockBalance = token.balanceOf(address(timelock)); + uint256 excessiveAmount = timelockBalance + 1000e18; + + address[] memory targets = new address[](1); + targets[0] = address(token); + bytes[] memory calldatas = new bytes[](1); + calldatas[0] = abi.encodeWithSignature( + "transfer(address,uint256)", + projectAddress3, + excessiveAmount + ); + uint256[] memory values = new uint256[](1); + values[0] = 0; + + vm.prank(validator1); + uint256 proposalId = createDAOProposal( + targets, + values, + calldatas, + "This execution will fail due to insufficient funds" + ); + + // Pass the vote + voteOnProposal(proposalId, validator1, 1); + voteOnProposal(proposalId, validator2, 1); + voteOnProposal(proposalId, validator3, 1); + + vm.roll(block.number + dao.votingPeriod() + 1); + + bytes32 descriptionHash = keccak256( + abi.encodePacked( + "This execution will fail due to insufficient funds" + ) + ); + dao.queue(targets, values, calldatas, descriptionHash); + + vm.warp(block.timestamp + timelock.getMinDelay()); + + // This should revert due to insufficient balance + vm.expectRevert(); + dao.execute(targets, values, calldatas, descriptionHash); + + console.log("Failed execution handled correctly"); + } +} diff --git a/test/TokenFuzz.t.sol b/test/TokenFuzz.t.sol new file mode 100644 index 0000000..b83be98 --- /dev/null +++ b/test/TokenFuzz.t.sol @@ -0,0 +1,534 @@ +// // SPDX-License-Identifier: MIT +// pragma solidity ^0.8.22; + +// import {console} from "forge-std/Test.sol"; +// import {SmartnodesERC20} from "../src/SmartnodesERC20.sol"; +// import {BaseSmartnodesTest} from "./BaseTest.sol"; + +// /** +// * @title SmartnodesTokenFuzzTest +// * @notice Fuzz testing for SmartnodesERC20 contract to test edge cases and boundary conditions +// */ +// contract SmartnodesTokenFuzzTest is BaseSmartnodesTest { +// uint256 constant MAX_REASONABLE_TOKENS = 1_000_000_000e18; // 1B tokens +// uint256 constant MAX_REASONABLE_ETH = 100_000 ether; // 100k ETH +// uint256 constant MIN_LOCK_AMOUNT = 1e18; // 1 token minimum + +// function _setupInitialState() internal override { +// BaseSmartnodesTest._setupInitialState(); +// createTestUser(user1, USER1_PUBKEY); +// createTestUser(user2, USER2_PUBKEY); +// } + +// // ============= Fuzz Tests for Token Locking ============= + +// /// @notice Fuzz test for locking user tokens with various amounts +// function testFuzz_LockUserTokens(uint256 lockAmount) public { +// vm.assume(lockAmount >= USER_LOCK_AMOUNT); +// // Reduce maximum to prevent overflow with validator1's initial balance +// vm.assume(lockAmount <= MAX_REASONABLE_TOKENS / 10); + +// // Ensure validator1 has enough tokens to transfer +// uint256 validatorBalance = token.balanceOf(validator1); +// vm.assume(lockAmount <= validatorBalance); + +// // Create a fresh user to avoid "already locked" issues +// address freshUser = makeAddr("freshUser"); + +// // Give user tokens +// vm.prank(validator1); +// token.transfer(freshUser, lockAmount); + +// uint256 initialBalance = token.balanceOf(freshUser); +// uint256 initialContractBalance = token.balanceOf(address(token)); + +// vm.prank(address(core)); +// token.lockTokens(freshUser, false); + +// // Should lock exactly USER_LOCK_AMOUNT regardless of balance +// assertEq(token.balanceOf(freshUser), initialBalance - USER_LOCK_AMOUNT); +// assertEq( +// token.balanceOf(address(token)), +// initialContractBalance + USER_LOCK_AMOUNT +// ); +// } + +// /// @notice Fuzz test for attempting to lock tokens with insufficient balance +// function testFuzz_LockTokensInsufficientBalance(uint256 balance) public { +// vm.assume(balance < USER_LOCK_AMOUNT); +// vm.assume(balance <= MAX_REASONABLE_TOKENS / 10); + +// address testUser = makeAddr("testUser"); + +// if (balance > 0) { +// // Ensure validator1 has enough tokens to transfer +// uint256 validatorBalance = token.balanceOf(validator1); +// vm.assume(balance <= validatorBalance); + +// vm.prank(validator1); +// token.transfer(testUser, balance); +// } + +// vm.expectRevert(SmartnodesERC20.Token__InsufficientBalance.selector); +// vm.prank(address(core)); +// token.lockTokens(testUser, false); +// } + +// /// @notice Fuzz test unlock timing with various time periods +// function testFuzz_UnlockTiming(uint256 timeAdvance) public { +// vm.assume(timeAdvance <= 365 days * 10); // Cap at 10 years + +// // Create a fresh validator to avoid conflicts +// address freshValidator = makeAddr("freshValidator"); + +// // Give the validator some tokens and lock them first +// vm.prank(validator1); +// token.transfer(freshValidator, VALIDATOR_LOCK_AMOUNT); + +// vm.prank(address(core)); +// token.lockTokens(freshValidator, true); + +// // Initiate unlock +// vm.prank(address(core)); +// token.unlockTokens(freshValidator); + +// // Fast forward time +// vm.warp(block.timestamp + timeAdvance); + +// if (timeAdvance >= UNLOCK_PERIOD) { +// // Should succeed +// vm.prank(address(core)); +// token.unlockTokens(freshValidator); +// } else { +// // Should fail +// vm.expectRevert(SmartnodesERC20.Token__UnlockPending.selector); +// vm.prank(address(core)); +// token.unlockTokens(freshValidator); +// } +// } + +// // ============= Fuzz Tests for Escrow ============= + +// /// @notice Fuzz test escrow amounts +// function testFuzz_EscrowPayment(uint256 escrowAmount) public { +// vm.assume(escrowAmount > 0); +// // Reduce maximum to prevent overflow +// vm.assume(escrowAmount <= MAX_REASONABLE_TOKENS / 10); + +// // Ensure validator1 has enough tokens to transfer +// uint256 validatorBalance = token.balanceOf(validator1); +// vm.assume(escrowAmount <= validatorBalance); + +// // Create fresh user for each test +// address freshUser = makeAddr("freshEscrowUser"); + +// // Give user enough tokens +// vm.prank(validator1); +// token.transfer(freshUser, escrowAmount); + +// uint256 initialBalance = token.balanceOf(freshUser); +// uint256 initialContractBalance = token.balanceOf(address(token)); + +// vm.prank(address(core)); +// token.escrowPayment(freshUser, escrowAmount); + +// assertEq(token.balanceOf(freshUser), initialBalance - escrowAmount); +// assertEq( +// token.balanceOf(address(token)), +// initialContractBalance + escrowAmount +// ); + +// SmartnodesERC20.PaymentAmounts memory escrowed = token +// .getEscrowedPayments(freshUser); +// assertEq(escrowed.sno, escrowAmount); +// } + +// /// @notice Fuzz test ETH escrow amounts +// function testFuzz_EscrowEthPayment(uint256 ethAmount) public { +// vm.assume(ethAmount > 0); +// vm.assume(ethAmount <= MAX_REASONABLE_ETH); + +// vm.deal(address(core), ethAmount); + +// // Create fresh user for each test +// address freshUser = makeAddr("freshEthEscrowUser"); + +// uint256 initialBalance = address(token).balance; + +// vm.prank(address(core)); +// token.escrowEthPayment{value: ethAmount}(freshUser, ethAmount); + +// assertEq(address(token).balance, initialBalance + ethAmount); + +// SmartnodesERC20.PaymentAmounts memory escrowed = token +// .getEscrowedPayments(freshUser); +// assertEq(escrowed.eth, ethAmount); +// } + +// /// @notice Fuzz test multiple escrows for same user +// function testFuzz_MultipleEscrows(uint256 escrow1, uint256 escrow2) public { +// vm.assume(escrow1 > 0 && escrow1 <= MAX_REASONABLE_TOKENS / 20); +// vm.assume(escrow2 > 0 && escrow2 <= MAX_REASONABLE_TOKENS / 20); + +// uint256 totalEscrow = escrow1 + escrow2; + +// // Ensure validator1 has enough tokens to transfer +// uint256 validatorBalance = token.balanceOf(validator1); +// vm.assume(totalEscrow <= validatorBalance); + +// // Create fresh user for each test +// address freshUser = makeAddr("freshMultiEscrowUser"); + +// // Give user enough tokens +// vm.prank(validator1); +// token.transfer(freshUser, totalEscrow); + +// vm.startPrank(address(core)); +// token.escrowPayment(freshUser, escrow1); +// token.escrowPayment(freshUser, escrow2); +// vm.stopPrank(); + +// SmartnodesERC20.PaymentAmounts memory escrowed = token +// .getEscrowedPayments(freshUser); +// assertEq(escrowed.sno, totalEscrow); +// } + +// // /// @notice Fuzz test escrow release amounts +// // function testFuzz_ReleaseEscrow( +// // uint256 escrowAmount, +// // uint256 releaseAmount +// // ) public { +// // vm.assume( +// // escrowAmount > 0 && escrowAmount <= MAX_REASONABLE_TOKENS / 10 +// // ); +// // vm.assume(releaseAmount > 0 && releaseAmount <= escrowAmount); // Ensure releaseAmount > 0 + +// // // Ensure validator1 has enough tokens to transfer +// // uint256 validatorBalance = token.balanceOf(validator1); +// // vm.assume(escrowAmount <= validatorBalance); + +// // // Create fresh user for each test +// // address freshUser = makeAddr("freshReleaseUser"); + +// // // Setup escrow +// // vm.prank(validator1); +// // token.transfer(freshUser, escrowAmount); +// // vm.prank(address(core)); +// // token.escrowPayment(freshUser, escrowAmount); + +// // uint256 initialBalance = token.balanceOf(freshUser); + +// // vm.prank(address(core)); +// // token.releaseEscrowedPayment(freshUser, releaseAmount); + +// // assertEq(token.balanceOf(freshUser), initialBalance + releaseAmount); + +// // SmartnodesERC20.PaymentAmounts memory remaining = token +// // .getEscrowedPayments(freshUser); +// // assertEq(remaining.sno, escrowAmount - releaseAmount); +// // } + +// // ============= Fuzz Tests for Merkle Distributions ============= + +// /// @notice Fuzz test distribution creation with various parameters +// function testFuzz_CreateDistribution( +// uint256 totalCapacity, +// uint256 additionalSno, +// uint256 additionalEth +// ) public { +// vm.assume(totalCapacity > 0 && totalCapacity <= 1_000_000); +// vm.assume(additionalSno <= MAX_REASONABLE_TOKENS); +// vm.assume(additionalEth <= MAX_REASONABLE_ETH); + +// _setupContractFunding(); + +// // Fund contract if needed +// if (additionalEth > ADDITIONAL_ETH_PAYMENT) { +// vm.deal(address(core), additionalEth); +// vm.prank(address(core)); +// (bool success, ) = address(token).call{value: additionalEth}(""); +// require(success, "Additional funding failed"); +// } + +// address[] memory validators = new address[](1); +// validators[0] = validator1; + +// SmartnodesERC20.PaymentAmounts memory payments = SmartnodesERC20 +// .PaymentAmounts({ +// sno: uint128(additionalSno), +// eth: uint128(additionalEth) +// }); + +// bytes32 dummyRoot = keccak256(abi.encode("dummy", totalCapacity)); + +// vm.prank(address(core)); +// token.createMerkleDistribution( +// dummyRoot, +// totalCapacity, +// validators, +// payments, +// validator1 +// ); + +// uint256 distributionId = token.s_currentDistributionId(); +// ( +// bytes32 storedRoot, +// SmartnodesERC20.PaymentAmounts memory workerReward, +// uint256 storedCapacity, +// bool active, +// , + +// ) = token.s_distributions(distributionId); + +// assertEq(storedRoot, dummyRoot); +// assertEq(storedCapacity, totalCapacity); +// assertTrue(active); +// } + +// /// @notice Fuzz test emission rate calculation at various timestamps +// function testFuzz_EmissionRate(uint256 timeAdvance) public { +// vm.assume(timeAdvance <= 365 days * 50); // Cap at 50 years + +// uint256 startTime = token.i_deploymentTimestamp(); +// vm.warp(startTime + timeAdvance); + +// uint256 rate = token.getEmissionRate(); + +// // Should never be zero or exceed initial rate +// assertGt(rate, 0); +// assertLe(rate, INITIAL_EMISSION_RATE); + +// // Should either be tail emission or calculated rate +// if (rate == TAIL_EMISSION) { +// // We've reached tail emission - should be after many halvings +// assertTrue(timeAdvance >= REWARD_PERIOD * 5); // After several halvings +// } else { +// // Should be a calculated reduction based on halvings +// assertTrue(rate <= INITIAL_EMISSION_RATE); +// } +// } + +// // ============= Invariant Testing ============= + +// /// @notice Test that total supply changes are tracked correctly +// function testFuzz_TotalSupplyInvariant( +// uint256 numDistributions, +// uint256 additionalPayment +// ) public { +// vm.assume(numDistributions > 0 && numDistributions <= 3); // Reduced for stability +// vm.assume( +// additionalPayment <= MAX_REASONABLE_TOKENS / (numDistributions * 10) +// ); + +// _setupContractFunding(); + +// uint256 initialSupply = token.totalSupply(); +// uint256 expectedIncrease = 0; + +// // Advance time to ensure we're past any distribution cooldown +// vm.warp(block.timestamp + UPDATE_TIME + 1); + +// for (uint256 i = 0; i < numDistributions; i++) { +// // Get emission rate BEFORE creating distribution +// uint256 emissionRate = token.getEmissionRate(); +// expectedIncrease += emissionRate; + +// address[] memory validators = new address[](1); +// validators[0] = validator1; + +// SmartnodesERC20.PaymentAmounts memory payments = SmartnodesERC20 +// .PaymentAmounts({sno: uint128(additionalPayment), eth: 0}); + +// vm.prank(address(core)); +// token.createMerkleDistribution( +// keccak256(abi.encode("test", i, block.timestamp)), // More unique roots +// 1000, +// validators, +// payments, +// validator1 +// ); + +// // Advance time significantly between distributions to ensure cooldown period passes +// vm.warp(block.timestamp + UPDATE_TIME + 1); +// } + +// uint256 finalSupply = token.totalSupply(); +// assertEq(finalSupply, initialSupply + expectedIncrease); +// } + +// /// @notice Test contract ETH balance invariant +// function testFuzz_EthBalanceInvariant( +// uint256 escrowAmount, +// uint256 distributionAmount +// ) public { +// vm.assume(escrowAmount > 0 && escrowAmount <= MAX_REASONABLE_ETH / 2); +// vm.assume( +// distributionAmount > 0 && +// distributionAmount <= MAX_REASONABLE_ETH / 2 +// ); + +// uint256 totalEth = escrowAmount + distributionAmount; +// vm.deal(address(core), totalEth); + +// // Create fresh user for each test +// address freshUser = makeAddr("freshEthInvariantUser"); + +// uint256 initialBalance = address(token).balance; + +// // Escrow some ETH +// vm.prank(address(core)); +// token.escrowEthPayment{value: escrowAmount}(freshUser, escrowAmount); + +// // Fund for distribution +// vm.prank(address(core)); +// (bool success, ) = address(token).call{value: distributionAmount}(""); +// require(success, "Distribution funding failed"); + +// uint256 finalBalance = address(token).balance; +// assertEq(finalBalance, initialBalance + totalEth); +// } + +// // ============= Stress Tests ============= + +// /// @notice Stress test with extreme values +// function testFuzz_ExtremeValues(uint256 seed) public { +// vm.assume(seed > 0); + +// // Use seed to generate deterministic but varied test cases +// uint256 tokenAmount = (seed % (MAX_REASONABLE_TOKENS / 10)) + 1; +// uint256 ethAmount = (seed % MAX_REASONABLE_ETH) + 1; +// uint256 capacity = (seed % 1_000_000) + 1; + +// // Ensure validator1 has enough tokens +// uint256 validatorBalance = token.balanceOf(validator1); +// vm.assume(tokenAmount <= validatorBalance); + +// // Create fresh user for each test to avoid state conflicts +// address freshUser = makeAddr( +// string(abi.encodePacked("extremeUser", seed)) +// ); + +// // Test with large token amounts +// vm.prank(validator1); +// token.transfer(freshUser, tokenAmount); + +// // Only try to lock if we have enough tokens and user isn't already locked +// if (tokenAmount >= USER_LOCK_AMOUNT) { +// // Check if this would cause conflicts by trying with a different user +// try this.testLockAttempt(freshUser) { +// // Success - continue +// } catch { +// // Skip locking test if it would fail due to existing state +// } +// } + +// // Test with large ETH amounts +// vm.deal(address(core), ethAmount); +// vm.prank(address(core)); +// token.escrowEthPayment{value: ethAmount}(freshUser, ethAmount); + +// // Test distribution with large capacity +// _setupContractFunding(); + +// address[] memory validators = new address[](1); +// validators[0] = validator1; + +// SmartnodesERC20.PaymentAmounts memory payments = SmartnodesERC20 +// .PaymentAmounts({sno: 0, eth: 0}); + +// vm.prank(address(core)); +// token.createMerkleDistribution( +// keccak256(abi.encode(seed, block.timestamp)), +// capacity, +// validators, +// payments, +// validator1 +// ); +// } + +// /// @notice Helper function for lock attempt testing +// function testLockAttempt(address user) external { +// // Ensure the user has enough balance first +// uint256 userBalance = token.balanceOf(user); +// if (userBalance < USER_LOCK_AMOUNT) { +// // Transfer enough tokens from validator1 +// vm.prank(validator1); +// token.transfer(user, USER_LOCK_AMOUNT); +// } + +// vm.prank(address(core)); +// token.lockTokens(user, false); +// } + +// // ============= Boundary Tests ============= + +// /// @notice Test with maximum validator array size +// function testBoundary_MaxValidators() public { +// _setupContractFunding(); + +// // Create large validator array (reasonable limit) +// address[] memory validators = new address[](100); +// for (uint256 i = 0; i < 100; i++) { +// validators[i] = address(uint160(0x4000 + i)); +// } + +// SmartnodesERC20.PaymentAmounts memory payments = SmartnodesERC20 +// .PaymentAmounts({ +// sno: uint128(ADDITIONAL_SNO_PAYMENT), +// eth: uint128(ADDITIONAL_ETH_PAYMENT) +// }); + +// vm.prank(address(core)); +// token.createMerkleDistribution( +// keccak256(abi.encode("boundary_test", block.timestamp)), +// 1000, +// validators, +// payments, +// validators[0] +// ); +// } + +// // ============= Gas Optimization Fuzz Tests ============= + +// /// @notice Fuzz test gas usage with varying participant counts +// function testFuzz_GasUsage(uint256 numParticipants) public { +// vm.assume(numParticipants > 0 && numParticipants <= 500); // Reduced for stability + +// _setupContractFunding(); +// ( +// Participant[] memory participants, +// uint256 totalCapacity +// ) = _setupTestParticipants(numParticipants, false); + +// bytes32[] memory leaves = _generateLeaves(participants, 1); +// bytes32 merkleRoot = _buildMerkleTree(leaves); + +// address[] memory validators = new address[](1); +// validators[0] = validator1; + +// SmartnodesERC20.PaymentAmounts memory payments = SmartnodesERC20 +// .PaymentAmounts({ +// sno: uint128(ADDITIONAL_SNO_PAYMENT), +// eth: uint128(ADDITIONAL_ETH_PAYMENT) +// }); + +// uint256 gasBefore = gasleft(); + +// vm.prank(address(core)); +// token.createMerkleDistribution( +// merkleRoot, +// totalCapacity, +// validators, +// payments, +// validator1 +// ); + +// uint256 gasUsed = gasBefore - gasleft(); + +// // Gas should be reasonable and roughly linear with participant count +// console.log("Participants:", numParticipants, "Gas used:", gasUsed); + +// // Assert gas usage is within reasonable bounds +// assertLt(gasUsed, 500_000, "Gas usage too high"); +// } +// } diff --git a/test/TokenTest.t.sol b/test/TokenTest.t.sol index 3978e84..cdd83ec 100644 --- a/test/TokenTest.t.sol +++ b/test/TokenTest.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.22; +pragma solidity ^0.8.24; import {console} from "forge-std/Test.sol"; import {SmartnodesERC20} from "../src/SmartnodesERC20.sol"; @@ -306,9 +306,7 @@ contract SmartnodesTokenTest is BaseSmartnodesTest { // Fast forward one year vm.warp(block.timestamp + REWARD_PERIOD); - uint256 expectedRate = (INITIAL_EMISSION_RATE * - DEPLOYMENT_MULTIPLIER * - 3) / 5; + uint256 expectedRate = (INITIAL_EMISSION_RATE * 3) / 5; assertEq(token.getEmissionRate(), expectedRate); } @@ -316,10 +314,7 @@ contract SmartnodesTokenTest is BaseSmartnodesTest { // Fast forward two years vm.warp(block.timestamp + (REWARD_PERIOD * 2)); - uint256 expectedRate = (INITIAL_EMISSION_RATE * - DEPLOYMENT_MULTIPLIER * - 3 * - 3) / (5 * 5); + uint256 expectedRate = (INITIAL_EMISSION_RATE * 3 * 3) / (5 * 5); assertEq(token.getEmissionRate(), expectedRate); } @@ -327,10 +322,7 @@ contract SmartnodesTokenTest is BaseSmartnodesTest { // Fast forward many years to reach tail emission vm.warp(block.timestamp + (REWARD_PERIOD * 20)); - assertEq( - token.getEmissionRate(), - TAIL_EMISSION * DEPLOYMENT_MULTIPLIER - ); + assertEq(token.getEmissionRate(), TAIL_EMISSION); } // ============= Access Control Tests ============= @@ -361,7 +353,9 @@ contract SmartnodesTokenTest is BaseSmartnodesTest { uint256 totalCapacity ) internal returns (uint256 distributionId, bytes32 merkleRoot) { // Generate merkle tree - bytes32[] memory leaves = _generateLeaves(participants); + distributionId = token.s_currentDistributionId() + 1; + + bytes32[] memory leaves = _generateLeaves(participants, distributionId); merkleRoot = _buildMerkleTree(leaves); console.log("Generated", leaves.length, "leaves"); @@ -394,22 +388,18 @@ contract SmartnodesTokenTest is BaseSmartnodesTest { ); distributionId = token.s_currentDistributionId(); - assertEq(distributionId, 1, "Distribution ID should be 1"); // Validate distribution storage ( bytes32 storedRoot, SmartnodesERC20.PaymentAmounts memory workerReward, uint256 storedCapacity, - bool active, - uint256 timestamp + uint256 timestamp, + uint256 _distributionId ) = token.s_distributions(distributionId); assertEq(storedRoot, merkleRoot, "Stored merkle root mismatch"); assertEq(storedCapacity, totalCapacity, "Stored capacity mismatch"); - if (participants.length > 0) - assertTrue(active, "Distribution should be active"); - console.log("Distribution created and validated successfully"); } @@ -421,9 +411,7 @@ contract SmartnodesTokenTest is BaseSmartnodesTest { (, SmartnodesERC20.PaymentAmounts memory workerReward, , , ) = token .s_distributions(distributionId); - uint256 totalSnoReward = INITIAL_EMISSION_RATE * - DEPLOYMENT_MULTIPLIER + - ADDITIONAL_SNO_PAYMENT; + uint256 totalSnoReward = INITIAL_EMISSION_RATE + ADDITIONAL_SNO_PAYMENT; uint256 totalEthReward = ADDITIONAL_ETH_PAYMENT; uint256 expectedValidatorSno = (totalSnoReward * @@ -467,12 +455,10 @@ contract SmartnodesTokenTest is BaseSmartnodesTest { Participant[] memory participants ) internal { uint256 numWorkers = participants.length; - bytes32[] memory leaves = _generateLeaves(participants); + bytes32[] memory leaves = _generateLeaves(participants, distributionId); // Calculate total rewards - uint256 totalSnoReward = INITIAL_EMISSION_RATE * - DEPLOYMENT_MULTIPLIER + - ADDITIONAL_SNO_PAYMENT; + uint256 totalSnoReward = INITIAL_EMISSION_RATE + ADDITIONAL_SNO_PAYMENT; uint256 totalEthReward = ADDITIONAL_ETH_PAYMENT; uint256 expectedValidatorSno = (totalSnoReward * @@ -563,7 +549,7 @@ contract SmartnodesTokenTest is BaseSmartnodesTest { Participant memory worker = participants[workerIndex]; // Generate Merkle proof just for this worker - bytes32[] memory leaves = _generateLeaves(participants); + bytes32[] memory leaves = _generateLeaves(participants, distributionId); bytes32[] memory proof = _generateMerkleProof(leaves, workerIndex); // Pre-claim balances @@ -573,16 +559,13 @@ contract SmartnodesTokenTest is BaseSmartnodesTest { vm.prank(worker.addr); token.claimMerkleRewards(distributionId, worker.capacity, proof); - uint256 validatorSnoReward = ((INITIAL_EMISSION_RATE * - DEPLOYMENT_MULTIPLIER + + uint256 validatorSnoReward = ((INITIAL_EMISSION_RATE + ADDITIONAL_SNO_PAYMENT) * VALIDATOR_REWARD_PERCENTAGE) / 100; - uint256 daoSnoReward = ((INITIAL_EMISSION_RATE * - DEPLOYMENT_MULTIPLIER + + uint256 daoSnoReward = ((INITIAL_EMISSION_RATE + ADDITIONAL_SNO_PAYMENT) * DAO_REWARD_PERCENTAGE) / 100; - uint256 expectedWorkerSno = (INITIAL_EMISSION_RATE * - DEPLOYMENT_MULTIPLIER + + uint256 expectedWorkerSno = (INITIAL_EMISSION_RATE + ADDITIONAL_SNO_PAYMENT) - validatorSnoReward - daoSnoReward; @@ -603,4 +586,411 @@ contract SmartnodesTokenTest is BaseSmartnodesTest { console.log("Specific worker claim test passed!"); } + + /** + * @notice Test creating multiple distributions in a loop and claiming rewards + */ + function testMultipleMerkleDistributions() public { + _setupContractFunding(); + vm.deal(address(core), ADDITIONAL_ETH_PAYMENT * 350); + vm.prank(address(core)); + (bool success, ) = address(token).call{ + value: ADDITIONAL_ETH_PAYMENT * 350 + }(""); + require(success, "Multi-distribution funding failed"); + + uint256 numDistributions = 100; + Participant[][] memory allParticipants = new Participant[][]( + numDistributions + ); + uint256[] memory distributionIds = new uint256[](numDistributions); + + // Create multiple distributions + for (uint256 i = 0; i < numDistributions; i++) { + ( + Participant[] memory participants, + uint256 totalCapacity + ) = _setupTestParticipants(5, false); + vm.warp(block.timestamp + UPDATE_TIME); + (uint256 distributionId, ) = _createAndValidateDistribution( + participants, + totalCapacity + ); + distributionIds[i] = distributionId; + allParticipants[i] = participants; + } + + // Prepare batch claim arrays + uint256[] memory capacities = new uint256[](numDistributions); + bytes32[][] memory proofs = new bytes32[][](numDistributions); + + for (uint256 i = 0; i < numDistributions; i++) { + Participant memory worker = allParticipants[i][0]; // pick first worker for simplicity + capacities[i] = worker.capacity; + + bytes32[] memory leaves = _generateLeaves( + allParticipants[i], + distributionIds[i] + ); + proofs[i] = _generateMerkleProof(leaves, 0); + } + + // Perform batch claim + vm.prank(allParticipants[0][0].addr); + token.batchClaimMerkleRewards(distributionIds, capacities, proofs); + + // Verify claims + for (uint256 i = 0; i < numDistributions; i++) { + Participant memory worker = allParticipants[i][0]; + assertTrue( + token.s_claimed(distributionIds[i], worker.addr), + "Worker claim not recorded" + ); + } + + console.log("Batch claim test passed!"); + } + + // ============= Additional Edge Case Tests ============= + + function testLockTokensWithExactBalance() public { + // Test locking when user has exactly the required amount + vm.prank(validator1); + token.transfer(worker1, USER_LOCK_AMOUNT); + + vm.prank(address(core)); + token.lockTokens(worker1, false); + + assertEq(token.balanceOf(worker1), 0); + } + + function testLockTokensJustUnderRequiredAmount() public { + // Test with 1 wei less than required + vm.prank(validator1); + token.transfer(worker1, USER_LOCK_AMOUNT - 1); + + vm.expectRevert(SmartnodesERC20.Token__InsufficientBalance.selector); + vm.prank(address(core)); + token.lockTokens(worker1, false); + } + + // ============= Token Unlocking Edge Cases ============= + + function testUnlockAtExactTimeBoundary() public { + vm.prank(address(core)); + token.unlockTokens(validator1); + + // Fast forward to exactly the unlock time + vm.warp(block.timestamp + UNLOCK_PERIOD - 1); + + // Should still revert as we need > unlock period + vm.expectRevert(SmartnodesERC20.Token__UnlockPending.selector); + vm.prank(address(core)); + token.unlockTokens(validator1); + } + + function testUnlockOneSecondAfterBoundary() public { + vm.prank(address(core)); + token.unlockTokens(validator1); + + vm.warp(block.timestamp + UNLOCK_PERIOD + 1); + + // Should succeed now + vm.prank(address(core)); + token.unlockTokens(validator1); + } + + function testMultipleUnlockInitiations() public { + vm.startPrank(address(core)); + token.unlockTokens(validator1); + + // Try to initiate unlock again - should revert + vm.expectRevert(SmartnodesERC20.Token__UnlockPending.selector); + token.unlockTokens(validator1); + vm.stopPrank(); + } + + // ============= Escrow Edge Cases ============= + + function testEscrowZeroAmount() public { + vm.expectRevert(); // Should revert on zero amount + vm.prank(address(core)); + token.escrowPayment(user1, 0); + } + + function testEscrowMoreThanBalance() public { + uint256 userBalance = token.balanceOf(user1); + + vm.expectRevert(SmartnodesERC20.Token__InsufficientBalance.selector); + vm.prank(address(core)); + token.escrowPayment(user1, userBalance + 1); + } + + function testEscrowExactBalance() public { + uint256 amount = 1000e18; + vm.prank(validator1); + token.transfer(worker1, amount); + + vm.prank(address(core)); + token.escrowPayment(worker1, amount); + + assertEq(token.balanceOf(worker1), 0); + } + + function testMultipleEscrowsSameUser() public { + uint256 amount1 = 500e18; + uint256 amount2 = 300e18; + + vm.prank(validator1); + token.transfer(worker1, amount1 + amount2); + + vm.startPrank(address(core)); + token.escrowPayment(worker1, amount1); + token.escrowPayment(worker1, amount2); + vm.stopPrank(); + + SmartnodesERC20.PaymentAmounts memory escrowed = token + .getEscrowedPayments(worker1); + assertEq(escrowed.sno, amount1 + amount2); + } + + function testEscrowEthWithWrongValue() public { + uint256 paymentAmount = 1 ether; + vm.deal(address(core), paymentAmount * 2); + + // Send less ETH than specified in parameter + vm.prank(address(core)); + vm.expectRevert(); + token.escrowEthPayment{value: paymentAmount / 2}(user1, paymentAmount); + } + + function testReleaseMoreThanEscrowed() public { + uint256 escrowAmount = 500e18; + uint256 releaseAmount = 600e18; + + // Setup escrow + vm.prank(validator1); + token.transfer(user1, escrowAmount); + vm.prank(address(core)); + token.escrowPayment(user1, escrowAmount); + + // Try to release more than escrowed + vm.expectRevert(); + vm.prank(address(core)); + token.releaseEscrowedPayment(user1, releaseAmount); + } + + // ============= Merkle Distribution Edge Cases ============= + + function testCreateDistributionWithZeroCapacity() public { + _setupContractFunding(); + + address[] memory validators = new address[](1); + validators[0] = validator1; + + SmartnodesERC20.PaymentAmounts memory payments = SmartnodesERC20 + .PaymentAmounts({sno: 0, eth: 0}); + + vm.prank(address(core)); + token.createMerkleDistribution( + bytes32(0), + 0, + validators, + payments, + validator1 + ); + } + + function testCreateDistributionWithEmptyValidators() public { + _setupContractFunding(); + + address[] memory validators = new address[](0); + + SmartnodesERC20.PaymentAmounts memory payments = SmartnodesERC20 + .PaymentAmounts({ + sno: uint128(ADDITIONAL_SNO_PAYMENT), + eth: uint128(ADDITIONAL_ETH_PAYMENT) + }); + + vm.expectRevert(); // Should revert with empty validators array + vm.prank(address(core)); + token.createMerkleDistribution( + bytes32(0), + 1000, + validators, + payments, + validator1 + ); + } + + function testClaimWithInvalidProof() public { + _setupContractFunding(); + ( + Participant[] memory participants, + uint256 totalCapacity + ) = _setupTestParticipants(3, false); + (uint256 distributionId, ) = _createAndValidateDistribution( + participants, + totalCapacity + ); + + // Generate invalid proof (empty proof) + bytes32[] memory invalidProof = new bytes32[](0); + + vm.expectRevert(); + vm.prank(participants[0].addr); + token.claimMerkleRewards( + distributionId, + participants[0].capacity, + invalidProof + ); + } + + function testClaimWithWrongCapacity() public { + _setupContractFunding(); + ( + Participant[] memory participants, + uint256 totalCapacity + ) = _setupTestParticipants(3, false); + (uint256 distributionId, ) = _createAndValidateDistribution( + participants, + totalCapacity + ); + + bytes32[] memory leaves = _generateLeaves(participants, distributionId); + bytes32[] memory proof = _generateMerkleProof(leaves, 0); + + // Use wrong capacity + vm.expectRevert(); + vm.prank(participants[0].addr); + token.claimMerkleRewards( + distributionId, + participants[0].capacity + 1, + proof + ); + } + + function testDoubleClaimSameDistribution() public { + _setupContractFunding(); + ( + Participant[] memory participants, + uint256 totalCapacity + ) = _setupTestParticipants(1, false); + (uint256 distributionId, ) = _createAndValidateDistribution( + participants, + totalCapacity + ); + + bytes32[] memory leaves = _generateLeaves(participants, distributionId); + bytes32[] memory proof = _generateMerkleProof(leaves, 0); + + // First claim should succeed + vm.prank(participants[0].addr); + token.claimMerkleRewards( + distributionId, + participants[0].capacity, + proof + ); + + // Second claim should revert + vm.expectRevert(SmartnodesERC20.Token__RewardsAlreadyClaimed.selector); + vm.prank(participants[0].addr); + token.claimMerkleRewards( + distributionId, + participants[0].capacity, + proof + ); + } + + function testClaimFromNonexistentDistribution() public { + bytes32[] memory proof = new bytes32[](1); + proof[0] = bytes32(0); + + vm.expectRevert(); + vm.prank(user1); + token.claimMerkleRewards(999, 1000, proof); + } + + // ============= Batch Operations Edge Cases ============= + + function testBatchClaimWithMismatchedArrays() public { + uint256[] memory distributionIds = new uint256[](2); + uint256[] memory capacities = new uint256[](1); // Different length + bytes32[][] memory proofs = new bytes32[][](2); + + vm.expectRevert(); + vm.prank(user1); + token.batchClaimMerkleRewards(distributionIds, capacities, proofs); + } + + // ============= Emission Rate Edge Cases ============= + + function testEmissionRateAtExactYearBoundaries() public { + uint256 start = token.i_deploymentTimestamp(); + + uint256 rateAtBeginning = token.getEmissionRate(); + + vm.warp(start + REWARD_PERIOD - 1); + uint256 rateAtOneYearEnd = token.getEmissionRate(); + assertEq(rateAtOneYearEnd, rateAtBeginning); // still era 0 + + vm.warp(start + REWARD_PERIOD); + uint256 rateAtOneYear = token.getEmissionRate(); // era 1 + + vm.warp(start + 2 * REWARD_PERIOD - 1); + uint256 rateJustBeforeTwoYears = token.getEmissionRate(); + assertEq(rateAtOneYear, rateJustBeforeTwoYears); // still era 1 + + vm.warp(start + 2 * REWARD_PERIOD); + uint256 rateAtTwoYears = token.getEmissionRate(); // era 2 + assertTrue(rateAtTwoYears < rateAtOneYear); + } + + function testEmissionRateNearTailEmission() public { + // Calculate when we reach tail emission + uint256 rate = INITIAL_EMISSION_RATE; + uint256 _years = 0; + + while (rate > TAIL_EMISSION) { + rate = (rate * 3) / 5; + _years++; + } + + // Go to just before tail emission threshold + vm.warp(block.timestamp + (REWARD_PERIOD * (_years - 1))); + assertTrue(token.getEmissionRate() > TAIL_EMISSION); + + // Go to tail emission threshold + vm.warp(block.timestamp + REWARD_PERIOD); + assertEq(token.getEmissionRate(), TAIL_EMISSION); + } + + // ============= Gas Optimization Tests ============= + + function testGasUsageForLargeDistribution() public { + _setupContractFunding(); + ( + Participant[] memory participants, + uint256 totalCapacity + ) = _setupTestParticipants(1000, false); + + uint256 gasStart = gasleft(); + (uint256 distributionId, ) = _createAndValidateDistribution( + participants, + totalCapacity + ); + uint256 gasUsed = gasStart - gasleft(); + + console.log("Gas used for 1000 participant distribution:", gasUsed); + // You can add assertions here based on your gas requirements + } + + function testOverflowProtection() public { + // Test with very large numbers near uint256 max + uint256 largeAmount = type(uint256).max; + + vm.expectRevert(); // Should revert due to overflow/insufficient balance + vm.prank(address(core)); + token.escrowPayment(user1, largeAmount); + } }