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
42 changes: 42 additions & 0 deletions Developer/rejected-ideas/stake-weighted-validator-selection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Rejected Idea: Stake-Weighted Validator Selection

**Tracking:** Issue #37 · staking-design.md §3.4 · MECHANISM_DESIGN §3
**Decision:** Rejected for DevNet 2.0. Stake gates eligibility only; it does not influence selection weight.

---

## Summary

Stake-weighted validator selection would assign higher probability of being selected for aggregation or auditing subgroups to validators who hold more DIN stake. This was considered as a way to align economic commitment with protocol influence.

**It is rejected.** Stake acts as a minimum eligibility bar — a validator must meet `minStake` to register — but all eligible validators above the floor are treated equally in selection.

---

## Why It Was Rejected

### 1. Concentrates work in whales

If selection probability scales with stake, validators with large balances receive disproportionately more assignment opportunities. Over time, work concentrates in a small set of high-stake operators, reducing the effective diversity of the validator set. DIN's security model depends on independent, uncorrelated validators producing scores and aggregations — concentration undermines that.

### 2. Weakens the cross-validator median

DIN's auditing layer relies on the median of independent evaluations to detect outliers and misbehaving aggregators. If high-stake validators are selected more often, the median is increasingly determined by a correlated subset. A coordinated group of high-stake validators could manipulate the median without triggering the outlier detection that a diverse random assignment would catch.

### 3. Creates a centralisation feedback loop

Higher stake → more assignments → more rewards → higher stake. This self-reinforcing dynamic tends toward a small oligopoly of validators and is inconsistent with the federated, decentralised nature of the DIN network.

### 4. Unnecessary for Sybil resistance

Stake's primary Sybil-resistance role is to make false-identity attacks expensive: an attacker needs `minStake` per identity to register multiple validators. This is fully achieved by the eligibility gate. Weighting adds no additional Sybil resistance while introducing the concentration problems above.

---

## What Stake Does Instead

- **Eligibility gate:** `getStake(validator) >= minStake` must hold for GI registration (and, once wired, `>= max(networkMin, modelMin)`).
- **Slashability:** Stake backs protocol commitments — misbehaviour costs real capital regardless of how much is staked above the floor.
- **Concurrent-registration cap:** `stake / slashableAmountPerRegistration` bounds how many GIs a single stake can back simultaneously (follow-up enforcement work).

Selection above the floor is random, producing an unbiased draw from the eligible validator set each GI. The selection engine design (randomness source, rotation, subgroup sizing) is tracked separately in `Developer/issues/validator_selection.md`.
101 changes: 92 additions & 9 deletions foundry/src/DinValidatorStake.sol
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,29 @@ contract DinValidatorStake is
error PendingWithdrawalExists();
error NoPendingWithdrawal();
error WithdrawalNotReady();
error InvalidMinStake();
error InvalidUnbondingPeriod();
error InvalidStakeBounds();
error InvalidJailDuration();
error NotJailed();
error JailPeriodNotExpired();
error StakeBelowFloor();

IERC20 public DIN_TOKEN;
address public DIN_COORDINATOR;

using SafeERC20 for IERC20;

uint256 public constant MIN_STAKE = 10 * 1e18;
uint64 public constant UNBONDING_PERIOD = 7 days;
// Governable parameters — set in initialize(), adjustable via setters (§5a)
uint256 public MIN_STAKE;
uint64 public UNBONDING_PERIOD;

// Unenforced stake-bounds storage — stored here for DAO governance round-trips;
// wiring into task-contract registration is explicit follow-up work (§5b)
struct ModelStakeBounds { uint256 min; uint256 max; }
mapping(uint256 => ModelStakeBounds) public modelMinStakeBounds;
uint256 public maxConcurrentRegistrationsPerStakeUnit;

mapping(address => bool) public slasherContracts;

enum ValidatorStatus {
Expand Down Expand Up @@ -72,11 +87,18 @@ contract DinValidatorStake is
event ValidatorUnblacklisted(address indexed validator);
event SlasherContractAdded(address indexed slasher);
event SlasherContractRemoved(address indexed slasher);
event MinStakeUpdated(uint256 newMinStake);
event UnbondingPeriodUpdated(uint64 newPeriod);
event ModelStakeBoundsUpdated(uint256 indexed modelId, uint256 min, uint256 max);
event MaxConcurrentRegistrationsPerStakeUnitUpdated(uint256 value);
event ValidatorJailed(address indexed validator, uint64 jailedUntil, bytes32 indexed reason, address indexed slasher);
event ValidatorReactivated(address indexed validator);

mapping(address => ValidatorInfo) public validators;

// Reserved for future state variables at this inheritance level.
uint256[50] private __gap;
// Reduced from [50] by 4: MIN_STAKE, UNBONDING_PERIOD, modelMinStakeBounds, maxConcurrentRegistrationsPerStakeUnit
uint256[46] private __gap;

/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
Expand All @@ -96,6 +118,8 @@ contract DinValidatorStake is
__Ownable_init(msg.sender);
DIN_TOKEN = IERC20(dinToken);
DIN_COORDINATOR = dinCoordinator;
MIN_STAKE = 10 * 1e18;
UNBONDING_PERIOD = 7 days;
}

modifier onlyDinCoordinator() {
Expand Down Expand Up @@ -277,11 +301,71 @@ contract DinValidatorStake is
}

/// @notice Returns the minimum token amount required to become an active validator.
/// @return The MIN_STAKE constant in wei.
function minStake() external pure returns (uint256) {
function minStake() external view returns (uint256) {
return MIN_STAKE;
}

/// @notice Updates the network-wide minimum stake floor.
function setMinStake(uint256 newMinStake) external onlyOwner {
if (newMinStake == 0) revert InvalidMinStake();
MIN_STAKE = newMinStake;
emit MinStakeUpdated(newMinStake);
}

/// @notice Updates the unbonding period applied to new unstake requests.
/// @dev Does not retroactively affect in-flight pending withdrawals whose
/// withdrawAvailableAt was already computed at unstake() time.
function setUnbondingPeriod(uint64 newPeriod) external onlyOwner {
if (newPeriod == 0) revert InvalidUnbondingPeriod();
UNBONDING_PERIOD = newPeriod;
emit UnbondingPeriodUpdated(newPeriod);
}

/// @notice Sets DAO-level per-model stake bounds for a given model ID.
/// @dev Inert storage only — not yet consumed by task-contract registration.
function setModelStakeBounds(uint256 modelId, uint256 min, uint256 max) external onlyOwner {
if (min > max) revert InvalidStakeBounds();
modelMinStakeBounds[modelId] = ModelStakeBounds(min, max);
emit ModelStakeBoundsUpdated(modelId, min, max);
}

/// @notice Sets the DAO-level concurrent-registration cap per stake unit.
/// @dev Inert storage only — not yet consumed by task-contract registration.
function setMaxConcurrentRegistrationsPerStakeUnit(uint256 value) external onlyOwner {
maxConcurrentRegistrationsPerStakeUnit = value;
emit MaxConcurrentRegistrationsPerStakeUnitUpdated(value);
}

/// @notice Jails a validator for a fixed duration, preventing GI registration.
/// @dev Callable by registered slasher contracts only. Extends an existing jail;
/// never shortens it. Cannot jail a blacklisted validator.
function jailValidator(address validator, uint64 duration, bytes32 reason)
external onlySlasherContract
{
if (validator == address(0)) revert InvalidAddress();
if (duration == 0) revert InvalidJailDuration();
ValidatorInfo storage v = validators[validator];
if (v.status == ValidatorStatus.Blacklisted) revert ValidatorIsBlacklisted();
uint64 newJailedUntil = uint64(block.timestamp) + duration;
if (newJailedUntil > v.jailedUntil) v.jailedUntil = newJailedUntil;
v.status = ValidatorStatus.Jailed;
emit ValidatorJailed(validator, v.jailedUntil, reason, msg.sender);
}

/// @notice Explicitly re-activates the caller after their jail period expires.
/// @dev Operator must call this themselves to confirm the node is back online.
/// Reverts if still jailed, or if stake has dropped below the current floor.
function reactivate() external nonReentrant {
ValidatorInfo storage v = validators[msg.sender];
if (v.status != ValidatorStatus.Jailed) revert NotJailed();
if (block.timestamp < v.jailedUntil) revert JailPeriodNotExpired();
if (v.activeStake < MIN_STAKE) revert StakeBelowFloor();
v.jailedUntil = 0;
v.status = ValidatorStatus.None; // clear Jailed before sync so _sync can set the correct status
_syncValidatorStatus(v);
emit ValidatorReactivated(msg.sender);
}

/// @notice Returns true if the validator's status is Active.
/// @param validator Address to query.
/// @return True if the validator is currently in the Active state.
Expand Down Expand Up @@ -320,10 +404,9 @@ contract DinValidatorStake is
return;
}

if (
validator.status == ValidatorStatus.Jailed &&
validator.jailedUntil > block.timestamp
) {
// Jailed validators stay Jailed until reactivate() explicitly clears the status.
// No silent auto-return: the operator must prove the node is back online first.
if (validator.status == ValidatorStatus.Jailed) {
return;
}

Expand Down
Loading