diff --git a/contracts/ClaimLifecycle.sol b/contracts/ClaimLifecycle.sol new file mode 100644 index 0000000..5a272b5 --- /dev/null +++ b/contracts/ClaimLifecycle.sol @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +contract ClaimLifecycle { + enum ClaimStatus { Pending, UnderVerification, VerificationEnded, UnderDispute, VerifiedTrue, VerifiedFalse, Inconclusive, Cancelled } + + mapping(uint256 => ClaimStatus) private _status; + mapping(uint256 => uint256) private _verificationWindowEnd; + mapping(uint256 => uint256) private _disputeDeadline; + + error InvalidStateTransition(ClaimStatus current, ClaimStatus requested); + error VerificationDeadlinePassed(); + error VerificationWindowNotEnded(); + error DisputeDeadlinePassed(); + + event ClaimStatusChanged(uint256 indexed claimId, ClaimStatus previousStatus, ClaimStatus newStatus, address indexed actor); + + function _canTransition(ClaimStatus current, ClaimStatus next) internal pure returns (bool) { + if (current == ClaimStatus.Pending) { + return next == ClaimStatus.UnderVerification || next == ClaimStatus.Cancelled; + } + if (current == ClaimStatus.UnderVerification) { + return next == ClaimStatus.VerificationEnded; + } + if (current == ClaimStatus.VerificationEnded) { + return next == ClaimStatus.VerifiedTrue || next == ClaimStatus.VerifiedFalse || next == ClaimStatus.Inconclusive || next == ClaimStatus.UnderDispute; + } + if (current == ClaimStatus.UnderDispute) { + return next == ClaimStatus.VerifiedTrue || next == ClaimStatus.VerifiedFalse || next == ClaimStatus.Inconclusive; + } + return false; + } + + function _validateTransition(ClaimStatus current, ClaimStatus next) internal pure { + if (!_canTransition(current, next)) revert InvalidStateTransition(current, next); + } + + function _changeStatus(uint256 claimId, ClaimStatus newStatus) internal { + ClaimStatus old = _status[claimId]; + _validateTransition(old, newStatus); + _status[claimId] = newStatus; + emit ClaimStatusChanged(claimId, old, newStatus, msg.sender); + } + + function _startVerification(uint256 claimId, uint256 verificationDeadline, uint256 windowEnd) internal { + if (block.timestamp > verificationDeadline) revert VerificationDeadlinePassed(); + _verificationWindowEnd[claimId] = windowEnd; + _changeStatus(claimId, ClaimStatus.UnderVerification); + } + + function _endVerification(uint256 claimId) internal { + if (block.timestamp < _verificationWindowEnd[claimId]) revert VerificationWindowNotEnded(); + _changeStatus(claimId, ClaimStatus.VerificationEnded); + } + + function _markVerifiedTrue(uint256 claimId) internal { + _changeStatus(claimId, ClaimStatus.VerifiedTrue); + } + + function _markVerifiedFalse(uint256 claimId) internal { + _changeStatus(claimId, ClaimStatus.VerifiedFalse); + } + + function _markInconclusive(uint256 claimId) internal { + _changeStatus(claimId, ClaimStatus.Inconclusive); + } + + function _openDispute(uint256 claimId, uint256 deadline) internal { + if (block.timestamp > deadline) revert DisputeDeadlinePassed(); + _disputeDeadline[claimId] = deadline; + _changeStatus(claimId, ClaimStatus.UnderDispute); + } + + function _cancelClaim(uint256 claimId) internal { + _changeStatus(claimId, ClaimStatus.Cancelled); + } + + function getClaimStatus(uint256 claimId) public view returns (ClaimStatus) { + return _status[claimId]; + } + + function isPending(uint256 claimId) public view returns (bool) { + return _status[claimId] == ClaimStatus.Pending; + } + + function isUnderVerification(uint256 claimId) public view returns (bool) { + return _status[claimId] == ClaimStatus.UnderVerification; + } + + function isResolved(uint256 claimId) public view returns (bool) { + ClaimStatus s = _status[claimId]; + return s == ClaimStatus.VerifiedTrue || s == ClaimStatus.VerifiedFalse || s == ClaimStatus.Inconclusive || s == ClaimStatus.Cancelled; + } + + function isDisputed(uint256 claimId) public view returns (bool) { + return _status[claimId] == ClaimStatus.UnderDispute; + } + + function canReceiveVotes(uint256 claimId) public view returns (bool) { + return _status[claimId] == ClaimStatus.UnderVerification; + } + + function canBeCancelled(uint256 claimId) public view returns (bool) { + return _status[claimId] == ClaimStatus.Pending; + } + + function getVerificationWindowEnd(uint256 claimId) public view returns (uint256) { + return _verificationWindowEnd[claimId]; + } + + function getDisputeDeadline(uint256 claimId) public view returns (uint256) { + return _disputeDeadline[claimId]; + } + + uint256[50] private __gap; +} diff --git a/contracts/ReputationDecay.sol b/contracts/ReputationDecay.sol index 82a58ef..b82d78d 100644 --- a/contracts/ReputationDecay.sol +++ b/contracts/ReputationDecay.sol @@ -1,307 +1,188 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.20; -import "@openzeppelin/contracts/access/AccessControl.sol"; - -/** - * @title ReputationDecay - * @dev Manages user reputation with time-based decay for inactive users. - * - * Decay is calculated on-read (view function) for gas efficiency. - * Users experience gradual reputation decay after an inactivity threshold. - * - * Decay Formula: - * epochsInactive = (currentTime - lastActivity) / epochDuration - * effectiveInactiveEpochs = max(0, epochsInactive - inactivityThreshold) - * decayPercent = min(effectiveInactiveEpochs * decayRatePerEpoch, maxDecayPercent) - * effectiveReputation = baseReputation * (100 - decayPercent) / 100 - */ -contract ReputationDecay is AccessControl { - // ============ Roles ============ - - bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE"); - bytes32 public constant ORACLE_ROLE = keccak256("ORACLE_ROLE"); - // ============ Storage ============ - - /// @notice Base reputation scores (before decay calculation) - mapping(address => uint256) public baseReputation; - - /// @notice Last activity timestamp for each user - mapping(address => uint256) public lastActivityTimestamp; +import "./governance/GovernanceOwnable.sol"; +import "./IReputationOracle.sol"; - // ============ Decay Parameters ============ +contract ReputationDecay is GovernanceOwnable, IReputationOracle { + bytes32 public constant ORACLE_ROLE = keccak256("ORACLE_ROLE"); - /// @notice Percentage of reputation lost per epoch (in basis points, e.g., 100 = 1%) - uint256 public decayRatePerEpoch = 100; // 1% + struct ReputationDecayConfig { + uint256 decayInterval; + uint256 decayPercentage; + uint256 minimumReputation; + bool enabled; + } - /// @notice Duration of one epoch in seconds (default: 7 days) - uint256 public epochDuration = 7 days; + struct InactivityTracking { + uint256 lastActiveBlock; + uint256 lastVerificationTimestamp; + uint256 lastSuccessfulVerification; + } - /// @notice Number of epochs before decay starts (default: 4 epochs = 28 days) - uint256 public inactivityThreshold = 4; + mapping(address => uint256) public baseReputation; + mapping(address => uint256) public lastActivityTimestamp; + mapping(address => InactivityTracking) public inactivityTracking; - /// @notice Maximum total decay percentage (in basis points, e.g., 5000 = 50%) - uint256 public maxDecayPercent = 5000; // 50% + ReputationDecayConfig public decayConfig; - /// @notice Basis points constant for percentage calculations uint256 private constant BASIS_POINTS = 10000; + uint256 private constant DEFAULT_MINIMUM_REPUTATION = 1; - // ============ Events ============ - - /// @notice Emitted when a user's base reputation is updated - event ReputationUpdated( - address indexed user, - uint256 oldReputation, - uint256 newReputation, - uint256 timestamp - ); - - /// @notice Emitted when user activity is recorded + event ReputationUpdated(address indexed user, uint256 oldReputation, uint256 newReputation, uint256 timestamp); event ActivityRecorded(address indexed user, uint256 timestamp); + event ReputationDecayed(address indexed verifier, uint256 previousScore, uint256 newScore); + event DecayConfigUpdated(uint256 decayInterval, uint256 decayPercentage, uint256 minimumReputation, bool enabled); - /// @notice Emitted when decay parameters are updated - event DecayParametersUpdated( - uint256 rate, - uint256 epochDuration, - uint256 threshold, - uint256 maxDecay - ); - - // ============ Errors ============ - - error InvalidDecayRate(); - error InvalidEpochDuration(); - error InvalidMaxDecayPercent(); - - // ============ Constructor ============ + error InvalidDecayPercentage(); + error InvalidDecayInterval(); constructor(address initialAdmin) { - require(initialAdmin != address(0), "Invalid admin address"); - _grantRole(DEFAULT_ADMIN_ROLE, initialAdmin); - _grantRole(ADMIN_ROLE, initialAdmin); + _initializeGovernance(address(0), initialAdmin, initialAdmin); _grantRole(ORACLE_ROLE, initialAdmin); - - _setRoleAdmin(ORACLE_ROLE, ADMIN_ROLE); + _setRoleAdmin(ORACLE_ROLE, GOVERNANCE_ADMIN_ROLE); + + decayConfig = ReputationDecayConfig({ + decayInterval: 7 days, + decayPercentage: 100, + minimumReputation: DEFAULT_MINIMUM_REPUTATION, + enabled: true + }); } - // ============ Core Functions ============ - - /** - * @notice Get the effective reputation after applying decay - * @param user The address to query - * @return The current effective reputation (after decay) - */ - function getEffectiveReputation( - address user - ) public view returns (uint256) { + function getEffectiveReputation(address user) public view returns (uint256) { uint256 base = baseReputation[user]; if (base == 0) return 0; + if (!decayConfig.enabled) return base; + uint256 lastActivity = lastActivityTimestamp[user]; - if (lastActivity == 0) { - // No activity recorded, return base reputation - return base; - } + if (lastActivity == 0) return base; - // Calculate epochs since last activity - uint256 timeSinceActivity = block.timestamp - lastActivity; - uint256 epochsInactive = timeSinceActivity / epochDuration; + uint256 intervalsSinceActivity = (block.timestamp - lastActivity) / decayConfig.decayInterval; + if (intervalsSinceActivity == 0) return base; - // Check if within grace period - if (epochsInactive <= inactivityThreshold) { - return base; - } + uint256 totalDecayBps = intervalsSinceActivity * decayConfig.decayPercentage; + if (totalDecayBps > BASIS_POINTS) totalDecayBps = BASIS_POINTS; - // Calculate effective inactive epochs (after grace period) - uint256 effectiveInactiveEpochs = epochsInactive - inactivityThreshold; + uint256 effective = (base * (BASIS_POINTS - totalDecayBps)) / BASIS_POINTS; - // Calculate decay percentage (in basis points) - uint256 decayPercent = effectiveInactiveEpochs * decayRatePerEpoch; - - // Cap at maximum decay - if (decayPercent > maxDecayPercent) { - decayPercent = maxDecayPercent; + if (effective < decayConfig.minimumReputation) { + effective = decayConfig.minimumReputation; } - // Apply decay - uint256 effectiveReputation = (base * (BASIS_POINTS - decayPercent)) / - BASIS_POINTS; + return effective; + } + + function applyDecay(address user) external onlyRole(ORACLE_ROLE) { + uint256 effective = getEffectiveReputation(user); + uint256 base = baseReputation[user]; - return effectiveReputation; + if (effective < base) { + baseReputation[user] = effective; + lastActivityTimestamp[user] = block.timestamp; + inactivityTracking[user] = InactivityTracking({ + lastActiveBlock: block.number, + lastVerificationTimestamp: block.timestamp, + lastSuccessfulVerification: block.timestamp + }); + + emit ReputationDecayed(user, base, effective); + } } - /** - * @notice Record activity for a user (resets decay timer) - * @param user The address to record activity for - */ function recordActivity(address user) external onlyRole(ORACLE_ROLE) { - lastActivityTimestamp[user] = block.timestamp; - emit ActivityRecorded(user, block.timestamp); + _recordActivity(user); + } + + function recordActivityBatch(address[] calldata users) external onlyRole(ORACLE_ROLE) { + for (uint256 i = 0; i < users.length; i++) { + _recordActivity(users[i]); + } } - /** - * @notice Set base reputation for a user (also records activity) - * @param user The address to set reputation for - * @param amount The new base reputation amount - */ function setReputation(address user, uint256 amount) external onlyRole(ORACLE_ROLE) { uint256 oldReputation = baseReputation[user]; baseReputation[user] = amount; - lastActivityTimestamp[user] = block.timestamp; + _recordActivity(user); emit ReputationUpdated(user, oldReputation, amount, block.timestamp); - emit ActivityRecorded(user, block.timestamp); } - /** - * @notice Add to user's base reputation (also records activity) - * @param user The address to add reputation to - * @param amount The amount to add - */ function addReputation(address user, uint256 amount) external onlyRole(ORACLE_ROLE) { uint256 oldReputation = baseReputation[user]; uint256 newReputation = oldReputation + amount; baseReputation[user] = newReputation; - lastActivityTimestamp[user] = block.timestamp; + _recordActivity(user); - emit ReputationUpdated( - user, - oldReputation, - newReputation, - block.timestamp - ); - emit ActivityRecorded(user, block.timestamp); + emit ReputationUpdated(user, oldReputation, newReputation, block.timestamp); } - /** - * @notice Deduct from user's base reputation - * @param user The address to deduct reputation from - * @param amount The amount to deduct - */ function deductReputation(address user, uint256 amount) external onlyRole(ORACLE_ROLE) { uint256 oldReputation = baseReputation[user]; - uint256 newReputation = oldReputation > amount - ? oldReputation - amount - : 0; + uint256 newReputation = oldReputation > amount ? oldReputation - amount : 0; baseReputation[user] = newReputation; - emit ReputationUpdated( - user, - oldReputation, - newReputation, - block.timestamp - ); + emit ReputationUpdated(user, oldReputation, newReputation, block.timestamp); } - // ============ Admin Functions ============ - - /** - * @notice Set the decay rate per epoch - * @param rate The new decay rate in basis points (e.g., 100 = 1%) - */ - function setDecayRatePerEpoch(uint256 rate) external onlyRole(ADMIN_ROLE) { - if (rate > BASIS_POINTS) revert InvalidDecayRate(); - decayRatePerEpoch = rate; - emit DecayParametersUpdated( - rate, - epochDuration, - inactivityThreshold, - maxDecayPercent + function setDecayConfig(ReputationDecayConfig calldata newConfig) external onlyGovernanceOrAdmin { + if (newConfig.decayPercentage > BASIS_POINTS) revert InvalidDecayPercentage(); + if (newConfig.decayInterval == 0) revert InvalidDecayInterval(); + + decayConfig = newConfig; + + emit DecayConfigUpdated( + newConfig.decayInterval, + newConfig.decayPercentage, + newConfig.minimumReputation, + newConfig.enabled ); } - /** - * @notice Set the epoch duration - * @param duration The new epoch duration in seconds - */ - function setEpochDuration(uint256 duration) external onlyRole(ADMIN_ROLE) { - if (duration == 0) revert InvalidEpochDuration(); - epochDuration = duration; - emit DecayParametersUpdated( - decayRatePerEpoch, - duration, - inactivityThreshold, - maxDecayPercent - ); + function calculateDecay(address user) external view returns (uint256 decayAmount) { + uint256 base = baseReputation[user]; + uint256 effective = getEffectiveReputation(user); + if (effective >= base) return 0; + return base - effective; } - /** - * @notice Set the inactivity threshold (grace period in epochs) - * @param threshold The number of epochs before decay starts - */ - function setInactivityThreshold(uint256 threshold) external onlyRole(ADMIN_ROLE) { - inactivityThreshold = threshold; - emit DecayParametersUpdated( - decayRatePerEpoch, - epochDuration, - threshold, - maxDecayPercent - ); + function isDecayRequired(address user) external view returns (bool) { + if (!decayConfig.enabled) return false; + uint256 base = baseReputation[user]; + if (base == 0) return false; + uint256 lastActivity = lastActivityTimestamp[user]; + if (lastActivity == 0) return false; + return (block.timestamp - lastActivity) >= decayConfig.decayInterval; } - /** - * @notice Set the maximum decay percentage - * @param maxDecay The maximum decay in basis points (e.g., 5000 = 50%) - */ - function setMaxDecayPercent(uint256 maxDecay) external onlyRole(ADMIN_ROLE) { - if (maxDecay > BASIS_POINTS) revert InvalidMaxDecayPercent(); - maxDecayPercent = maxDecay; - emit DecayParametersUpdated( - decayRatePerEpoch, - epochDuration, - inactivityThreshold, - maxDecay - ); + function nextDecayTimestamp(address user) external view returns (uint256) { + uint256 lastActivity = lastActivityTimestamp[user]; + if (lastActivity == 0) return type(uint256).max; + return lastActivity + decayConfig.decayInterval; } - // ============ View Functions ============ - - /** - * @notice Get all decay parameters - * @return rate The decay rate per epoch (basis points) - * @return duration The epoch duration (seconds) - * @return threshold The inactivity threshold (epochs) - * @return maxDecay The maximum decay percentage (basis points) - */ - function getDecayParameters() - external - view - returns ( - uint256 rate, - uint256 duration, - uint256 threshold, - uint256 maxDecay - ) - { - return ( - decayRatePerEpoch, - epochDuration, - inactivityThreshold, - maxDecayPercent - ); + function getReputationScore(address user) external view returns (uint256 score) { + return getEffectiveReputation(user); } - /** - * @notice Calculate how much reputation a user would lose at current time - * @param user The address to query - * @return decayAmount The amount of reputation lost to decay - */ - function getDecayAmount( - address user - ) external view returns (uint256 decayAmount) { - uint256 base = baseReputation[user]; - uint256 effective = getEffectiveReputation(user); - return base - effective; + function isActive() external view returns (bool) { + return decayConfig.enabled; } - /** - * @notice Check if a user is currently experiencing decay - * @param user The address to query - * @return isDecaying True if the user's reputation is being reduced by decay - */ - function isUserDecaying( - address user - ) external view returns (bool isDecaying) { - return getEffectiveReputation(user) < baseReputation[user]; + function getLastReputationUpdate(address user) external view returns (uint256 timestamp) { + return lastActivityTimestamp[user]; } + + function _recordActivity(address user) internal { + lastActivityTimestamp[user] = block.timestamp; + inactivityTracking[user] = InactivityTracking({ + lastActiveBlock: block.number, + lastVerificationTimestamp: block.timestamp, + lastSuccessfulVerification: block.timestamp + }); + emit ActivityRecorded(user, block.timestamp); + } + + uint256[50] private __gap; } diff --git a/contracts/TruthBountyWeighted.sol b/contracts/TruthBountyWeighted.sol index 4d2bd21..816e1c1 100644 --- a/contracts/TruthBountyWeighted.sol +++ b/contracts/TruthBountyWeighted.sol @@ -1110,6 +1110,30 @@ contract TruthBountyWeighted is ResolverRoleTimelock, ReentrancyGuard, Pausable, return verifierStakes[verifier]; } + function getClaimVoterCount(uint256 claimId) external view returns (uint256) { + return claimVoters[claimId].length; + } + + function getClaimVoterAt(uint256 claimId, uint256 index) external view returns (address) { + return claimVoters[claimId][index]; + } + + function getVerificationWeight(uint256 claimId, address verifier) external view returns (uint256) { + return votes[claimId][verifier].effectiveStake; + } + + function getVerificationSupport(uint256 claimId, address verifier) external view returns (bool) { + return votes[claimId][verifier].support; + } + + function getClaimVerificationWindowEnd(uint256 claimId) external view returns (uint256) { + return claims[claimId].verificationWindowEnd; + } + + function getClaimSubmitter(uint256 claimId) external view returns (address) { + return claims[claimId].submitter; + } + /** * @notice Preview the effective stake for a user */ diff --git a/contracts/VerificationAggregation.sol b/contracts/VerificationAggregation.sol new file mode 100644 index 0000000..d22ccee --- /dev/null +++ b/contracts/VerificationAggregation.sol @@ -0,0 +1,182 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +enum ClaimOutcome { + VERIFIED_TRUE, + VERIFIED_FALSE, + INCONCLUSIVE +} + +struct AggregationResult { + ClaimOutcome outcome; + uint256 trueWeight; + uint256 falseWeight; + uint256 totalWeight; + uint256 confidence; +} + +interface IVerificationSource { + function getClaimVoterCount(uint256 claimId) external view returns (uint256); + function getClaimVoterAt(uint256 claimId, uint256 index) external view returns (address); + function getVerificationWeight(uint256 claimId, address verifier) external view returns (uint256); + function getVerificationSupport(uint256 claimId, address verifier) external view returns (bool); + function getClaimVerificationWindowEnd(uint256 claimId) external view returns (uint256); + function getClaimSubmitter(uint256 claimId) external view returns (address); +} + +contract VerificationAggregation { + uint256 public constant BASIS_POINTS_DENOMINATOR = 10000; + uint256 public constant MAX_VERIFICATION_COUNT = 200; + + uint256 public minVerificationCount; + uint256 public minTotalStake; + uint256 public minConfidenceBps; + + address public owner; + + IVerificationSource public verificationSource; + + mapping(uint256 => AggregationResult) private _aggregations; + mapping(uint256 => bool) private _aggregated; + + event ClaimAggregated(uint256 indexed claimId, ClaimOutcome outcome, uint256 confidence); + event ThresholdsUpdated(uint256 minVerificationCount, uint256 minTotalStake, uint256 minConfidenceBps); + event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); + + error NotAggregated(uint256 claimId); + error AlreadyAggregated(uint256 claimId); + error InvalidSource(); + error ClaimNotExists(uint256 claimId); + error VerificationWindowOpen(uint256 claimId); + error NotOwner(); + + modifier onlyOwner() { + if (msg.sender != owner) revert NotOwner(); + _; + } + + constructor(address _verificationSource) { + if (_verificationSource == address(0)) revert InvalidSource(); + verificationSource = IVerificationSource(_verificationSource); + owner = msg.sender; + minVerificationCount = 1; + } + + function aggregateClaim(uint256 claimId) external returns (AggregationResult memory) { + if (_aggregated[claimId]) revert AlreadyAggregated(claimId); + + address submitter = verificationSource.getClaimSubmitter(claimId); + if (submitter == address(0)) revert ClaimNotExists(claimId); + + uint256 verificationWindowEnd = verificationSource.getClaimVerificationWindowEnd(claimId); + if (block.timestamp < verificationWindowEnd) revert VerificationWindowOpen(claimId); + + (uint256 trueWeight, uint256 falseWeight, uint256 totalWeight) = _calculateWeights(claimId); + + uint256 voterCount = verificationSource.getClaimVoterCount(claimId); + + if (voterCount < minVerificationCount || totalWeight < minTotalStake) { + AggregationResult memory insufficient = AggregationResult({ + outcome: ClaimOutcome.INCONCLUSIVE, + trueWeight: trueWeight, + falseWeight: falseWeight, + totalWeight: totalWeight, + confidence: 0 + }); + _aggregations[claimId] = insufficient; + _aggregated[claimId] = true; + emit ClaimAggregated(claimId, ClaimOutcome.INCONCLUSIVE, 0); + return insufficient; + } + + uint256 confidence = _calculateConfidence(trueWeight, falseWeight); + ClaimOutcome outcome = _determineOutcome(trueWeight, falseWeight, confidence); + + AggregationResult memory aggregated = AggregationResult({ + outcome: outcome, + trueWeight: trueWeight, + falseWeight: falseWeight, + totalWeight: totalWeight, + confidence: confidence + }); + + _aggregations[claimId] = aggregated; + _aggregated[claimId] = true; + + emit ClaimAggregated(claimId, outcome, confidence); + + return aggregated; + } + + function calculateWeights(uint256 claimId) external view returns (uint256 trueWeight, uint256 falseWeight, uint256 totalWeight) { + return _calculateWeights(claimId); + } + + function calculateConfidence(uint256 trueWeight, uint256 falseWeight) external pure returns (uint256) { + return _calculateConfidence(trueWeight, falseWeight); + } + + function getAggregation(uint256 claimId) external view returns (AggregationResult memory) { + if (!_aggregated[claimId]) revert NotAggregated(claimId); + return _aggregations[claimId]; + } + + function isAggregated(uint256 claimId) external view returns (bool) { + return _aggregated[claimId]; + } + + function setThresholds(uint256 _minVerificationCount, uint256 _minTotalStake, uint256 _minConfidenceBps) external onlyOwner { + minVerificationCount = _minVerificationCount; + minTotalStake = _minTotalStake; + minConfidenceBps = _minConfidenceBps; + emit ThresholdsUpdated(_minVerificationCount, _minTotalStake, _minConfidenceBps); + } + + function transferOwnership(address newOwner) external onlyOwner { + if (newOwner == address(0)) revert InvalidSource(); + address oldOwner = owner; + owner = newOwner; + emit OwnershipTransferred(oldOwner, newOwner); + } + + function _calculateWeights(uint256 claimId) internal view returns (uint256 trueWeight, uint256 falseWeight, uint256 totalWeight) { + uint256 count = verificationSource.getClaimVoterCount(claimId); + + for (uint256 i = 0; i < count; i++) { + address voter = verificationSource.getClaimVoterAt(claimId, i); + bool support = verificationSource.getVerificationSupport(claimId, voter); + uint256 weight = verificationSource.getVerificationWeight(claimId, voter); + + if (support) { + trueWeight += weight; + } else { + falseWeight += weight; + } + } + + totalWeight = trueWeight + falseWeight; + } + + function _calculateConfidence(uint256 trueWeight, uint256 falseWeight) internal pure returns (uint256) { + uint256 totalWeight = trueWeight + falseWeight; + if (totalWeight == 0) return 0; + uint256 winningWeight = trueWeight > falseWeight ? trueWeight : falseWeight; + return (winningWeight * BASIS_POINTS_DENOMINATOR) / totalWeight; + } + + function _determineOutcome(uint256 trueWeight, uint256 falseWeight, uint256 confidence) internal view returns (ClaimOutcome) { + if (trueWeight == falseWeight && trueWeight > 0) { + return ClaimOutcome.INCONCLUSIVE; + } + + if (trueWeight == 0 && falseWeight == 0) { + return ClaimOutcome.INCONCLUSIVE; + } + + if (minConfidenceBps > 0 && confidence < minConfidenceBps) { + return ClaimOutcome.INCONCLUSIVE; + } + + return trueWeight > falseWeight ? ClaimOutcome.VERIFIED_TRUE : ClaimOutcome.VERIFIED_FALSE; + } +} diff --git a/contracts/bootstrap/BootstrapController.sol b/contracts/bootstrap/BootstrapController.sol index 99a9cd1..0ea5a8e 100644 --- a/contracts/bootstrap/BootstrapController.sol +++ b/contracts/bootstrap/BootstrapController.sol @@ -12,6 +12,7 @@ import "../IReputationOracle.sol"; interface ITruthBountyWeighted { function grantRole(bytes32 role, address account) external; function hasRole(bytes32 role, address account) external view returns (bool); + function GOVERNANCE_ROLE() external view returns (bytes32); function bountyToken() external view returns (address); function reputationOracle() external view returns (address); function verificationWindowDuration() external view returns (uint256); @@ -43,6 +44,7 @@ contract BootstrapController is ReentrancyGuard, Pausable, GovernanceOwnable { bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE"); bytes32 public constant DEPLOYER_ROLE = keccak256("DEPLOYER_ROLE"); + bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE"); // ============ Constants ============ @@ -308,7 +310,7 @@ contract BootstrapController is ReentrancyGuard, Pausable, GovernanceOwnable { } } - function _validateConfiguration() internal view { + function _validateConfiguration() internal { BootstrapConfig memory cfg = config; if (cfg.verificationWindowDuration < 1 days || cfg.verificationWindowDuration > 30 days) { diff --git a/contracts/deployment/MigrationManager.sol b/contracts/deployment/MigrationManager.sol index 0ff286b..7fd20a0 100644 --- a/contracts/deployment/MigrationManager.sol +++ b/contracts/deployment/MigrationManager.sol @@ -10,6 +10,7 @@ contract MigrationManager is ReentrancyGuard, Pausable, GovernanceOwnable { bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE"); bytes32 public constant MIGRATOR_ROLE = keccak256("MIGRATOR_ROLE"); bytes32 public constant UPGRADER_ROLE = keccak256("UPGRADER_ROLE"); + bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE"); struct Release { string version; diff --git a/contracts/test/ClaimLifecycleTestWrapper.sol b/contracts/test/ClaimLifecycleTestWrapper.sol new file mode 100644 index 0000000..24b5642 --- /dev/null +++ b/contracts/test/ClaimLifecycleTestWrapper.sol @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import "../ClaimLifecycle.sol"; + +contract ClaimLifecycleTestWrapper is ClaimLifecycle { + function startVerification(uint256 claimId, uint256 verificationDeadline, uint256 windowEnd) external { + _startVerification(claimId, verificationDeadline, windowEnd); + } + + function endVerification(uint256 claimId) external { + _endVerification(claimId); + } + + function markVerifiedTrue(uint256 claimId) external { + _markVerifiedTrue(claimId); + } + + function markVerifiedFalse(uint256 claimId) external { + _markVerifiedFalse(claimId); + } + + function markInconclusive(uint256 claimId) external { + _markInconclusive(claimId); + } + + function openDispute(uint256 claimId, uint256 deadline) external { + _openDispute(claimId, deadline); + } + + function cancelClaim(uint256 claimId) external { + _cancelClaim(claimId); + } + + function canTransition(ClaimStatus current, ClaimStatus next) external pure returns (bool) { + return _canTransition(current, next); + } + + function validateTransition(ClaimStatus current, ClaimStatus next) external pure { + _validateTransition(current, next); + } + + function changeStatus(uint256 claimId, ClaimStatus newStatus) external { + _changeStatus(claimId, newStatus); + } +} diff --git a/hardhat.config.ts b/hardhat.config.ts index f46f70f..70f1b32 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -12,6 +12,7 @@ const config: HardhatUserConfig = { version: "0.8.28", settings: { evmVersion: "cancun", + viaIR: true, optimizer: { enabled: true, runs: 200 diff --git a/package-lock.json b/package-lock.json index c70314a..87b7fac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,8 @@ "dependencies": { "@openzeppelin/contracts": "^5.6.1", "@openzeppelin/contracts-upgradeable": "^5.6.1", - "dotenv": "^16.4.7" + "dotenv": "^16.4.7", + "solc": "^0.8.28" }, "devDependencies": { "@nomicfoundation/hardhat-chai-matchers": "^2.1.2", @@ -3451,7 +3452,6 @@ "version": "1.2.9", "resolved": "https://registry.npmjs.org/command-exists/-/command-exists-1.2.9.tgz", "integrity": "sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w==", - "dev": true, "license": "MIT" }, "node_modules/command-line-args": { @@ -3588,7 +3588,6 @@ "version": "8.3.0", "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", - "dev": true, "license": "MIT", "engines": { "node": ">= 12" @@ -4548,7 +4547,6 @@ "version": "1.16.0", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", - "dev": true, "funding": [ { "type": "individual", @@ -5187,6 +5185,38 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/hardhat/node_modules/solc": { + "version": "0.8.26", + "resolved": "https://registry.npmjs.org/solc/-/solc-0.8.26.tgz", + "integrity": "sha512-yiPQNVf5rBFHwN6SIf3TUUvVAFKcQqmSUFeq+fb6pNRCo0ZCgpYOZDi3BVoezCPIAcKrVYd/qXlBLUP9wVrZ9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "command-exists": "^1.2.8", + "commander": "^8.1.0", + "follow-redirects": "^1.12.1", + "js-sha3": "0.8.0", + "memorystream": "^0.3.1", + "semver": "^5.5.0", + "tmp": "0.0.33" + }, + "bin": { + "solcjs": "solc.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/hardhat/node_modules/solc/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, "node_modules/hardhat/node_modules/universalify": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", @@ -5721,7 +5751,6 @@ "version": "0.8.0", "resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz", "integrity": "sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q==", - "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -5973,7 +6002,6 @@ "version": "0.3.1", "resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz", "integrity": "sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==", - "dev": true, "engines": { "node": ">= 0.10.0" } @@ -6486,7 +6514,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -7511,10 +7538,9 @@ } }, "node_modules/solc": { - "version": "0.8.26", - "resolved": "https://registry.npmjs.org/solc/-/solc-0.8.26.tgz", - "integrity": "sha512-yiPQNVf5rBFHwN6SIf3TUUvVAFKcQqmSUFeq+fb6pNRCo0ZCgpYOZDi3BVoezCPIAcKrVYd/qXlBLUP9wVrZ9g==", - "dev": true, + "version": "0.8.28", + "resolved": "https://registry.npmjs.org/solc/-/solc-0.8.28.tgz", + "integrity": "sha512-AFCiJ+b4RosyyNhnfdVH4ZR1+TxiL91iluPjw0EJslIu4LXGM9NYqi2z5y8TqochC4tcH9QsHfwWhOIC9jPDKA==", "license": "MIT", "dependencies": { "command-exists": "^1.2.8", @@ -7536,7 +7562,6 @@ "version": "5.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver" @@ -8055,7 +8080,6 @@ "version": "0.0.33", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", - "dev": true, "license": "MIT", "dependencies": { "os-tmpdir": "~1.0.2" diff --git a/package.json b/package.json index 7dc2cf8..113773e 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "dependencies": { "@openzeppelin/contracts": "^5.6.1", "@openzeppelin/contracts-upgradeable": "^5.6.1", - "dotenv": "^16.4.7" + "dotenv": "^16.4.7", + "solc": "^0.8.28" } } diff --git a/test/ClaimLifecycle.ts b/test/ClaimLifecycle.ts new file mode 100644 index 0000000..9c71fb2 --- /dev/null +++ b/test/ClaimLifecycle.ts @@ -0,0 +1,458 @@ +import { expect } from "chai"; +import { ethers } from "hardhat"; +import { loadFixture, time } from "@nomicfoundation/hardhat-network-helpers"; + +describe("ClaimLifecycle", function () { + async function deployLifecycleFixture() { + const [deployer, other] = await ethers.getSigners(); + const Wrapper = await ethers.getContractFactory("ClaimLifecycleTestWrapper"); + const lifecycle = await Wrapper.deploy(); + return { lifecycle, deployer, other }; + } + + function statusEnum() { + return { + Pending: 0, + UnderVerification: 1, + VerificationEnded: 2, + UnderDispute: 3, + VerifiedTrue: 4, + VerifiedFalse: 5, + Inconclusive: 6, + Cancelled: 7, + }; + } + + describe("_canTransition - Valid Transitions", function () { + it("should allow Pending → UnderVerification", async function () { + const { lifecycle } = await loadFixture(deployLifecycleFixture); + const S = statusEnum(); + expect(await lifecycle.canTransition(S.Pending, S.UnderVerification)).to.be.true; + }); + + it("should allow Pending → Cancelled", async function () { + const { lifecycle } = await loadFixture(deployLifecycleFixture); + const S = statusEnum(); + expect(await lifecycle.canTransition(S.Pending, S.Cancelled)).to.be.true; + }); + + it("should allow UnderVerification → VerificationEnded", async function () { + const { lifecycle } = await loadFixture(deployLifecycleFixture); + const S = statusEnum(); + expect(await lifecycle.canTransition(S.UnderVerification, S.VerificationEnded)).to.be.true; + }); + + it("should allow VerificationEnded → VerifiedTrue", async function () { + const { lifecycle } = await loadFixture(deployLifecycleFixture); + const S = statusEnum(); + expect(await lifecycle.canTransition(S.VerificationEnded, S.VerifiedTrue)).to.be.true; + }); + + it("should allow VerificationEnded → VerifiedFalse", async function () { + const { lifecycle } = await loadFixture(deployLifecycleFixture); + const S = statusEnum(); + expect(await lifecycle.canTransition(S.VerificationEnded, S.VerifiedFalse)).to.be.true; + }); + + it("should allow VerificationEnded → Inconclusive", async function () { + const { lifecycle } = await loadFixture(deployLifecycleFixture); + const S = statusEnum(); + expect(await lifecycle.canTransition(S.VerificationEnded, S.Inconclusive)).to.be.true; + }); + + it("should allow VerificationEnded → UnderDispute", async function () { + const { lifecycle } = await loadFixture(deployLifecycleFixture); + const S = statusEnum(); + expect(await lifecycle.canTransition(S.VerificationEnded, S.UnderDispute)).to.be.true; + }); + + it("should allow UnderDispute → VerifiedTrue", async function () { + const { lifecycle } = await loadFixture(deployLifecycleFixture); + const S = statusEnum(); + expect(await lifecycle.canTransition(S.UnderDispute, S.VerifiedTrue)).to.be.true; + }); + + it("should allow UnderDispute → VerifiedFalse", async function () { + const { lifecycle } = await loadFixture(deployLifecycleFixture); + const S = statusEnum(); + expect(await lifecycle.canTransition(S.UnderDispute, S.VerifiedFalse)).to.be.true; + }); + + it("should allow UnderDispute → Inconclusive", async function () { + const { lifecycle } = await loadFixture(deployLifecycleFixture); + const S = statusEnum(); + expect(await lifecycle.canTransition(S.UnderDispute, S.Inconclusive)).to.be.true; + }); + }); + + describe("_canTransition - Invalid Transitions", function () { + it("should reject Pending → VerifiedTrue", async function () { + const { lifecycle } = await loadFixture(deployLifecycleFixture); + const S = statusEnum(); + expect(await lifecycle.canTransition(S.Pending, S.VerifiedTrue)).to.be.false; + }); + + it("should reject Pending → VerifiedFalse", async function () { + const { lifecycle } = await loadFixture(deployLifecycleFixture); + const S = statusEnum(); + expect(await lifecycle.canTransition(S.Pending, S.VerifiedFalse)).to.be.false; + }); + + it("should reject Pending → UnderDispute", async function () { + const { lifecycle } = await loadFixture(deployLifecycleFixture); + const S = statusEnum(); + expect(await lifecycle.canTransition(S.Pending, S.UnderDispute)).to.be.false; + }); + + it("should reject Pending → VerificationEnded", async function () { + const { lifecycle } = await loadFixture(deployLifecycleFixture); + const S = statusEnum(); + expect(await lifecycle.canTransition(S.Pending, S.VerificationEnded)).to.be.false; + }); + + it("should reject Pending → Inconclusive", async function () { + const { lifecycle } = await loadFixture(deployLifecycleFixture); + const S = statusEnum(); + expect(await lifecycle.canTransition(S.Pending, S.Inconclusive)).to.be.false; + }); + + it("should reject VerifiedTrue → any state", async function () { + const { lifecycle } = await loadFixture(deployLifecycleFixture); + const S = statusEnum(); + expect(await lifecycle.canTransition(S.VerifiedTrue, S.Pending)).to.be.false; + expect(await lifecycle.canTransition(S.VerifiedTrue, S.UnderVerification)).to.be.false; + expect(await lifecycle.canTransition(S.VerifiedTrue, S.VerificationEnded)).to.be.false; + expect(await lifecycle.canTransition(S.VerifiedTrue, S.UnderDispute)).to.be.false; + expect(await lifecycle.canTransition(S.VerifiedTrue, S.Inconclusive)).to.be.false; + expect(await lifecycle.canTransition(S.VerifiedTrue, S.Cancelled)).to.be.false; + }); + + it("should reject VerifiedFalse → any state", async function () { + const { lifecycle } = await loadFixture(deployLifecycleFixture); + const S = statusEnum(); + expect(await lifecycle.canTransition(S.VerifiedFalse, S.Pending)).to.be.false; + expect(await lifecycle.canTransition(S.VerifiedFalse, S.UnderVerification)).to.be.false; + }); + + it("should reject Inconclusive → any state", async function () { + const { lifecycle } = await loadFixture(deployLifecycleFixture); + const S = statusEnum(); + expect(await lifecycle.canTransition(S.Inconclusive, S.Pending)).to.be.false; + expect(await lifecycle.canTransition(S.Inconclusive, S.UnderVerification)).to.be.false; + }); + + it("should reject Cancelled → any state", async function () { + const { lifecycle } = await loadFixture(deployLifecycleFixture); + const S = statusEnum(); + for (let s = 0; s <= 7; s++) { + if (s === S.Cancelled) continue; + expect(await lifecycle.canTransition(S.Cancelled, s)).to.be.false; + } + }); + + it("should reject UnderVerification → skip states", async function () { + const { lifecycle } = await loadFixture(deployLifecycleFixture); + const S = statusEnum(); + expect(await lifecycle.canTransition(S.UnderVerification, S.VerifiedTrue)).to.be.false; + expect(await lifecycle.canTransition(S.UnderVerification, S.UnderDispute)).to.be.false; + expect(await lifecycle.canTransition(S.UnderVerification, S.Pending)).to.be.false; + }); + }); + + describe("validateTransition", function () { + it("should revert on invalid transition", async function () { + const { lifecycle } = await loadFixture(deployLifecycleFixture); + const S = statusEnum(); + await expect( + lifecycle.validateTransition(S.Pending, S.VerifiedTrue) + ).to.be.revertedWithCustomError(lifecycle, "InvalidStateTransition"); + }); + + it("should not revert on valid transition", async function () { + const { lifecycle } = await loadFixture(deployLifecycleFixture); + const S = statusEnum(); + await expect( + lifecycle.validateTransition(S.Pending, S.UnderVerification) + ).to.not.be.reverted; + }); + }); + + describe("Full Lifecycle Transitions", function () { + it("should execute Pending → UnderVerification → VerificationEnded → VerifiedTrue", async function () { + const { lifecycle, deployer } = await loadFixture(deployLifecycleFixture); + const S = statusEnum(); + const now = await time.latest(); + const deadline = now + 1000; + const windowEnd = now + 2000; + + await lifecycle.startVerification(1, deadline, windowEnd); + expect(await lifecycle.getClaimStatus(1)).to.equal(S.UnderVerification); + + await time.increaseTo(windowEnd + 1); + await lifecycle.endVerification(1); + expect(await lifecycle.getClaimStatus(1)).to.equal(S.VerificationEnded); + + await lifecycle.markVerifiedTrue(1); + expect(await lifecycle.getClaimStatus(1)).to.equal(S.VerifiedTrue); + }); + + it("should execute Pending → UnderVerification → VerificationEnded → VerifiedFalse", async function () { + const { lifecycle } = await loadFixture(deployLifecycleFixture); + const S = statusEnum(); + const now = await time.latest(); + const deadline = now + 1000; + const windowEnd = now + 2000; + + await lifecycle.startVerification(2, deadline, windowEnd); + await time.increaseTo(windowEnd + 1); + await lifecycle.endVerification(2); + await lifecycle.markVerifiedFalse(2); + expect(await lifecycle.getClaimStatus(2)).to.equal(S.VerifiedFalse); + }); + + it("should execute Pending → UnderVerification → VerificationEnded → Inconclusive", async function () { + const { lifecycle } = await loadFixture(deployLifecycleFixture); + const S = statusEnum(); + const now = await time.latest(); + const deadline = now + 1000; + const windowEnd = now + 2000; + + await lifecycle.startVerification(3, deadline, windowEnd); + await time.increaseTo(windowEnd + 1); + await lifecycle.endVerification(3); + await lifecycle.markInconclusive(3); + expect(await lifecycle.getClaimStatus(3)).to.equal(S.Inconclusive); + }); + + it("should execute Pending → UnderVerification → VerificationEnded → UnderDispute → VerifiedTrue", async function () { + const { lifecycle } = await loadFixture(deployLifecycleFixture); + const S = statusEnum(); + const now = await time.latest(); + const deadline = now + 1000; + const windowEnd = now + 2000; + + await lifecycle.startVerification(4, deadline, windowEnd); + await time.increaseTo(windowEnd + 1); + await lifecycle.endVerification(4); + + const disputeDeadline = (await time.latest()) + 1000; + await lifecycle.openDispute(4, disputeDeadline); + expect(await lifecycle.getClaimStatus(4)).to.equal(S.UnderDispute); + + await lifecycle.markVerifiedTrue(4); + expect(await lifecycle.getClaimStatus(4)).to.equal(S.VerifiedTrue); + }); + + it("should execute Pending → Cancelled", async function () { + const { lifecycle } = await loadFixture(deployLifecycleFixture); + const S = statusEnum(); + await lifecycle.cancelClaim(5); + expect(await lifecycle.getClaimStatus(5)).to.equal(S.Cancelled); + }); + }); + + describe("Event Emission", function () { + it("should emit ClaimStatusChanged on startVerification", async function () { + const { lifecycle, deployer } = await loadFixture(deployLifecycleFixture); + const now = await time.latest(); + const S = statusEnum(); + await expect(lifecycle.startVerification(10, now + 1000, now + 2000)) + .to.emit(lifecycle, "ClaimStatusChanged") + .withArgs(10, S.Pending, S.UnderVerification, deployer.address); + }); + + it("should emit ClaimStatusChanged on endVerification", async function () { + const { lifecycle, deployer } = await loadFixture(deployLifecycleFixture); + const now = await time.latest(); + const windowEnd = now + 10; + await lifecycle.startVerification(11, now + 1000, windowEnd); + + await time.increaseTo(windowEnd + 1); + const S = statusEnum(); + await expect(lifecycle.endVerification(11)) + .to.emit(lifecycle, "ClaimStatusChanged") + .withArgs(11, S.UnderVerification, S.VerificationEnded, deployer.address); + }); + + it("should emit ClaimStatusChanged on markVerifiedTrue", async function () { + const { lifecycle, deployer } = await loadFixture(deployLifecycleFixture); + const now = await time.latest(); + await lifecycle.startVerification(12, now + 1000, now + 10); + await time.increaseTo(now + 11); + await lifecycle.endVerification(12); + const S = statusEnum(); + await expect(lifecycle.markVerifiedTrue(12)) + .to.emit(lifecycle, "ClaimStatusChanged") + .withArgs(12, S.VerificationEnded, S.VerifiedTrue, deployer.address); + }); + + it("should emit ClaimStatusChanged on cancelClaim", async function () { + const { lifecycle, deployer } = await loadFixture(deployLifecycleFixture); + const S = statusEnum(); + await expect(lifecycle.cancelClaim(13)) + .to.emit(lifecycle, "ClaimStatusChanged") + .withArgs(13, S.Pending, S.Cancelled, deployer.address); + }); + + it("should emit ClaimStatusChanged on openDispute", async function () { + const { lifecycle, deployer } = await loadFixture(deployLifecycleFixture); + const now = await time.latest(); + await lifecycle.startVerification(14, now + 1000, now + 10); + await time.increaseTo(now + 11); + await lifecycle.endVerification(14); + const S = statusEnum(); + await expect(lifecycle.openDispute(14, now + 2000)) + .to.emit(lifecycle, "ClaimStatusChanged") + .withArgs(14, S.VerificationEnded, S.UnderDispute, deployer.address); + }); + }); + + describe("Time-Based Validation", function () { + it("should reject startVerification after deadline", async function () { + const { lifecycle } = await loadFixture(deployLifecycleFixture); + const now = await time.latest(); + const deadline = now - 1; + await expect( + lifecycle.startVerification(20, deadline, now + 1000) + ).to.be.revertedWithCustomError(lifecycle, "VerificationDeadlinePassed"); + }); + + it("should reject endVerification before window ends", async function () { + const { lifecycle } = await loadFixture(deployLifecycleFixture); + const now = await time.latest(); + const windowEnd = now + 1000; + await lifecycle.startVerification(21, now + 2000, windowEnd); + await expect( + lifecycle.endVerification(21) + ).to.be.revertedWithCustomError(lifecycle, "VerificationWindowNotEnded"); + }); + + it("should reject openDispute after deadline", async function () { + const { lifecycle } = await loadFixture(deployLifecycleFixture); + const now = await time.latest(); + await lifecycle.startVerification(22, now + 1000, now + 10); + await time.increaseTo(now + 11); + await lifecycle.endVerification(22); + await expect( + lifecycle.openDispute(22, now - 1) + ).to.be.revertedWithCustomError(lifecycle, "DisputeDeadlinePassed"); + }); + }); + + describe("View Helpers", function () { + it("should return correct status from getClaimStatus", async function () { + const { lifecycle } = await loadFixture(deployLifecycleFixture); + const S = statusEnum(); + expect(await lifecycle.getClaimStatus(30)).to.equal(S.Pending); + }); + + it("isPending should return true for Pending", async function () { + const { lifecycle } = await loadFixture(deployLifecycleFixture); + expect(await lifecycle.isPending(31)).to.be.true; + }); + + it("isUnderVerification should return true for UnderVerification", async function () { + const { lifecycle } = await loadFixture(deployLifecycleFixture); + const now = await time.latest(); + await lifecycle.startVerification(32, now + 1000, now + 2000); + expect(await lifecycle.isUnderVerification(32)).to.be.true; + }); + + it("isResolved should return true for resolved states", async function () { + const { lifecycle } = await loadFixture(deployLifecycleFixture); + const now = await time.latest(); + + await lifecycle.cancelClaim(33); + expect(await lifecycle.isResolved(33)).to.be.true; + }); + + it("isDisputed should return true for UnderDispute", async function () { + const { lifecycle } = await loadFixture(deployLifecycleFixture); + const now = await time.latest(); + await lifecycle.startVerification(34, now + 1000, now + 10); + await time.increaseTo(now + 11); + await lifecycle.endVerification(34); + await lifecycle.openDispute(34, now + 2000); + expect(await lifecycle.isDisputed(34)).to.be.true; + }); + + it("canReceiveVotes should return true only during verification", async function () { + const { lifecycle } = await loadFixture(deployLifecycleFixture); + const now = await time.latest(); + + expect(await lifecycle.canReceiveVotes(35)).to.be.false; + + await lifecycle.startVerification(35, now + 1000, now + 2000); + expect(await lifecycle.canReceiveVotes(35)).to.be.true; + + await time.increaseTo(now + 2001); + await lifecycle.endVerification(35); + expect(await lifecycle.canReceiveVotes(35)).to.be.false; + }); + + it("canBeCancelled should return true only when Pending", async function () { + const { lifecycle } = await loadFixture(deployLifecycleFixture); + expect(await lifecycle.canBeCancelled(36)).to.be.true; + + const now = await time.latest(); + await lifecycle.startVerification(36, now + 1000, now + 2000); + expect(await lifecycle.canBeCancelled(36)).to.be.false; + }); + + it("getVerificationWindowEnd should return stored value", async function () { + const { lifecycle } = await loadFixture(deployLifecycleFixture); + const now = await time.latest(); + const windowEnd = now + 5000; + await lifecycle.startVerification(37, now + 1000, windowEnd); + expect(await lifecycle.getVerificationWindowEnd(37)).to.equal(windowEnd); + }); + + it("getDisputeDeadline should return stored value", async function () { + const { lifecycle } = await loadFixture(deployLifecycleFixture); + const now = await time.latest(); + await lifecycle.startVerification(38, now + 1000, now + 10); + await time.increaseTo(now + 11); + await lifecycle.endVerification(38); + await lifecycle.openDispute(38, now + 3000); + expect(await lifecycle.getDisputeDeadline(38)).to.equal(now + 3000); + }); + }); + + describe("Gas Benchmarks", function () { + it("should measure startVerification gas", async function () { + const { lifecycle } = await loadFixture(deployLifecycleFixture); + const now = await time.latest(); + const tx = await lifecycle.startVerification(50, now + 1000, now + 2000); + const receipt = await tx.wait(); + console.log(` startVerification gas: ${receipt.gasUsed}`); + }); + + it("should measure endVerification gas", async function () { + const { lifecycle } = await loadFixture(deployLifecycleFixture); + const now = await time.latest(); + await lifecycle.startVerification(51, now + 1000, now + 10); + await time.increaseTo(now + 11); + const tx = await lifecycle.endVerification(51); + const receipt = await tx.wait(); + console.log(` endVerification gas: ${receipt.gasUsed}`); + }); + + it("should measure markVerifiedTrue gas", async function () { + const { lifecycle } = await loadFixture(deployLifecycleFixture); + const now = await time.latest(); + await lifecycle.startVerification(52, now + 1000, now + 10); + await time.increaseTo(now + 11); + await lifecycle.endVerification(52); + const tx = await lifecycle.markVerifiedTrue(52); + const receipt = await tx.wait(); + console.log(` markVerifiedTrue gas: ${receipt.gasUsed}`); + }); + + it("should measure cancelClaim gas", async function () { + const { lifecycle } = await loadFixture(deployLifecycleFixture); + const tx = await lifecycle.cancelClaim(53); + const receipt = await tx.wait(); + console.log(` cancelClaim gas: ${receipt.gasUsed}`); + }); + }); +}); diff --git a/test/RBAC.test.ts b/test/RBAC.test.ts index f492a4d..0110b33 100644 --- a/test/RBAC.test.ts +++ b/test/RBAC.test.ts @@ -81,10 +81,14 @@ describe("Unified RBAC System", function () { .to.be.revertedWithCustomError(decay, "AccessControlUnauthorizedAccount"); }); - it("Should allow ADMIN_ROLE to set decay rate", async function () { + it("Should allow ADMIN_ROLE to set decay config", async function () { const { decay, admin } = await loadFixture(deployRBACFixture); - await expect(decay.connect(admin).setDecayRatePerEpoch(200)) - .to.emit(decay, "DecayParametersUpdated"); + await expect(decay.connect(admin).setDecayConfig({ + decayInterval: 7 * 86400, + decayPercentage: 200, + minimumReputation: 1, + enabled: true + })).to.emit(decay, "DecayConfigUpdated"); }); }); diff --git a/test/ReputationDecay.ts b/test/ReputationDecay.ts index 74136a8..d6972b3 100644 --- a/test/ReputationDecay.ts +++ b/test/ReputationDecay.ts @@ -6,45 +6,42 @@ import { expect } from "chai"; import hre from "hardhat"; describe("ReputationDecay", function () { - // Constants matching contract defaults const ONE_DAY = 24 * 60 * 60; const ONE_WEEK = 7 * ONE_DAY; - const FOUR_WEEKS = 4 * ONE_WEEK; const BASIS_POINTS = 10000; async function deployReputationDecayFixture() { - const [owner, user1, user2, otherAccount] = await hre.ethers.getSigners(); + const [owner, user1, user2, otherAccount, oracle] = await hre.ethers.getSigners(); const ReputationDecay = await hre.ethers.getContractFactory("ReputationDecay"); const reputationDecay = await ReputationDecay.deploy(owner.address); - return { reputationDecay, owner, user1, user2, otherAccount }; + const ORACLE_ROLE = await reputationDecay.ORACLE_ROLE(); + await reputationDecay.grantRole(ORACLE_ROLE, oracle.address); + + return { reputationDecay, owner, user1, user2, otherAccount, oracle, ORACLE_ROLE }; } describe("Deployment", function () { it("Should set the correct admin", async function () { const { reputationDecay, owner } = await loadFixture(deployReputationDecayFixture); - const ADMIN_ROLE = await reputationDecay.ADMIN_ROLE(); - expect(await reputationDecay.hasRole(ADMIN_ROLE, owner.address)).to.be.true; + expect(await reputationDecay.hasRole(await reputationDecay.DEFAULT_ADMIN_ROLE(), owner.address)).to.be.true; }); - it("Should initialize with default decay parameters", async function () { + it("Should initialize with default decay config", async function () { const { reputationDecay } = await loadFixture(deployReputationDecayFixture); - expect(await reputationDecay.decayRatePerEpoch()).to.equal(100); // 1% - expect(await reputationDecay.epochDuration()).to.equal(ONE_WEEK); - expect(await reputationDecay.inactivityThreshold()).to.equal(4); - expect(await reputationDecay.maxDecayPercent()).to.equal(5000); // 50% + const config = await reputationDecay.decayConfig(); + expect(config.decayInterval).to.equal(ONE_WEEK); + expect(config.decayPercentage).to.equal(100); + expect(config.minimumReputation).to.equal(1); + expect(config.enabled).to.equal(true); }); - it("Should return correct decay parameters via getter", async function () { - const { reputationDecay } = await loadFixture(deployReputationDecayFixture); - - const [rate, duration, threshold, maxDecay] = await reputationDecay.getDecayParameters(); - expect(rate).to.equal(100); - expect(duration).to.equal(ONE_WEEK); - expect(threshold).to.equal(4); - expect(maxDecay).to.equal(5000); + it("Should set up ORACLE_ROLE correctly", async function () { + const { reputationDecay, owner } = await loadFixture(deployReputationDecayFixture); + const ORACLE_ROLE = await reputationDecay.ORACLE_ROLE(); + expect(await reputationDecay.hasRole(ORACLE_ROLE, owner.address)).to.be.true; }); }); @@ -100,86 +97,94 @@ describe("ReputationDecay", function () { expect(await reputationDecay.lastActivityTimestamp(user1.address)).to.equal(timestamp); }); + it("Should update inactivity tracking when recording activity", async function () { + const { reputationDecay, user1, oracle } = await loadFixture(deployReputationDecayFixture); + + await reputationDecay.connect(oracle).recordActivity(user1.address); + + const tracking = await reputationDecay.inactivityTracking(user1.address); + expect(tracking.lastActiveBlock).to.be.gt(0); + expect(tracking.lastVerificationTimestamp).to.equal(await time.latest()); + expect(tracking.lastSuccessfulVerification).to.equal(await time.latest()); + }); + it("Should record activity and emit event", async function () { - const { reputationDecay, user1 } = await loadFixture(deployReputationDecayFixture); + const { reputationDecay, user1, oracle } = await loadFixture(deployReputationDecayFixture); - await expect(reputationDecay.recordActivity(user1.address)) + await expect(reputationDecay.connect(oracle).recordActivity(user1.address)) .to.emit(reputationDecay, "ActivityRecorded"); }); + + it("Should batch record activity", async function () { + const { reputationDecay, user1, user2, oracle } = await loadFixture(deployReputationDecayFixture); + + await reputationDecay.connect(oracle).recordActivityBatch([user1.address, user2.address]); + + expect(await reputationDecay.lastActivityTimestamp(user1.address)).to.be.gt(0); + expect(await reputationDecay.lastActivityTimestamp(user2.address)).to.be.gt(0); + }); }); describe("Decay Calculation", function () { - it("Should return full reputation within grace period", async function () { + it("Should return full reputation before decay interval", async function () { const { reputationDecay, user1 } = await loadFixture(deployReputationDecayFixture); await reputationDecay.setReputation(user1.address, 1000); - // Move 3 weeks forward (still within 4-week grace period) - await time.increase(3 * ONE_WEEK); - expect(await reputationDecay.getEffectiveReputation(user1.address)).to.equal(1000); }); - it("Should return full reputation exactly at grace period boundary", async function () { + it("Should return full reputation at the moment decay interval starts", async function () { const { reputationDecay, user1 } = await loadFixture(deployReputationDecayFixture); await reputationDecay.setReputation(user1.address, 1000); - // Move exactly 4 weeks forward - await time.increase(FOUR_WEEKS); + await time.increase(ONE_WEEK - 1); expect(await reputationDecay.getEffectiveReputation(user1.address)).to.equal(1000); }); - it("Should apply 1% decay after 5 epochs of inactivity", async function () { + it("Should apply 1% decay after one interval", async function () { const { reputationDecay, user1 } = await loadFixture(deployReputationDecayFixture); await reputationDecay.setReputation(user1.address, 1000); - // Move 5 weeks forward (1 week past grace period = 1% decay) - await time.increase(5 * ONE_WEEK); + await time.increase(ONE_WEEK); - // 1000 * 99% = 990 expect(await reputationDecay.getEffectiveReputation(user1.address)).to.equal(990); }); - it("Should apply 5% decay after 9 epochs of inactivity", async function () { + it("Should apply 5% decay after five intervals", async function () { const { reputationDecay, user1 } = await loadFixture(deployReputationDecayFixture); await reputationDecay.setReputation(user1.address, 1000); - // Move 9 weeks forward (5 weeks past grace period = 5% decay) - await time.increase(9 * ONE_WEEK); + await time.increase(5 * ONE_WEEK); - // 1000 * 95% = 950 expect(await reputationDecay.getEffectiveReputation(user1.address)).to.equal(950); }); - it("Should cap decay at maximum (50%)", async function () { + it("Should cap decay at minimum reputation", async function () { const { reputationDecay, user1 } = await loadFixture(deployReputationDecayFixture); await reputationDecay.setReputation(user1.address, 1000); - // Move 100 weeks forward (way past max decay) - await time.increase(100 * ONE_WEEK); + await time.increase(200 * ONE_WEEK); - // Should be capped at 50% decay = 500 - expect(await reputationDecay.getEffectiveReputation(user1.address)).to.equal(500); + const effective = await reputationDecay.getEffectiveReputation(user1.address); + expect(effective).to.equal(1); }); it("Should reset decay when activity is recorded", async function () { - const { reputationDecay, user1 } = await loadFixture(deployReputationDecayFixture); + const { reputationDecay, user1, oracle } = await loadFixture(deployReputationDecayFixture); await reputationDecay.setReputation(user1.address, 1000); - // Move 6 weeks forward (2% decay) - await time.increase(6 * ONE_WEEK); + await time.increase(2 * ONE_WEEK); expect(await reputationDecay.getEffectiveReputation(user1.address)).to.equal(980); - // Record activity to reset timer - await reputationDecay.recordActivity(user1.address); + await reputationDecay.connect(oracle).recordActivity(user1.address); - // Decay should be reset expect(await reputationDecay.getEffectiveReputation(user1.address)).to.equal(1000); }); @@ -189,29 +194,151 @@ describe("ReputationDecay", function () { expect(await reputationDecay.getEffectiveReputation(user1.address)).to.equal(0); }); - it("Should correctly report decay amount", async function () { + it("Should respect minimum reputation floor", async function () { + const { reputationDecay, user1 } = await loadFixture(deployReputationDecayFixture); + + await reputationDecay.setReputation(user1.address, 10); + + await time.increase(100 * ONE_WEEK); + + expect(await reputationDecay.getEffectiveReputation(user1.address)).to.equal(1); + }); + + it("Should return full reputation when decay is disabled", async function () { const { reputationDecay, user1 } = await loadFixture(deployReputationDecayFixture); await reputationDecay.setReputation(user1.address, 1000); - // Move 5 weeks forward (1% decay) + await reputationDecay.setDecayConfig({ + decayInterval: ONE_WEEK, + decayPercentage: 100, + minimumReputation: 1, + enabled: false + }); + + await time.increase(10 * ONE_WEEK); + + expect(await reputationDecay.getEffectiveReputation(user1.address)).to.equal(1000); + }); + }); + + describe("applyDecay", function () { + it("Should apply decay and emit ReputationDecayed event", async function () { + const { reputationDecay, user1, oracle } = await loadFixture(deployReputationDecayFixture); + + await reputationDecay.setReputation(user1.address, 1000); + await time.increase(5 * ONE_WEEK); + + await expect(reputationDecay.connect(oracle).applyDecay(user1.address)) + .to.emit(reputationDecay, "ReputationDecayed") + .withArgs(user1.address, 1000, 950); + }); + + it("Should update base reputation after applyDecay", async function () { + const { reputationDecay, user1, oracle } = await loadFixture(deployReputationDecayFixture); + + await reputationDecay.setReputation(user1.address, 1000); + await time.increase(5 * ONE_WEEK); + + await reputationDecay.connect(oracle).applyDecay(user1.address); + + expect(await reputationDecay.baseReputation(user1.address)).to.equal(950); + }); + + it("Should not emit event if no decay is needed", async function () { + const { reputationDecay, user1, oracle } = await loadFixture(deployReputationDecayFixture); + + await reputationDecay.setReputation(user1.address, 1000); + + await expect(reputationDecay.connect(oracle).applyDecay(user1.address)) + .to.not.emit(reputationDecay, "ReputationDecayed"); + }); + }); + + describe("View Functions", function () { + it("calculateDecay should return correct amount", async function () { + const { reputationDecay, user1 } = await loadFixture(deployReputationDecayFixture); + + await reputationDecay.setReputation(user1.address, 1000); await time.increase(5 * ONE_WEEK); - expect(await reputationDecay.getDecayAmount(user1.address)).to.equal(10); + expect(await reputationDecay.calculateDecay(user1.address)).to.equal(50); + }); + + it("calculateDecay should return 0 when no decay", async function () { + const { reputationDecay, user1 } = await loadFixture(deployReputationDecayFixture); + + await reputationDecay.setReputation(user1.address, 1000); + + expect(await reputationDecay.calculateDecay(user1.address)).to.equal(0); }); - it("Should correctly report if user is decaying", async function () { + it("isDecayRequired should return true when decay interval has passed", async function () { const { reputationDecay, user1 } = await loadFixture(deployReputationDecayFixture); await reputationDecay.setReputation(user1.address, 1000); + await time.increase(ONE_WEEK); + + expect(await reputationDecay.isDecayRequired(user1.address)).to.equal(true); + }); - // Initially not decaying - expect(await reputationDecay.isUserDecaying(user1.address)).to.equal(false); + it("isDecayRequired should return false when decay is not due", async function () { + const { reputationDecay, user1 } = await loadFixture(deployReputationDecayFixture); - // Move past grace period + await reputationDecay.setReputation(user1.address, 1000); + + expect(await reputationDecay.isDecayRequired(user1.address)).to.equal(false); + }); + + it("nextDecayTimestamp should return correct timestamp", async function () { + const { reputationDecay, user1 } = await loadFixture(deployReputationDecayFixture); + + await reputationDecay.setReputation(user1.address, 1000); + const timestamp = await time.latest(); + + expect(await reputationDecay.nextDecayTimestamp(user1.address)).to.equal(timestamp + ONE_WEEK); + }); + + it("nextDecayTimestamp should return max uint for unknown user", async function () { + const { reputationDecay, user1 } = await loadFixture(deployReputationDecayFixture); + + const max = hre.ethers.MaxUint256; + expect(await reputationDecay.nextDecayTimestamp(user1.address)).to.equal(max); + }); + }); + + describe("IReputationOracle Interface", function () { + it("getReputationScore should return effective reputation", async function () { + const { reputationDecay, user1 } = await loadFixture(deployReputationDecayFixture); + + await reputationDecay.setReputation(user1.address, 1000); await time.increase(5 * ONE_WEEK); - expect(await reputationDecay.isUserDecaying(user1.address)).to.equal(true); + expect(await reputationDecay.getReputationScore(user1.address)).to.equal(950); + }); + + it("isActive should return decay enabled state", async function () { + const { reputationDecay } = await loadFixture(deployReputationDecayFixture); + + expect(await reputationDecay.isActive()).to.equal(true); + + await reputationDecay.setDecayConfig({ + decayInterval: ONE_WEEK, + decayPercentage: 100, + minimumReputation: 1, + enabled: false + }); + + expect(await reputationDecay.isActive()).to.equal(false); + }); + + it("getLastReputationUpdate should return lastActivityTimestamp", async function () { + const { reputationDecay, user1 } = await loadFixture(deployReputationDecayFixture); + + await reputationDecay.setReputation(user1.address, 1000); + const timestamp = await time.latest(); + + expect(await reputationDecay.getLastReputationUpdate(user1.address)).to.equal(timestamp); }); }); @@ -219,102 +346,120 @@ describe("ReputationDecay", function () { it("Should track decay independently for different users", async function () { const { reputationDecay, user1, user2 } = await loadFixture(deployReputationDecayFixture); - // Set reputation for user1 await reputationDecay.setReputation(user1.address, 1000); - // Wait 3 weeks await time.increase(3 * ONE_WEEK); - // Set reputation for user2 await reputationDecay.setReputation(user2.address, 1000); - // Wait 2 more weeks (user1: 5 weeks total, user2: 2 weeks) await time.increase(2 * ONE_WEEK); - // user1 should have 1% decay, user2 should have none - expect(await reputationDecay.getEffectiveReputation(user1.address)).to.equal(990); - expect(await reputationDecay.getEffectiveReputation(user2.address)).to.equal(1000); + expect(await reputationDecay.getEffectiveReputation(user1.address)).to.equal(950); + expect(await reputationDecay.getEffectiveReputation(user2.address)).to.equal(980); }); }); - describe("Admin Functions", function () { - it("Should allow owner to update decay rate", async function () { + describe("Governance & Admin Functions", function () { + it("Should allow admin to update decay config", async function () { const { reputationDecay } = await loadFixture(deployReputationDecayFixture); - await reputationDecay.setDecayRatePerEpoch(200); // 2% - expect(await reputationDecay.decayRatePerEpoch()).to.equal(200); - }); - - it("Should reject decay rate above 100%", async function () { - const { reputationDecay } = await loadFixture(deployReputationDecayFixture); + await reputationDecay.setDecayConfig({ + decayInterval: ONE_DAY, + decayPercentage: 200, + minimumReputation: 5, + enabled: true + }); - await expect(reputationDecay.setDecayRatePerEpoch(10001)) - .to.be.revertedWithCustomError(reputationDecay, "InvalidDecayRate"); + const config = await reputationDecay.decayConfig(); + expect(config.decayInterval).to.equal(ONE_DAY); + expect(config.decayPercentage).to.equal(200); + expect(config.minimumReputation).to.equal(5); + expect(config.enabled).to.equal(true); }); - it("Should allow owner to update epoch duration", async function () { + it("Should reject decay percentage above 100%", async function () { const { reputationDecay } = await loadFixture(deployReputationDecayFixture); - await reputationDecay.setEpochDuration(ONE_DAY); // 1 day - expect(await reputationDecay.epochDuration()).to.equal(ONE_DAY); + await expect(reputationDecay.setDecayConfig({ + decayInterval: ONE_WEEK, + decayPercentage: BASIS_POINTS + 1, + minimumReputation: 1, + enabled: true + })).to.be.revertedWithCustomError(reputationDecay, "InvalidDecayPercentage"); }); - it("Should reject zero epoch duration", async function () { + it("Should reject zero decay interval", async function () { const { reputationDecay } = await loadFixture(deployReputationDecayFixture); - await expect(reputationDecay.setEpochDuration(0)) - .to.be.revertedWithCustomError(reputationDecay, "InvalidEpochDuration"); + await expect(reputationDecay.setDecayConfig({ + decayInterval: 0, + decayPercentage: 100, + minimumReputation: 1, + enabled: true + })).to.be.revertedWithCustomError(reputationDecay, "InvalidDecayInterval"); }); - it("Should allow owner to update inactivity threshold", async function () { + it("Should emit DecayConfigUpdated on config update", async function () { const { reputationDecay } = await loadFixture(deployReputationDecayFixture); - await reputationDecay.setInactivityThreshold(2); - expect(await reputationDecay.inactivityThreshold()).to.equal(2); + await expect(reputationDecay.setDecayConfig({ + decayInterval: ONE_DAY, + decayPercentage: 200, + minimumReputation: 5, + enabled: false + })) + .to.emit(reputationDecay, "DecayConfigUpdated") + .withArgs(ONE_DAY, 200, 5, false); }); - it("Should allow owner to update max decay percent", async function () { - const { reputationDecay } = await loadFixture(deployReputationDecayFixture); - - await reputationDecay.setMaxDecayPercent(7500); // 75% - expect(await reputationDecay.maxDecayPercent()).to.equal(7500); - }); + it("Should allow disabling decay via config", async function () { + const { reputationDecay, user1 } = await loadFixture(deployReputationDecayFixture); - it("Should reject max decay above 100%", async function () { - const { reputationDecay } = await loadFixture(deployReputationDecayFixture); + await reputationDecay.setReputation(user1.address, 1000); - await expect(reputationDecay.setMaxDecayPercent(10001)) - .to.be.revertedWithCustomError(reputationDecay, "InvalidMaxDecayPercent"); - }); + await reputationDecay.setDecayConfig({ + decayInterval: ONE_WEEK, + decayPercentage: 100, + minimumReputation: 1, + enabled: false + }); - it("Should emit DecayParametersUpdated on parameter changes", async function () { - const { reputationDecay } = await loadFixture(deployReputationDecayFixture); + await time.increase(10 * ONE_WEEK); - await expect(reputationDecay.setDecayRatePerEpoch(200)) - .to.emit(reputationDecay, "DecayParametersUpdated") - .withArgs(200, ONE_WEEK, 4, 5000); + expect(await reputationDecay.getEffectiveReputation(user1.address)).to.equal(1000); }); }); describe("Access Control", function () { - it("Should reject non-owner from setting reputation", async function () { + it("Should reject non-oracle from setting reputation", async function () { const { reputationDecay, user1, otherAccount } = await loadFixture(deployReputationDecayFixture); await expect(reputationDecay.connect(otherAccount).setReputation(user1.address, 1000)) .to.be.revertedWithCustomError(reputationDecay, "AccessControlUnauthorizedAccount"); }); - it("Should reject non-owner from recording activity", async function () { + it("Should reject non-oracle from recording activity", async function () { const { reputationDecay, user1, otherAccount } = await loadFixture(deployReputationDecayFixture); await expect(reputationDecay.connect(otherAccount).recordActivity(user1.address)) .to.be.revertedWithCustomError(reputationDecay, "AccessControlUnauthorizedAccount"); }); - it("Should reject non-owner from updating parameters", async function () { + it("Should reject non-admin from updating config", async function () { const { reputationDecay, otherAccount } = await loadFixture(deployReputationDecayFixture); - await expect(reputationDecay.connect(otherAccount).setDecayRatePerEpoch(200)) + await expect(reputationDecay.connect(otherAccount).setDecayConfig({ + decayInterval: ONE_DAY, + decayPercentage: 200, + minimumReputation: 5, + enabled: true + })).to.be.revertedWithCustomError(reputationDecay, "UnauthorizedGovernance"); + }); + + it("Should reject non-oracle from applying decay", async function () { + const { reputationDecay, user1, otherAccount } = await loadFixture(deployReputationDecayFixture); + + await expect(reputationDecay.connect(otherAccount).applyDecay(user1.address)) .to.be.revertedWithCustomError(reputationDecay, "AccessControlUnauthorizedAccount"); }); }); @@ -323,31 +468,55 @@ describe("ReputationDecay", function () { it("Should handle very large reputation values", async function () { const { reputationDecay, user1 } = await loadFixture(deployReputationDecayFixture); - const largeValue = hre.ethers.parseEther("1000000000"); // 1 billion tokens + const largeValue = hre.ethers.parseEther("1000000000"); await reputationDecay.setReputation(user1.address, largeValue); await time.increase(5 * ONE_WEEK); - // 1% decay - const expected = (largeValue * BigInt(99)) / BigInt(100); + const expected = (largeValue * BigInt(95)) / BigInt(100); expect(await reputationDecay.getEffectiveReputation(user1.address)).to.equal(expected); }); it("Should work with custom decay parameters", async function () { const { reputationDecay, user1 } = await loadFixture(deployReputationDecayFixture); - // Set 5% decay per day, 2 day grace, 80% max - await reputationDecay.setDecayRatePerEpoch(500); // 5% - await reputationDecay.setEpochDuration(ONE_DAY); - await reputationDecay.setInactivityThreshold(2); - await reputationDecay.setMaxDecayPercent(8000); // 80% + await reputationDecay.setDecayConfig({ + decayInterval: ONE_DAY, + decayPercentage: 500, + minimumReputation: 1, + enabled: true + }); await reputationDecay.setReputation(user1.address, 1000); - // After 4 days (2 past grace) = 10% decay - await time.increase(4 * ONE_DAY); + await time.increase(3 * ONE_DAY); + + expect(await reputationDecay.getEffectiveReputation(user1.address)).to.equal(850); + }); + + it("Should not decay below custom minimum reputation", async function () { + const { reputationDecay, user1 } = await loadFixture(deployReputationDecayFixture); + + await reputationDecay.setDecayConfig({ + decayInterval: ONE_DAY, + decayPercentage: 2000, + minimumReputation: 100, + enabled: true + }); - expect(await reputationDecay.getEffectiveReputation(user1.address)).to.equal(900); + await reputationDecay.setReputation(user1.address, 1000); + + await time.increase(10 * ONE_DAY); + + expect(await reputationDecay.getEffectiveReputation(user1.address)).to.equal(100); + }); + + it("Should return zero for user with zero base reputation", async function () { + const { reputationDecay, user1 } = await loadFixture(deployReputationDecayFixture); + + expect(await reputationDecay.getEffectiveReputation(user1.address)).to.equal(0); + expect(await reputationDecay.calculateDecay(user1.address)).to.equal(0); + expect(await reputationDecay.isDecayRequired(user1.address)).to.equal(false); }); }); }); diff --git a/test/VerificationAggregation.t.sol b/test/VerificationAggregation.t.sol new file mode 100644 index 0000000..db24a69 --- /dev/null +++ b/test/VerificationAggregation.t.sol @@ -0,0 +1,547 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import "forge-std/Test.sol"; +import "../contracts/VerificationAggregation.sol"; +import "../contracts/TruthBountyWeighted.sol"; +import "../contracts/MockERC20.sol"; +import "../test/mocks/MockReputationOracle.sol"; + +contract VerificationAggregationTest is Test { + TruthBountyWeighted public truthBounty; + VerificationAggregation public aggregator; + MockERC20 public token; + MockReputationOracle public oracle; + + address public admin = address(0x1); + address public submitter = address(0x2); + address public verifier1 = address(0x3); + address public verifier2 = address(0x4); + address public verifier3 = address(0x5); + address public verifier4 = address(0x6); + address public verifier5 = address(0x7); + + uint256 constant MIN_STAKE = 100 ether; + uint256 constant VERIFICATION_WINDOW = 7 days; + uint256 constant CONFIRMATION_DELAY = 1 hours; + + function setUp() public { + token = new MockERC20("Test", "TST", 18); + oracle = new MockReputationOracle(); + + // Mint tokens to everyone + token.mint(admin, 1000000 ether); + token.mint(submitter, 1000000 ether); + token.mint(verifier1, 1000000 ether); + token.mint(verifier2, 1000000 ether); + token.mint(verifier3, 1000000 ether); + token.mint(verifier4, 1000000 ether); + token.mint(verifier5, 1000000 ether); + + // Deploy TruthBountyWeighted + vm.prank(admin); + truthBounty = new TruthBountyWeighted( + address(token), + address(oracle), + admin, + admin + ); + + // Fund the bounty contract with tokens for rewards + vm.prank(admin); + token.transfer(address(truthBounty), 100000 ether); + + // Deploy aggregator + aggregator = new VerificationAggregation(address(truthBounty)); + } + + function _approveAndStake(address verifier, uint256 amount) internal { + vm.prank(verifier); + token.approve(address(truthBounty), type(uint256).max); + vm.prank(verifier); + truthBounty.stake(amount); + } + + function _createClaim() internal returns (uint256) { + vm.prank(submitter); + uint256 claimId = truthBounty.createClaim("QmTestHash"); + return claimId; + } + + function _vote(uint256 claimId, address verifier, bool support, uint256 stakeAmount) internal { + vm.prank(verifier); + truthBounty.vote(claimId, support, stakeAmount); + } + + function _fastForwardWindow() internal { + vm.warp(block.timestamp + VERIFICATION_WINDOW + CONFIRMATION_DELAY + 1); + } + + // ============ Successful Cases ============ + + function testUnanimousTrue() public { + uint256 claimId = _createClaim(); + + _approveAndStake(verifier1, 1000 ether); + _approveAndStake(verifier2, 1000 ether); + + _vote(claimId, verifier1, true, 100 ether); + _vote(claimId, verifier2, true, 100 ether); + + _fastForwardWindow(); + + AggregationResult memory result = aggregator.aggregateClaim(claimId); + assertEq(uint256(result.outcome), uint256(ClaimOutcome.VERIFIED_TRUE)); + assertEq(result.trueWeight, 200 ether); + assertEq(result.falseWeight, 0); + assertEq(result.totalWeight, 200 ether); + assertEq(result.confidence, 10000); + } + + function testUnanimousFalse() public { + uint256 claimId = _createClaim(); + + _approveAndStake(verifier1, 1000 ether); + _approveAndStake(verifier2, 1000 ether); + + _vote(claimId, verifier1, false, 100 ether); + _vote(claimId, verifier2, false, 100 ether); + + _fastForwardWindow(); + + AggregationResult memory result = aggregator.aggregateClaim(claimId); + assertEq(uint256(result.outcome), uint256(ClaimOutcome.VERIFIED_FALSE)); + assertEq(result.trueWeight, 0); + assertEq(result.falseWeight, 200 ether); + assertEq(result.totalWeight, 200 ether); + assertEq(result.confidence, 10000); + } + + function testMixedVerifications() public { + uint256 claimId = _createClaim(); + + _approveAndStake(verifier1, 1000 ether); + _approveAndStake(verifier2, 1000 ether); + _approveAndStake(verifier3, 1000 ether); + + _vote(claimId, verifier1, true, 100 ether); + _vote(claimId, verifier2, true, 100 ether); + _vote(claimId, verifier3, false, 100 ether); + + _fastForwardWindow(); + + AggregationResult memory result = aggregator.aggregateClaim(claimId); + assertEq(uint256(result.outcome), uint256(ClaimOutcome.VERIFIED_TRUE)); + assertEq(result.trueWeight, 200 ether); + assertEq(result.falseWeight, 100 ether); + assertEq(result.totalWeight, 300 ether); + assertEq(result.confidence, 6666); + } + + function testWeightedTrueMajority() public { + uint256 claimId = _createClaim(); + + _approveAndStake(verifier1, 1000 ether); + _approveAndStake(verifier2, 1000 ether); + _approveAndStake(verifier3, 1000 ether); + + oracle.setReputationScore(verifier1, 2 ether); + oracle.setReputationScore(verifier2, 0.5 ether); + oracle.setReputationScore(verifier3, 0.5 ether); + + _vote(claimId, verifier1, true, 100 ether); + _vote(claimId, verifier2, false, 100 ether); + _vote(claimId, verifier3, false, 100 ether); + + _fastForwardWindow(); + + AggregationResult memory result = aggregator.aggregateClaim(claimId); + assertEq(uint256(result.outcome), uint256(ClaimOutcome.VERIFIED_TRUE)); + assertEq(result.trueWeight, 200 ether); + assertEq(result.falseWeight, 100 ether); + assertEq(result.totalWeight, 300 ether); + assertEq(result.confidence, 6666); + } + + function testWeightedFalseMajority() public { + uint256 claimId = _createClaim(); + + _approveAndStake(verifier1, 1000 ether); + _approveAndStake(verifier2, 1000 ether); + _approveAndStake(verifier3, 1000 ether); + + oracle.setReputationScore(verifier1, 0.5 ether); + oracle.setReputationScore(verifier2, 2 ether); + oracle.setReputationScore(verifier3, 2 ether); + + _vote(claimId, verifier1, true, 100 ether); + _vote(claimId, verifier2, false, 100 ether); + _vote(claimId, verifier3, false, 100 ether); + + _fastForwardWindow(); + + AggregationResult memory result = aggregator.aggregateClaim(claimId); + assertEq(uint256(result.outcome), uint256(ClaimOutcome.VERIFIED_FALSE)); + assertEq(result.trueWeight, 50 ether); + assertEq(result.falseWeight, 400 ether); + assertEq(result.totalWeight, 450 ether); + assertEq(result.confidence, 8888); + } + + function testLargeVerificationSet() public { + uint256 claimId = _createClaim(); + + address[10] memory voters = [ + address(0x10), address(0x11), address(0x12), address(0x13), address(0x14), + address(0x15), address(0x16), address(0x17), address(0x18), address(0x19) + ]; + + for (uint256 i = 0; i < 10; i++) { + token.mint(voters[i], 1000 ether); + _approveAndStake(voters[i], 500 ether); + _vote(claimId, voters[i], i < 6, 50 ether); + } + + _fastForwardWindow(); + + AggregationResult memory result = aggregator.aggregateClaim(claimId); + assertEq(uint256(result.outcome), uint256(ClaimOutcome.VERIFIED_TRUE)); + assertEq(result.trueWeight, 300 ether); + assertEq(result.falseWeight, 200 ether); + assertEq(result.totalWeight, 500 ether); + assertEq(result.confidence, 6000); + } + + function testConfidenceCalculation() public { + uint256 claimId = _createClaim(); + + _approveAndStake(verifier1, 1000 ether); + _approveAndStake(verifier2, 1000 ether); + + _vote(claimId, verifier1, true, 100 ether); + _vote(claimId, verifier2, false, 100 ether); + + _fastForwardWindow(); + + AggregationResult memory result = aggregator.aggregateClaim(claimId); + assertEq(uint256(result.outcome), uint256(ClaimOutcome.INCONCLUSIVE)); + assertEq(result.trueWeight, 100 ether); + assertEq(result.falseWeight, 100 ether); + assertEq(result.totalWeight, 200 ether); + assertEq(result.confidence, 5000); + } + + // ============ Tie Cases ============ + + function testEqualWeightTie() public { + uint256 claimId = _createClaim(); + + _approveAndStake(verifier1, 1000 ether); + _approveAndStake(verifier2, 1000 ether); + + _vote(claimId, verifier1, true, 100 ether); + _vote(claimId, verifier2, false, 100 ether); + + _fastForwardWindow(); + + AggregationResult memory result = aggregator.aggregateClaim(claimId); + assertEq(uint256(result.outcome), uint256(ClaimOutcome.INCONCLUSIVE)); + assertEq(result.trueWeight, 100 ether); + assertEq(result.falseWeight, 100 ether); + assertEq(result.confidence, 5000); + } + + function testEqualWeightWithThresholds() public { + uint256 claimId = _createClaim(); + + _approveAndStake(verifier1, 1000 ether); + _approveAndStake(verifier2, 1000 ether); + + _vote(claimId, verifier1, true, 100 ether); + _vote(claimId, verifier2, false, 100 ether); + + _fastForwardWindow(); + + aggregator.setThresholds(1, 0, 0); + AggregationResult memory result = aggregator.aggregateClaim(claimId); + assertEq(uint256(result.outcome), uint256(ClaimOutcome.INCONCLUSIVE)); + } + + function testEqualStakeEqualConfidence() public { + uint256 claimId = _createClaim(); + + _approveAndStake(verifier1, 1000 ether); + _approveAndStake(verifier2, 1000 ether); + + oracle.setReputationScore(verifier1, 1 ether); + oracle.setReputationScore(verifier2, 1 ether); + + _vote(claimId, verifier1, true, 100 ether); + _vote(claimId, verifier2, false, 100 ether); + + _fastForwardWindow(); + + AggregationResult memory result = aggregator.aggregateClaim(claimId); + assertEq(uint256(result.outcome), uint256(ClaimOutcome.INCONCLUSIVE)); + assertEq(result.trueWeight, 100 ether); + assertEq(result.falseWeight, 100 ether); + assertEq(result.totalWeight, 200 ether); + assertEq(result.confidence, 5000); + } + + function testInsufficientParticipation() public { + uint256 claimId = _createClaim(); + + _approveAndStake(verifier1, 1000 ether); + _vote(claimId, verifier1, true, 100 ether); + + _fastForwardWindow(); + + aggregator.setThresholds(2, 0, 0); + AggregationResult memory result = aggregator.aggregateClaim(claimId); + assertEq(uint256(result.outcome), uint256(ClaimOutcome.INCONCLUSIVE)); + } + + function testInsufficientTotalStake() public { + uint256 claimId = _createClaim(); + + _approveAndStake(verifier1, 1000 ether); + _vote(claimId, verifier1, true, 100 ether); + + _fastForwardWindow(); + + aggregator.setThresholds(1, 500 ether, 0); + AggregationResult memory result = aggregator.aggregateClaim(claimId); + assertEq(uint256(result.outcome), uint256(ClaimOutcome.INCONCLUSIVE)); + } + + function testConfidenceBelowThreshold() public { + uint256 claimId = _createClaim(); + + _approveAndStake(verifier1, 1000 ether); + _approveAndStake(verifier2, 1000 ether); + _approveAndStake(verifier3, 1000 ether); + + _vote(claimId, verifier1, true, 100 ether); + _vote(claimId, verifier2, true, 100 ether); + _vote(claimId, verifier3, false, 100 ether); + + _fastForwardWindow(); + + aggregator.setThresholds(1, 0, 7000); + AggregationResult memory result = aggregator.aggregateClaim(claimId); + assertEq(uint256(result.outcome), uint256(ClaimOutcome.INCONCLUSIVE)); + assertEq(result.confidence, 6666); + } + + // ============ Determinism Tests ============ + + function testDeterministicReaggregation() public { + uint256 claimId = _createClaim(); + + _approveAndStake(verifier1, 1000 ether); + _approveAndStake(verifier2, 1000 ether); + _approveAndStake(verifier3, 1000 ether); + + _vote(claimId, verifier1, true, 100 ether); + _vote(claimId, verifier2, false, 100 ether); + _vote(claimId, verifier3, true, 100 ether); + + _fastForwardWindow(); + + AggregationResult memory first = aggregator.aggregateClaim(claimId); + + // Verify aggregation is stored + AggregationResult memory stored = aggregator.getAggregation(claimId); + assertEq(uint256(first.outcome), uint256(stored.outcome)); + assertEq(first.trueWeight, stored.trueWeight); + assertEq(first.falseWeight, stored.falseWeight); + assertEq(first.totalWeight, stored.totalWeight); + assertEq(first.confidence, stored.confidence); + } + + function testDeterministicOrderIndependence() public view { + uint256 trueWeight1 = 300 ether; + uint256 falseWeight1 = 100 ether; + + uint256 trueWeight2 = 100 ether; + uint256 falseWeight2 = 300 ether; + + // Order of weights shouldn't affect confidence calculation + uint256 confidence1 = aggregator.calculateConfidence(trueWeight1, falseWeight1); + uint256 confidence2 = aggregator.calculateConfidence(trueWeight2, falseWeight2); + assertEq(confidence1, confidence2); + } + + // ============ Stress Tests ============ + + function testMaxParticipation() public { + uint256 voterCount = 50; + address[] memory voters = new address[](voterCount); + + uint256 claimId = _createClaim(); + + for (uint256 i = 0; i < voterCount; i++) { + address voter = address(uint160(0x100 + i)); + voters[i] = voter; + token.mint(voter, 1000 ether); + _approveAndStake(voter, 500 ether); + _vote(claimId, voter, i < 30, 10 ether); + } + + _fastForwardWindow(); + + AggregationResult memory result = aggregator.aggregateClaim(claimId); + + assertEq(uint256(result.outcome), uint256(ClaimOutcome.VERIFIED_TRUE)); + assertEq(result.totalWeight, voterCount * 10 ether); + assertEq(result.trueWeight, 30 * 10 ether); + assertEq(result.falseWeight, 20 * 10 ether); + assertEq(result.confidence, 6000); + } + + function testRepeatedAggregation() public { + uint256 claimId = _createClaim(); + + _approveAndStake(verifier1, 1000 ether); + _vote(claimId, verifier1, true, 100 ether); + + _fastForwardWindow(); + aggregator.aggregateClaim(claimId); + + vm.expectRevert(); + aggregator.aggregateClaim(claimId); + } + + // ============ Gas Benchmarks ============ + + function testGasAggregationSingle() public { + uint256 claimId = _createClaim(); + _approveAndStake(verifier1, 1000 ether); + _vote(claimId, verifier1, true, 100 ether); + _fastForwardWindow(); + + aggregator.aggregateClaim(claimId); + } + + function testGasAggregationTen() public { + uint256 claimId = _createClaim(); + + for (uint256 i = 0; i < 10; i++) { + address voter = address(uint160(0x100 + i)); + token.mint(voter, 1000 ether); + _approveAndStake(voter, 500 ether); + _vote(claimId, voter, i % 2 == 0, 10 ether); + } + + _fastForwardWindow(); + aggregator.aggregateClaim(claimId); + } + + function testGasAggregationFifty() public { + uint256 claimId = _createClaim(); + + for (uint256 i = 0; i < 50; i++) { + address voter = address(uint160(0x100 + i)); + token.mint(voter, 1000 ether); + _approveAndStake(voter, 500 ether); + _vote(claimId, voter, i % 2 == 0, 10 ether); + } + + _fastForwardWindow(); + aggregator.aggregateClaim(claimId); + } + + // ============ Edge Cases ============ + + function testZeroVotes() public { + uint256 claimId = _createClaim(); + _fastForwardWindow(); + + AggregationResult memory result = aggregator.aggregateClaim(claimId); + assertEq(uint256(result.outcome), uint256(ClaimOutcome.INCONCLUSIVE)); + assertEq(result.trueWeight, 0); + assertEq(result.falseWeight, 0); + assertEq(result.totalWeight, 0); + assertEq(result.confidence, 0); + } + + function testAlreadyAggregatedReverts() public { + uint256 claimId = _createClaim(); + + _approveAndStake(verifier1, 1000 ether); + _vote(claimId, verifier1, true, 100 ether); + + _fastForwardWindow(); + aggregator.aggregateClaim(claimId); + + vm.expectRevert(); + aggregator.aggregateClaim(claimId); + } + + function testNotAggregatedReverts() public { + uint256 claimId = _createClaim(); + vm.expectRevert(); + aggregator.getAggregation(claimId); + } + + function testIsAggregated() public { + uint256 claimId = _createClaim(); + + assertFalse(aggregator.isAggregated(claimId)); + + _approveAndStake(verifier1, 1000 ether); + _vote(claimId, verifier1, true, 100 ether); + + _fastForwardWindow(); + aggregator.aggregateClaim(claimId); + + assertTrue(aggregator.isAggregated(claimId)); + } + + function testHighConfidenceThreshold() public { + uint256 claimId = _createClaim(); + + _approveAndStake(verifier1, 1000 ether); + _approveAndStake(verifier2, 1000 ether); + _approveAndStake(verifier3, 1000 ether); + + oracle.setReputationScore(verifier1, 10 ether); + oracle.setReputationScore(verifier2, 10 ether); + oracle.setReputationScore(verifier3, 1 ether); + + _vote(claimId, verifier1, true, 100 ether); + _vote(claimId, verifier2, true, 100 ether); + _vote(claimId, verifier3, false, 100 ether); + + _fastForwardWindow(); + + AggregationResult memory result = aggregator.aggregateClaim(claimId); + assertEq(uint256(result.outcome), uint256(ClaimOutcome.VERIFIED_TRUE)); + assertEq(result.trueWeight, 2000 ether); + assertEq(result.falseWeight, 100 ether); + assertEq(result.totalWeight, 2100 ether); + assertEq(result.confidence, 9523); + } + + // ============ Event Tests ============ + + function testClaimAggregatedEvent() public { + uint256 claimId = _createClaim(); + + _approveAndStake(verifier1, 1000 ether); + _vote(claimId, verifier1, true, 100 ether); + + _fastForwardWindow(); + + vm.expectEmit(true, true, true, true); + emit ClaimAggregated(claimId, ClaimOutcome.VERIFIED_TRUE, 10000); + aggregator.aggregateClaim(claimId); + } + + function testThresholdsEvent() public { + vm.expectEmit(true, true, true, true); + emit ThresholdsUpdated(5, 1000 ether, 6000); + aggregator.setThresholds(5, 1000 ether, 6000); + } +} diff --git a/test/fuzz/ReputationDecayFuzz.t.sol b/test/fuzz/ReputationDecayFuzz.t.sol new file mode 100644 index 0000000..d4dabea --- /dev/null +++ b/test/fuzz/ReputationDecayFuzz.t.sol @@ -0,0 +1,197 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; +import "../contracts/ReputationDecay.sol"; + +contract ReputationDecayFuzzTest is Test { + ReputationDecay public decay; + + address public owner = address(0x1); + address public oracle = address(0x2); + address public verifier = address(0x3); + + uint256 public constant ONE_WEEK = 7 * 86400; + uint256 public constant BASIS_POINTS = 10000; + + function setUp() public { + vm.startPrank(owner); + decay = new ReputationDecay(owner); + decay.grantRole(decay.ORACLE_ROLE(), oracle); + vm.stopPrank(); + } + + function testFuzz_Decay_LinearCalculation( + uint256 baseRep, + uint256 intervalsInactive, + uint256 decayPercentage + ) public { + baseRep = bound(baseRep, 1, 1e24); + intervalsInactive = bound(intervalsInactive, 1, 100); + decayPercentage = bound(decayPercentage, 1, BASIS_POINTS); + + vm.prank(owner); + decay.setDecayConfig(ReputationDecay.ReputationDecayConfig({ + decayInterval: ONE_WEEK, + decayPercentage: decayPercentage, + minimumReputation: 1, + enabled: true + })); + + vm.prank(oracle); + decay.setReputation(verifier, baseRep); + + uint256 expectedDecayBps = intervalsInactive * decayPercentage; + if (expectedDecayBps > BASIS_POINTS) expectedDecayBps = BASIS_POINTS; + + uint256 expectedEffective = (baseRep * (BASIS_POINTS - expectedDecayBps)) / BASIS_POINTS; + if (expectedEffective < 1) expectedEffective = 1; + + vm.warp(block.timestamp + intervalsInactive * ONE_WEEK); + + uint256 effective = decay.getEffectiveReputation(verifier); + assertEq(effective, expectedEffective); + } + + function testFuzz_MinimumReputation_Enforced( + uint256 baseRep, + uint256 intervalsInactive, + uint256 minRep + ) public { + baseRep = bound(baseRep, 1, 1e6); + intervalsInactive = bound(intervalsInactive, 1, 1000); + minRep = bound(minRep, 1, 1000); + + vm.prank(owner); + decay.setDecayConfig(ReputationDecay.ReputationDecayConfig({ + decayInterval: ONE_WEEK, + decayPercentage: 5000, + minimumReputation: minRep, + enabled: true + })); + + vm.prank(oracle); + decay.setReputation(verifier, baseRep); + + vm.warp(block.timestamp + intervalsInactive * ONE_WEEK); + + uint256 effective = decay.getEffectiveReputation(verifier); + assertGe(effective, minRep); + } + + function testFuzz_ActivityReset_PreventsDecay( + uint256 baseRep, + uint256 intervalsBeforeReset, + uint256 intervalsAfterReset + ) public { + baseRep = bound(baseRep, 1, 1e6); + intervalsBeforeReset = bound(intervalsBeforeReset, 1, 10); + intervalsAfterReset = bound(intervalsAfterReset, 1, 10); + + vm.prank(oracle); + decay.setReputation(verifier, baseRep); + + vm.warp(block.timestamp + intervalsBeforeReset * ONE_WEEK); + + vm.prank(oracle); + decay.recordActivity(verifier); + + vm.warp(block.timestamp + intervalsAfterReset * ONE_WEEK); + + uint256 effective = decay.getEffectiveReputation(verifier); + uint256 expectedDecayBps = intervalsAfterReset * 100; + if (expectedDecayBps > BASIS_POINTS) expectedDecayBps = BASIS_POINTS; + uint256 expected = (baseRep * (BASIS_POINTS - expectedDecayBps)) / BASIS_POINTS; + if (expected < 1) expected = 1; + + assertEq(effective, expected); + } + + function testFuzz_DecayDisabled_NoDecay( + uint256 baseRep, + uint256 timeJump + ) public { + baseRep = bound(baseRep, 1, 1e6); + timeJump = bound(timeJump, ONE_WEEK, 365 days); + + vm.prank(owner); + decay.setDecayConfig(ReputationDecay.ReputationDecayConfig({ + decayInterval: ONE_WEEK, + decayPercentage: 1000, + minimumReputation: 1, + enabled: false + })); + + vm.prank(oracle); + decay.setReputation(verifier, baseRep); + + vm.warp(block.timestamp + timeJump); + + assertEq(decay.getEffectiveReputation(verifier), baseRep); + } + + function testFuzz_ZeroReputation_ReturnsZero( + uint256 timeJump + ) public { + timeJump = bound(timeJump, ONE_WEEK, 365 days); + + vm.warp(block.timestamp + timeJump); + + assertEq(decay.getEffectiveReputation(verifier), 0); + } + + function testFuzz_ApplyDecay_UpdatesState( + uint256 baseRep, + uint256 intervalsInactive + ) public { + baseRep = bound(baseRep, 2, 1e6); + intervalsInactive = bound(intervalsInactive, 1, 50); + + vm.prank(oracle); + decay.setReputation(verifier, baseRep); + + vm.warp(block.timestamp + intervalsInactive * ONE_WEEK); + + vm.prank(oracle); + decay.applyDecay(verifier); + + uint256 effectiveBefore = decay.getEffectiveReputation(verifier); + uint256 storedBase = decay.baseReputation(verifier); + + assertEq(storedBase, effectiveBefore); + } + + function testFuzz_IsDecayRequired_Consistent( + uint256 baseRep, + uint256 timeJump + ) public { + baseRep = bound(baseRep, 1, 1e6); + timeJump = bound(timeJump, 0, 100 * ONE_WEEK); + + vm.prank(oracle); + decay.setReputation(verifier, baseRep); + + vm.warp(block.timestamp + timeJump); + + bool required = decay.isDecayRequired(verifier); + bool shouldBeRequired = timeJump >= ONE_WEEK; + assertEq(required, shouldBeRequired); + } + + function testFuzz_NextDecayTimestamp_Accurate( + uint256 baseRep, + uint256 timeJump + ) public { + baseRep = bound(baseRep, 1, 1e6); + timeJump = bound(timeJump, 0, 10 * ONE_WEEK); + + vm.prank(oracle); + decay.setReputation(verifier, baseRep); + + uint256 expectedNext = block.timestamp + ONE_WEEK; + assertEq(decay.nextDecayTimestamp(verifier), expectedNext); + + vm.warp(block.timestamp + timeJump); + assertEq(decay.nextDecayTimestamp(verifier), expectedNext); + } +}