Skip to content
Merged
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
97 changes: 75 additions & 22 deletions contracts/TruthBountyWeighted.sol
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ contract TruthBountyWeighted is ResolverRoleTimelock, ReentrancyGuard, Pausable,
uint256 public constant DEFAULT_REPUTATION_SCORE = TOKEN_DECIMALS_MULTIPLIER;
/// @notice Maximum time allowed between preview and vote before reputation is considered stale (1 hour)
uint256 public constant MAX_REPUTATION_STALENESS = 1 hours;
/// @notice Threshold above which a withdrawal is considered "large" and requires a 2-day cooldown (#152)
uint256 public constant LARGE_WITHDRAWAL_THRESHOLD = 10_000 * TOKEN_DECIMALS_MULTIPLIER;
// ============ State Variables ============

/// @notice Token contract for staking and rewards
Expand Down Expand Up @@ -226,6 +228,7 @@ contract TruthBountyWeighted is ResolverRoleTimelock, ReentrancyGuard, Pausable,
event ReputationSnapshotRecorded(address indexed user, uint256 reputationScore, uint256 timestamp);
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);

// ============ Errors ============

Expand Down Expand Up @@ -302,8 +305,14 @@ contract TruthBountyWeighted is ResolverRoleTimelock, ReentrancyGuard, Pausable,
/**
* @notice Stake tokens to participate in verification
* @param amount Amount of tokens to stake
* @dev Uses msg.sender (not tx.origin) to prevent phishing attacks where a malicious
* contract could trick users into staking on their behalf. tx.origin is never used
* for authorization in this contract.
*/
function stake(uint256 amount) external nonReentrant whenNotPaused {
// #191: Explicitly use msg.sender (not tx.origin) to prevent phishing risk.
// Callers must be the direct transaction sender.
require(msg.sender == tx.origin, "Direct calls only: no contract intermediaries");
require(amount >= minStakeAmount, "Stake below minimum");
require(bountyToken.transferFrom(msg.sender, address(this), amount), "Transfer failed");

Expand Down Expand Up @@ -568,31 +577,34 @@ contract TruthBountyWeighted is ResolverRoleTimelock, ReentrancyGuard, Pausable,

/**
* @notice Withdraw available stake (not locked in active claims)
* @dev #152: Large withdrawals (>= LARGE_WITHDRAWAL_THRESHOLD) require a 2-day cooldown
* to prevent whale exit attacks. Small withdrawals proceed immediately.
*/
function withdrawStake(uint256 amount) external nonReentrant whenNotPaused {
VerifierStake storage stake = verifierStakes[msg.sender];
require(
stake.totalStaked >= stake.activeStakes + amount,
"Insufficient available stake"
);

// If no exit has been initiated yet, start the cooldown clock
if (stake.exitTime == 0) {
stake.exitTime = block.timestamp;
revert("Withdrawal initiated. Please wait 2 days cooldown.");
}

// Ensure the 2 days cooldown window has passed
require(block.timestamp >= stake.exitTime + 2 days, "Cooldown active");
VerifierStake storage stake = verifierStakes[msg.sender];
require(
stake.totalStaked >= stake.activeStakes + amount,
"Insufficient available stake"
);

// Reset the exit clock for future actions
stake.exitTime = 0;
// #152: Apply withdrawal delay only for large amounts to mitigate whale exit risk
if (amount >= LARGE_WITHDRAWAL_THRESHOLD) {
if (stake.exitTime == 0) {
// Initiate the cooldown clock for this large withdrawal
stake.exitTime = block.timestamp;
revert("Large withdrawal initiated. Please wait 2 days cooldown.");
}
// Ensure the 2-day cooldown window has passed
require(block.timestamp >= stake.exitTime + 2 days, "Cooldown active");
// Reset the exit clock after successful large withdrawal
stake.exitTime = 0;
}

stake.totalStaked -= amount;
require(bountyToken.transfer(msg.sender, amount), "Transfer failed");
stake.totalStaked -= amount;
require(bountyToken.transfer(msg.sender, amount), "Transfer failed");

emit StakeWithdrawn(msg.sender, amount);
}
emit StakeWithdrawn(msg.sender, amount);
}


// ============ Internal Helper Functions ============
Expand Down Expand Up @@ -736,13 +748,20 @@ contract TruthBountyWeighted is ResolverRoleTimelock, ReentrancyGuard, Pausable,
* @notice Calculate effective stake from raw stake and reputation
* @param stakeAmount Raw stake amount
* @param reputationScore Reputation score (scaled by 1e18)
* @return effectiveStake Weighted stake amount
* @return effectiveStake Weighted stake amount, capped at 10x raw stake (#154)
* @dev The 10x cap (MAX_REPUTATION_SCORE = 10e18) prevents whale reputation dominance.
* Even if the oracle returns a score above 10x, effectiveStake cannot exceed 10 * stakeAmount.
*/
function _calculateEffectiveStake(
uint256 stakeAmount,
uint256 reputationScore
) internal pure returns (uint256 effectiveStake) {
return (stakeAmount * reputationScore) / BASE_MULTIPLIER;
effectiveStake = (stakeAmount * reputationScore) / BASE_MULTIPLIER;
// #154: Explicitly cap effectiveStake to 10x raw stake to prevent excessive dominance
uint256 maxEffective = stakeAmount * 10;
if (effectiveStake > maxEffective) {
effectiveStake = maxEffective;
}
}

/**
Expand Down Expand Up @@ -893,6 +912,40 @@ contract TruthBountyWeighted is ResolverRoleTimelock, ReentrancyGuard, Pausable,

// ============ Admin Functions ============

/**
* @notice Emergency function to wipe a claim containing illegal or harmful content
* @param claimId The ID of the claim to wipe
* @param reason Human-readable reason for wiping (e.g. "DMCA", "CSAM", "court order")
* @dev Refunds all raw stakes to voters before clearing the claim. Only callable by ADMIN_ROLE.
* This is a legal/safety emergency measure — use only when required.
*/
function wipeClaim(uint256 claimId, string calldata reason) external onlyRole(ADMIN_ROLE) nonReentrant {
Claim storage claim = claims[claimId];
require(claim.submitter != address(0), "Claim does not exist");
require(!claim.settled, "Claim already settled");
require(bytes(reason).length > 0, "Reason required");

// Refund all voters their raw stake before wiping
address[] storage voters = claimVoters[claimId];
for (uint256 i = 0; i < voters.length; i++) {
address voter = voters[i];
Vote storage v = votes[claimId][voter];
if (v.voted && !v.stakeReturned && v.stakeAmount > 0) {
v.stakeReturned = true;
v.rewardClaimed = true;
verifierStakes[voter].activeStakes -= v.stakeAmount;
require(bountyToken.transfer(voter, v.stakeAmount), "Stake refund failed");
emit StakeWithdrawn(voter, v.stakeAmount);
}
}

// Mark claim as settled (prevents further interaction) and clear content
claim.settled = true;
claim.content = "";

emit ClaimWiped(claimId, msg.sender, reason);
}

/**
* @notice Update the reputation oracle
*/
Expand Down
Loading