Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
186 changes: 181 additions & 5 deletions contracts/TruthBountyWeighted.sol
Original file line number Diff line number Diff line change
Expand Up @@ -121,15 +121,28 @@ 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;
string content;
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
Expand Down Expand Up @@ -171,6 +184,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 {
Expand All @@ -191,6 +211,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;
Expand Down Expand Up @@ -225,6 +246,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,
Expand All @@ -248,6 +272,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 ============

Expand Down Expand Up @@ -277,6 +303,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);
Expand Down Expand Up @@ -312,6 +340,7 @@ contract TruthBountyWeighted is ResolverRoleTimelock, ReentrancyGuard, Pausable,
createdAt: block.timestamp,
verificationWindowEnd: verificationWindowEnd,
settled: false,
finalized: false,
totalWeightedFor: 0,
totalWeightedAgainst: 0,
totalStakeAmount: 0,
Expand Down Expand Up @@ -460,23 +489,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,
Expand All @@ -485,6 +526,7 @@ contract TruthBountyWeighted is ResolverRoleTimelock, ReentrancyGuard, Pausable,
rewardAmount,
slashedAmount
);
emit SettlementCompleted(claimId, settlement.rewardPool, settlement.protocolFee, treasury);
}

/**
Expand Down Expand Up @@ -824,7 +866,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);
Expand All @@ -835,6 +884,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.
Expand All @@ -844,6 +894,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;
Expand All @@ -856,7 +907,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
});
}

Expand Down Expand Up @@ -914,8 +972,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
Expand Down