Skip to content
Open
Show file tree
Hide file tree
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
178 changes: 178 additions & 0 deletions contracts/SlashingModule.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/access/Ownable.sol";

interface IStakingModule {
function slash(
address seller,
uint256 amount,
address recipient,
string calldata reason
) external;

function stakedBalance(address seller) external view returns (uint256);
}

/// @title SlashingModule
/// @notice DAO and dispute-resolution gateway for slashing seller stake
/// @dev The DAO address can be a multisig, governor executor, or timelock.
contract SlashingModule is Ownable {
uint256 public constant MAX_TRUST_SCORE = 10_000;
uint256 public constant BPS_DENOMINATOR = 10_000;

IStakingModule public immutable stakingModule;

address public dao;
address public slashRecipient;
uint256 public slashPenaltyBps = 1_000;
uint256 public trustDecayPerDay = 25;

struct TrustState {
uint256 score;
uint256 updatedAt;
}

mapping(address => bool) public disputeResolvers;
mapping(uint256 => bool) public resolvedDisputes;
mapping(address => TrustState) private trustStates;

event Slashed(address indexed seller, uint256 amount, string reason);
event DaoUpdated(address indexed dao);
event DisputeResolverUpdated(address indexed resolver, bool allowed);
event SlashRecipientUpdated(address indexed recipient);
event SlashPenaltyUpdated(uint256 penaltyBps);
event TrustDecayUpdated(uint256 decayPerDay);
event TrustScoreUpdated(address indexed seller, uint256 score, uint256 timestamp);

constructor(address _stakingModule, address _dao, address _slashRecipient) {
require(_stakingModule != address(0), "Invalid staking module");
require(_dao != address(0), "Invalid DAO");
require(_slashRecipient != address(0), "Invalid recipient");

stakingModule = IStakingModule(_stakingModule);
dao = _dao;
slashRecipient = _slashRecipient;
}

modifier onlyDAO() {
require(msg.sender == dao, "Only DAO");
_;
}

modifier onlyDisputeResolver() {
require(disputeResolvers[msg.sender], "Only dispute resolver");
_;
}

/// @notice Slash after a DAO vote has passed and executed through the configured DAO address
function slashByDAO(
address seller,
uint256 amount,
string calldata reason
) external onlyDAO {
_slash(seller, amount, reason);
}

/// @notice Slash after an approved dispute resolver decides against a seller
function slashByDispute(
uint256 disputeId,
address seller,
uint256 amount,
string calldata reason
) external onlyDisputeResolver {
require(!resolvedDisputes[disputeId], "Dispute already resolved");
resolvedDisputes[disputeId] = true;

_slash(seller, amount, reason);
}

/// @notice Returns the seller trust score after applying elapsed time decay
function trustScore(address seller) public view returns (uint256) {
TrustState memory state = trustStates[seller];
if (state.updatedAt == 0) {
return MAX_TRUST_SCORE;
}

uint256 elapsedDays = (block.timestamp - state.updatedAt) / 1 days;
uint256 decay = elapsedDays * trustDecayPerDay;

if (decay >= state.score) {
return 0;
}

return state.score - decay;
}

/// @notice Checkpoints a seller's decayed trust score for indexers and external callers
function applyTrustDecay(address seller) external returns (uint256) {
return _checkpointTrust(seller);
}

/// @notice Returns raw checkpoint data before applying additional elapsed decay
function trustState(address seller) external view returns (uint256 score, uint256 updatedAt) {
TrustState memory state = trustStates[seller];
return (state.score, state.updatedAt);
}

function setDAO(address _dao) external onlyOwner {
require(_dao != address(0), "Invalid DAO");
dao = _dao;
emit DaoUpdated(_dao);
}

function setDisputeResolver(address resolver, bool allowed) external onlyOwner {
require(resolver != address(0), "Invalid resolver");
disputeResolvers[resolver] = allowed;
emit DisputeResolverUpdated(resolver, allowed);
}

function setSlashRecipient(address recipient) external onlyOwner {
require(recipient != address(0), "Invalid recipient");
slashRecipient = recipient;
emit SlashRecipientUpdated(recipient);
}

function setSlashPenaltyBps(uint256 penaltyBps) external onlyOwner {
require(penaltyBps <= BPS_DENOMINATOR, "Penalty too high");
slashPenaltyBps = penaltyBps;
emit SlashPenaltyUpdated(penaltyBps);
}

function setTrustDecayPerDay(uint256 decayPerDay) external onlyOwner {
require(decayPerDay <= MAX_TRUST_SCORE, "Decay too high");
trustDecayPerDay = decayPerDay;
emit TrustDecayUpdated(decayPerDay);
}

function _slash(address seller, uint256 amount, string calldata reason) internal {
require(seller != address(0), "Invalid seller");
require(amount > 0, "Cannot slash zero");
require(stakingModule.stakedBalance(seller) >= amount, "Insufficient stake");

uint256 currentScore = trustScore(seller);
uint256 penalty = (currentScore * slashPenaltyBps) / BPS_DENOMINATOR;
uint256 nextScore = currentScore > penalty ? currentScore - penalty : 0;

trustStates[seller] = TrustState({
score: nextScore,
updatedAt: block.timestamp
});

stakingModule.slash(seller, amount, slashRecipient, reason);

emit Slashed(seller, amount, reason);
emit TrustScoreUpdated(seller, nextScore, block.timestamp);
}

function _checkpointTrust(address seller) internal returns (uint256) {
uint256 decayedScore = trustScore(seller);
trustStates[seller] = TrustState({
score: decayedScore,
updatedAt: block.timestamp
});

emit TrustScoreUpdated(seller, decayedScore, block.timestamp);
return decayedScore;
}
}
56 changes: 53 additions & 3 deletions contracts/StakingModule.sol
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ import "@openzeppelin/contracts/access/Ownable.sol";

/// @title StakingModule
/// @notice Allows sellers to stake $NOBAY for trust tier assignment
/// @dev Modular contract, upgradeable by DAO later, no slashing in MVP
/// @dev Modular contract, upgradeable by DAO later

contract StakingModule is Ownable {
IERC20 public immutable nobay;
address public slashingModule;

struct Stake {
uint256 amount;
Expand All @@ -23,17 +24,24 @@ contract StakingModule is Ownable {

event Staked(address indexed seller, uint256 amount, uint256 timestamp);
event Unstaked(address indexed seller, uint256 amount, uint256 timestamp);
event SlashingModuleUpdated(address indexed module);
event Slashed(address indexed seller, address indexed recipient, uint256 amount, string reason, uint256 timestamp);

constructor(address _nobay) {
nobay = IERC20(_nobay);
}

modifier onlySlashingModule() {
require(msg.sender == slashingModule && slashingModule != address(0), "Not slashing module");
_;
}

/// @notice Stake $NOBAY tokens to enter trust tiers
/// @param amount Number of tokens to stake
function stake(uint256 amount) external {
require(amount > 0, "Cannot stake zero");

nobay.transferFrom(msg.sender, address(this), amount);
require(nobay.transferFrom(msg.sender, address(this), amount), "Stake transfer failed");

stakes[msg.sender].amount += amount;
stakes[msg.sender].timestamp = block.timestamp;
Expand All @@ -54,10 +62,41 @@ contract StakingModule is Ownable {
s.amount = 0;
s.active = false;

nobay.transfer(msg.sender, amount);
require(nobay.transfer(msg.sender, amount), "Unstake transfer failed");
emit Unstaked(msg.sender, amount, block.timestamp);
}

/// @notice Slash a seller's active stake through the configured slashing module
/// @dev Restarts the seller cooldown on partial slash to prevent immediate exit after a dispute
/// @param seller Seller whose stake should be slashed
/// @param amount Number of staked $NOBAY tokens to slash
/// @param recipient Address receiving the slashed stake, usually the DAO treasury
/// @param reason Human-readable DAO/dispute reason emitted for indexers
function slash(
address seller,
uint256 amount,
address recipient,
string calldata reason
) external onlySlashingModule {
require(seller != address(0), "Invalid seller");
require(recipient != address(0), "Invalid recipient");
require(amount > 0, "Cannot slash zero");

Stake storage s = stakes[seller];
require(s.active, "No active stake");
require(s.amount >= amount, "Insufficient stake");

s.amount -= amount;
if (s.amount == 0) {
s.active = false;
} else {
s.timestamp = block.timestamp;
}

require(nobay.transfer(recipient, amount), "Slash transfer failed");
emit Slashed(seller, recipient, amount, reason, block.timestamp);
}

/// @notice Returns current trust tier based on stake amount
function getTier(address seller) public view returns (uint8) {
uint256 amount = stakes[seller].amount;
Expand All @@ -71,4 +110,15 @@ contract StakingModule is Ownable {
function setCooldown(uint256 newCooldown) external onlyOwner {
cooldownPeriod = newCooldown;
}

/// @notice Sets the module allowed to slash seller stakes
function setSlashingModule(address module) external onlyOwner {
slashingModule = module;
emit SlashingModuleUpdated(module);
}

/// @notice Returns the seller's current staked amount for external modules
function stakedBalance(address seller) external view returns (uint256) {
return stakes[seller].amount;
}
}
16 changes: 16 additions & 0 deletions docs/slashing-module.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Slashing Module

`SlashingModule` adds a DAO-compatible path for reducing seller stake after fraud or dispute outcomes.

## Flow

1. Deploy `SlashingModule` with the `StakingModule`, DAO executor, and slash-recipient treasury addresses.
2. Call `StakingModule.setSlashingModule(address(slashingModule))` from the staking owner or DAO.
3. Configure dispute resolvers with `setDisputeResolver`.
4. Slash either through `slashByDAO` after a DAO vote, or `slashByDispute` after an approved dispute resolver reaches an outcome.

Slashed stake is transferred to the configured recipient, usually the DAO treasury. Partial slashes restart the seller cooldown so the remaining stake cannot be withdrawn immediately after a dispute.

## Trust Decay

Sellers start at `MAX_TRUST_SCORE` and lose trust on each slash. `trustScore` applies a configurable per-day decay from the last checkpoint, while `applyTrustDecay` checkpoints the decayed score for indexers or frontends.