From 4eca192b44495d6f0a319358b9a22e76df98c3c9 Mon Sep 17 00:00:00 2001 From: emickwrld Date: Tue, 28 Jul 2026 15:36:22 +0100 Subject: [PATCH] Implement deterministic settlement lifecycle --- contracts/TruthBountyWeighted.sol | 186 +++++++++++++++++++++++++++++- 1 file changed, 181 insertions(+), 5 deletions(-) diff --git a/contracts/TruthBountyWeighted.sol b/contracts/TruthBountyWeighted.sol index 2bd6a36..b991992 100644 --- a/contracts/TruthBountyWeighted.sol +++ b/contracts/TruthBountyWeighted.sol @@ -118,8 +118,20 @@ contract TruthBountyWeighted is ResolverRoleTimelock, ReentrancyGuard, Pausable, /// @notice Whether weighted staking is enabled bool public weightedStakingEnabled = true; + /// @notice Treasury address receiving protocol fees from successful settlements. + address public treasury; + + /// @notice Percentage of the settlement reward pool routed to the treasury. + uint256 public protocolFeePercent = 10; + // ============ Structs ============ + enum SettlementState { + Pending, + Executing, + Completed + } + struct Claim { uint256 id; address submitter; @@ -127,6 +139,7 @@ contract TruthBountyWeighted is ResolverRoleTimelock, ReentrancyGuard, Pausable, uint256 createdAt; uint256 verificationWindowEnd; bool settled; + bool finalized; uint256 totalWeightedFor; // Weighted votes for claim (NEW) uint256 totalWeightedAgainst; // Weighted votes against claim (NEW) uint256 totalStakeAmount; // Total raw stake amount @@ -154,6 +167,13 @@ contract TruthBountyWeighted is ResolverRoleTimelock, ReentrancyGuard, Pausable, uint256 winnerCount; // Number of winning voters eligible for rewards uint256 winnersClaimed; // Number of winning voters that claimed rewards uint256 rewardsClaimed; // Total rewards already distributed + uint256 rewardPool; + uint256 winningStake; + uint256 losingStake; + uint256 protocolFee; + uint256 treasuryFee; + uint256 refundedStake; + bool settled; } struct VerifierStake { @@ -174,6 +194,7 @@ contract TruthBountyWeighted is ResolverRoleTimelock, ReentrancyGuard, Pausable, mapping(uint256 => mapping(address => Vote)) public votes; mapping(address => VerifierStake) public verifierStakes; mapping(uint256 => address[]) private claimVoters; // Track all voters per claim for settlement + mapping(uint256 => SettlementState) public settlementStates; /// @notice Track reputation snapshots for staleness validation: user => (reputationScore, timestamp) mapping(address => ReputationSnapshot) public reputationSnapshots; @@ -208,6 +229,9 @@ contract TruthBountyWeighted is ResolverRoleTimelock, ReentrancyGuard, Pausable, uint256 totalRewards, uint256 totalSlashed ); + event SettlementPreviewed(uint256 indexed claimId, uint256 rewardPool, uint256 protocolFee); + event SettlementCompleted(uint256 indexed claimId, uint256 rewardPool, uint256 protocolFee, address indexed treasury); + event TreasuryAllocated(uint256 indexed claimId, address indexed treasury, uint256 amount); event RewardsDistributed( uint256 indexed claimId, @@ -231,6 +255,8 @@ contract TruthBountyWeighted is ResolverRoleTimelock, ReentrancyGuard, Pausable, event ReputationStalenessValidated(address indexed user, uint256 expectedReputation, uint256 actualReputation, uint256 maxDrift); event ReputationUpdateGracePeriodUpdated(uint256 newGracePeriod); event ClaimWiped(uint256 indexed claimId, address indexed admin, string reason); + event TreasuryUpdated(address indexed oldTreasury, address indexed newTreasury); + event ProtocolFeePercentUpdated(uint256 oldPercent, uint256 newPercent); // ============ Errors ============ @@ -260,6 +286,8 @@ contract TruthBountyWeighted is ResolverRoleTimelock, ReentrancyGuard, Pausable, _setRoleAdmin(RESOLVER_ROLE, ADMIN_ROLE); _setRoleAdmin(TREASURY_ROLE, ADMIN_ROLE); _setRoleAdmin(PAUSER_ROLE, ADMIN_ROLE); + + treasury = initialAdmin; // Initialize governance _initializeGovernance(_governanceController, initialAdmin, initialAdmin); @@ -295,6 +323,7 @@ contract TruthBountyWeighted is ResolverRoleTimelock, ReentrancyGuard, Pausable, createdAt: block.timestamp, verificationWindowEnd: verificationWindowEnd, settled: false, + finalized: false, totalWeightedFor: 0, totalWeightedAgainst: 0, totalStakeAmount: 0, @@ -440,23 +469,35 @@ contract TruthBountyWeighted is ResolverRoleTimelock, ReentrancyGuard, Pausable, Claim storage claim = claims[claimId]; require(claim.submitter != address(0), "Claim does not exist"); require(block.timestamp >= claim.verificationWindowEnd + confirmationDelay, "Confirmation delay pending"); - require(!claim.settled, "Claim already settled"); + require(!claim.settled && !claim.finalized, "Claim already settled"); + require(settlementStates[claimId] != SettlementState.Executing, "Settlement already executing"); + require(settlementStates[claimId] != SettlementState.Completed, "Settlement already completed"); require(claim.totalStakeAmount > 0, "No votes cast"); - claim.settled = true; + settlementStates[claimId] = SettlementState.Executing; // Determine outcome based on WEIGHTED votes. Exact ties are resolved as // a refund-only outcome so no side is left unable to recover stake. bool isTie = claim.totalWeightedFor == claim.totalWeightedAgainst && claim.totalWeightedFor > 0; bool passed = isTie ? false : _determineOutcome(claim.totalWeightedFor, claim.totalWeightedAgainst); - // Calculate rewards and slashing + // Calculate rewards and slashing deterministically before any state changes are finalized. (uint256 rewardAmount, uint256 slashedAmount) = _calculateSettlement( claimId, passed, isTie ); + claim.settled = true; + claim.finalized = true; + settlementStates[claimId] = SettlementState.Completed; + + SettlementResult storage settlement = settlementResults[claimId]; + if (settlement.protocolFee > 0) { + require(bountyToken.transfer(treasury, settlement.protocolFee), "Treasury transfer failed"); + emit TreasuryAllocated(claimId, treasury, settlement.protocolFee); + } + emit ClaimSettled( claimId, passed, @@ -465,6 +506,7 @@ contract TruthBountyWeighted is ResolverRoleTimelock, ReentrancyGuard, Pausable, rewardAmount, slashedAmount ); + emit SettlementCompleted(claimId, settlement.rewardPool, settlement.protocolFee, treasury); } /** @@ -804,7 +846,14 @@ contract TruthBountyWeighted is ResolverRoleTimelock, ReentrancyGuard, Pausable, loserWeightedStake: 0, winnerCount: 0, winnersClaimed: 0, - rewardsClaimed: 0 + rewardsClaimed: 0, + rewardPool: 0, + winningStake: 0, + losingStake: 0, + protocolFee: 0, + treasuryFee: 0, + refundedStake: claim.totalStakeAmount, + settled: true }); return (0, 0); @@ -815,6 +864,7 @@ contract TruthBountyWeighted is ResolverRoleTimelock, ReentrancyGuard, Pausable, uint256 loserWeightedStake = passed ? claim.totalWeightedAgainst : claim.totalWeightedFor; // Exact total RAW stake from losing side (pre-calculated during voting) + uint256 winnerRawStake = passed ? claim.totalStakedFor : claim.totalStakedAgainst; uint256 loserRawStake = passed ? claim.totalStakedAgainst : claim.totalStakedFor; // Slash the configured percentage of losing raw stake. @@ -824,6 +874,7 @@ contract TruthBountyWeighted is ResolverRoleTimelock, ReentrancyGuard, Pausable, // The configured reward share of slashed stake goes to winners. rewardAmount = (slashedAmount * rewardPercent) / PERCENT_DENOMINATOR; + uint256 protocolFee = (rewardAmount * protocolFeePercent) / PERCENT_DENOMINATOR; totalSlashed += slashedAmount; totalRewarded += rewardAmount; @@ -836,7 +887,14 @@ contract TruthBountyWeighted is ResolverRoleTimelock, ReentrancyGuard, Pausable, loserWeightedStake: loserWeightedStake, winnerCount: _countWinners(claimId, passed), winnersClaimed: 0, - rewardsClaimed: 0 + rewardsClaimed: 0, + rewardPool: rewardAmount, + winningStake: winnerRawStake, + losingStake: loserRawStake, + protocolFee: protocolFee, + treasuryFee: protocolFee, + refundedStake: claim.totalStakeAmount - slashedAmount, + settled: true }); } @@ -894,8 +952,126 @@ contract TruthBountyWeighted is ResolverRoleTimelock, ReentrancyGuard, Pausable, } } + // ============ Settlement View Functions ============ + + function getSettlement(uint256 claimId) external view returns (SettlementResult memory) { + if (claims[claimId].submitter == address(0)) { + return _emptySettlementResult(); + } + + if (settlementResults[claimId].settled) { + return settlementResults[claimId]; + } + + return _previewSettlement(claimId); + } + + function isSettled(uint256 claimId) external view returns (bool) { + Claim storage claim = claims[claimId]; + if (claim.submitter == address(0)) { + return false; + } + return claim.settled || settlementResults[claimId].settled; + } + + function calculateSettlement(uint256 claimId) external view returns (SettlementResult memory) { + return _previewSettlement(claimId); + } + + function previewSettlement(uint256 claimId) external view returns (SettlementResult memory) { + return _previewSettlement(claimId); + } + + function _previewSettlement(uint256 claimId) internal view returns (SettlementResult memory) { + Claim storage claim = claims[claimId]; + if (claim.submitter == address(0)) { + return _emptySettlementResult(); + } + + bool isTie = claim.totalWeightedFor == claim.totalWeightedAgainst && claim.totalWeightedFor > 0; + bool passed = isTie ? false : _determineOutcome(claim.totalWeightedFor, claim.totalWeightedAgainst); + + if (isTie) { + return SettlementResult({ + passed: false, + totalRewards: 0, + totalSlashed: 0, + winnerWeightedStake: 0, + loserWeightedStake: 0, + winnerCount: 0, + winnersClaimed: 0, + rewardsClaimed: 0, + rewardPool: 0, + winningStake: 0, + losingStake: 0, + protocolFee: 0, + treasuryFee: 0, + refundedStake: claim.totalStakeAmount, + settled: false + }); + } + + uint256 winnerRawStake = passed ? claim.totalStakedFor : claim.totalStakedAgainst; + uint256 loserRawStake = passed ? claim.totalStakedAgainst : claim.totalStakedFor; + uint256 slashedAmount = (loserRawStake * slashPercent) / PERCENT_DENOMINATOR; + uint256 rewardAmount = (slashedAmount * rewardPercent) / PERCENT_DENOMINATOR; + uint256 protocolFee = (rewardAmount * protocolFeePercent) / PERCENT_DENOMINATOR; + + return SettlementResult({ + passed: passed, + totalRewards: rewardAmount, + totalSlashed: slashedAmount, + winnerWeightedStake: passed ? claim.totalWeightedFor : claim.totalWeightedAgainst, + loserWeightedStake: passed ? claim.totalWeightedAgainst : claim.totalWeightedFor, + winnerCount: _countWinners(claimId, passed), + winnersClaimed: 0, + rewardsClaimed: 0, + rewardPool: rewardAmount, + winningStake: winnerRawStake, + losingStake: loserRawStake, + protocolFee: protocolFee, + treasuryFee: protocolFee, + refundedStake: claim.totalStakeAmount - slashedAmount, + settled: false + }); + } + + function _emptySettlementResult() internal pure returns (SettlementResult memory) { + return SettlementResult({ + passed: false, + totalRewards: 0, + totalSlashed: 0, + winnerWeightedStake: 0, + loserWeightedStake: 0, + winnerCount: 0, + winnersClaimed: 0, + rewardsClaimed: 0, + rewardPool: 0, + winningStake: 0, + losingStake: 0, + protocolFee: 0, + treasuryFee: 0, + refundedStake: 0, + settled: false + }); + } + // ============ Admin Functions ============ + function setTreasury(address newTreasury) external onlyGovernanceOrAdmin { + require(newTreasury != address(0), "Invalid treasury address"); + address oldTreasury = treasury; + treasury = newTreasury; + emit TreasuryUpdated(oldTreasury, newTreasury); + } + + function setProtocolFeePercent(uint256 newProtocolFeePercent) external onlyGovernanceOrAdmin { + require(newProtocolFeePercent <= PERCENT_DENOMINATOR, "Invalid protocol fee percent"); + uint256 oldPercent = protocolFeePercent; + protocolFeePercent = newProtocolFeePercent; + emit ProtocolFeePercentUpdated(oldPercent, newProtocolFeePercent); + } + /** * @notice Emergency function to wipe a claim containing illegal or harmful content * @param claimId The ID of the claim to wipe