diff --git a/Developer/rejected-ideas/stake-weighted-validator-selection.md b/Developer/rejected-ideas/stake-weighted-validator-selection.md new file mode 100644 index 0000000..b883107 --- /dev/null +++ b/Developer/rejected-ideas/stake-weighted-validator-selection.md @@ -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`. diff --git a/foundry/src/DinValidatorStake.sol b/foundry/src/DinValidatorStake.sol index 98dccc0..79c012f 100644 --- a/foundry/src/DinValidatorStake.sol +++ b/foundry/src/DinValidatorStake.sol @@ -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 { @@ -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() { @@ -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() { @@ -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. @@ -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; } diff --git a/foundry/test/DinValidatorStake.t.sol b/foundry/test/DinValidatorStake.t.sol new file mode 100644 index 0000000..9241e52 --- /dev/null +++ b/foundry/test/DinValidatorStake.t.sol @@ -0,0 +1,354 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {Test} from "forge-std/Test.sol"; +import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; + +import {DinToken} from "../src/DinToken.sol"; +import {DinCoordinator} from "../src/DinCoordinator.sol"; +import {DinValidatorStake} from "../src/DinValidatorStake.sol"; + +contract DinValidatorStakeTest is Test { + DinToken token; + DinCoordinator coordinator; + DinValidatorStake stake; + + address alice = makeAddr("alice"); + address bob = makeAddr("bob"); + + // Registered as a slasher so tests can call jailValidator/slash + address slasher = makeAddr("slasher"); + + uint256 constant FLOOR = 10e18; // initial MIN_STAKE + + function setUp() public { + // ── DinToken ──────────────────────────────────────────────────────── + DinToken tokenImpl = new DinToken(); + token = DinToken( + address( + new TransparentUpgradeableProxy( + address(tokenImpl), + address(this), + abi.encodeCall(DinToken.initialize, ()) + ) + ) + ); + + // ── DinCoordinator ────────────────────────────────────────────────── + DinCoordinator coordImpl = new DinCoordinator(); + coordinator = DinCoordinator( + payable( + address( + new TransparentUpgradeableProxy( + address(coordImpl), + address(this), + abi.encodeCall(DinCoordinator.initialize, (address(token))) + ) + ) + ) + ); + token.setCoordinator(address(coordinator)); + + // ── DinValidatorStake ─────────────────────────────────────────────── + DinValidatorStake stakeImpl = new DinValidatorStake(); + stake = DinValidatorStake( + address( + new TransparentUpgradeableProxy( + address(stakeImpl), + address(this), + abi.encodeCall( + DinValidatorStake.initialize, + (address(token), address(coordinator)) + ) + ) + ) + ); + coordinator.updateValidatorStakeContract(address(stake)); + + // Register slasher via coordinator + coordinator.addSlasherContract(slasher); + + // Fund alice and bob with DIN for staking + vm.deal(alice, 10 ether); + vm.deal(bob, 10 ether); + vm.prank(alice); + coordinator.depositAndMint{value: 1 ether}(); + vm.prank(bob); + coordinator.depositAndMint{value: 1 ether}(); + vm.prank(alice); + token.approve(address(stake), type(uint256).max); + vm.prank(bob); + token.approve(address(stake), type(uint256).max); + } + + // ── setMinStake ─────────────────────────────────────────────────────────── + + function test_setMinStake_onlyOwner() public { + vm.prank(alice); + vm.expectRevert(); + stake.setMinStake(5e18); + } + + function test_setMinStake_revertsOnZero() public { + vm.expectRevert(DinValidatorStake.InvalidMinStake.selector); + stake.setMinStake(0); + } + + function test_setMinStake_updatesStorage() public { + stake.setMinStake(20e18); + assertEq(stake.MIN_STAKE(), 20e18); + assertEq(stake.minStake(), 20e18); + } + + // ── setUnbondingPeriod ──────────────────────────────────────────────────── + + function test_setUnbondingPeriod_onlyOwner() public { + vm.prank(alice); + vm.expectRevert(); + stake.setUnbondingPeriod(1 days); + } + + function test_setUnbondingPeriod_revertsOnZero() public { + vm.expectRevert(DinValidatorStake.InvalidUnbondingPeriod.selector); + stake.setUnbondingPeriod(0); + } + + function test_setUnbondingPeriod_updatesStorage() public { + stake.setUnbondingPeriod(14 days); + assertEq(stake.UNBONDING_PERIOD(), 14 days); + } + + function test_setUnbondingPeriod_doesNotAffectExistingWithdrawal() public { + // Alice stakes and requests unstake — records withdrawAvailableAt = now + 7d + vm.prank(alice); + stake.stake(FLOOR); + vm.prank(alice); + stake.unstake(FLOOR); + + (, , uint64 withdrawAvailableAt, , ) = stake.validators(alice); + uint64 expectedAt = uint64(block.timestamp + 7 days); + assertEq(withdrawAvailableAt, expectedAt); + + // Owner changes period to 30 days — existing withdrawal unaffected + stake.setUnbondingPeriod(30 days); + (, , uint64 stillSame, , ) = stake.validators(alice); + assertEq(stillSame, expectedAt); + } + + // ── setModelStakeBounds ─────────────────────────────────────────────────── + + function test_setModelStakeBounds_onlyOwner() public { + vm.prank(alice); + vm.expectRevert(); + stake.setModelStakeBounds(1, 10e18, 100e18); + } + + function test_setModelStakeBounds_revertsWhenMinGtMax() public { + vm.expectRevert(DinValidatorStake.InvalidStakeBounds.selector); + stake.setModelStakeBounds(1, 100e18, 10e18); + } + + function test_setModelStakeBounds_roundTrip() public { + stake.setModelStakeBounds(42, 5e18, 50e18); + (uint256 min, uint256 max) = stake.modelMinStakeBounds(42); + assertEq(min, 5e18); + assertEq(max, 50e18); + } + + function test_setModelStakeBounds_doesNotAffectStaking() public { + // Setting bounds must not change whether a validator can stake/register + stake.setModelStakeBounds(1, 999e18, 9999e18); + // Alice can still stake at the network floor with no issue + vm.prank(alice); + stake.stake(FLOOR); + assertTrue(stake.isValidatorActive(alice)); + } + + // ── setMaxConcurrentRegistrationsPerStakeUnit ───────────────────────────── + + function test_setMaxConcurrent_onlyOwner() public { + vm.prank(alice); + vm.expectRevert(); + stake.setMaxConcurrentRegistrationsPerStakeUnit(3); + } + + function test_setMaxConcurrent_roundTrip() public { + stake.setMaxConcurrentRegistrationsPerStakeUnit(5); + assertEq(stake.maxConcurrentRegistrationsPerStakeUnit(), 5); + } + + function test_setMaxConcurrent_doesNotAffectStaking() public { + stake.setMaxConcurrentRegistrationsPerStakeUnit(1); + vm.prank(alice); + stake.stake(FLOOR); + assertTrue(stake.isValidatorActive(alice)); + } + + // ── jailValidator ───────────────────────────────────────────────────────── + + function test_jailValidator_onlySlasher() public { + vm.prank(alice); + stake.stake(FLOOR); + vm.prank(alice); // alice is not a slasher + vm.expectRevert(DinValidatorStake.NotSlasherContract.selector); + stake.jailValidator(alice, 1 days, bytes32("bad")); + } + + function test_jailValidator_revertsOnZeroAddress() public { + vm.prank(slasher); + vm.expectRevert(DinValidatorStake.InvalidAddress.selector); + stake.jailValidator(address(0), 1 days, bytes32("bad")); + } + + function test_jailValidator_revertsOnZeroDuration() public { + vm.prank(alice); + stake.stake(FLOOR); + vm.prank(slasher); + vm.expectRevert(DinValidatorStake.InvalidJailDuration.selector); + stake.jailValidator(alice, 0, bytes32("bad")); + } + + function test_jailValidator_revertsForBlacklisted() public { + vm.prank(alice); + stake.stake(FLOOR); + stake.blacklistValidator(alice); + vm.prank(slasher); + vm.expectRevert(DinValidatorStake.ValidatorIsBlacklisted.selector); + stake.jailValidator(alice, 1 days, bytes32("bad")); + } + + function test_jailValidator_setsJailedStatus() public { + vm.prank(alice); + stake.stake(FLOOR); + assertTrue(stake.isValidatorActive(alice)); + + vm.prank(slasher); + stake.jailValidator(alice, 1 days, bytes32("offline")); + + (, , , , DinValidatorStake.ValidatorStatus status) = stake.validators(alice); + assertEq(uint8(status), uint8(DinValidatorStake.ValidatorStatus.Jailed)); + assertFalse(stake.isValidatorActive(alice)); + } + + function test_jailValidator_extendsExistingJail() public { + vm.prank(alice); + stake.stake(FLOOR); + + // First jail: 1 day + vm.prank(slasher); + stake.jailValidator(alice, 1 days, bytes32("first")); + (, , , uint64 jailedUntilFirst, ) = stake.validators(alice); + + // Second jail: 3 days — should extend (set further out) + vm.prank(slasher); + stake.jailValidator(alice, 3 days, bytes32("second")); + (, , , uint64 jailedUntilSecond, ) = stake.validators(alice); + assertGt(jailedUntilSecond, jailedUntilFirst); + } + + function test_jailValidator_neverShortensExistingJail() public { + vm.prank(alice); + stake.stake(FLOOR); + + // Long jail first: 7 days + vm.prank(slasher); + stake.jailValidator(alice, 7 days, bytes32("long")); + (, , , uint64 longJail, ) = stake.validators(alice); + + // Short jail attempt: 1 day — should NOT shorten + vm.prank(slasher); + stake.jailValidator(alice, 1 days, bytes32("short")); + (, , , uint64 stillLong, ) = stake.validators(alice); + assertEq(stillLong, longJail); + } + + // ── reactivate ──────────────────────────────────────────────────────────── + + function test_reactivate_revertsIfNotJailed() public { + vm.prank(alice); + stake.stake(FLOOR); + vm.prank(alice); + vm.expectRevert(DinValidatorStake.NotJailed.selector); + stake.reactivate(); + } + + function test_reactivate_revertsIfJailPeriodNotExpired() public { + vm.prank(alice); + stake.stake(FLOOR); + vm.prank(slasher); + stake.jailValidator(alice, 1 days, bytes32("bad")); + + vm.prank(alice); + vm.expectRevert(DinValidatorStake.JailPeriodNotExpired.selector); + stake.reactivate(); + } + + function test_reactivate_revertsIfStakeBelowFloor() public { + vm.prank(alice); + stake.stake(FLOOR); + + // Slash alice below floor then jail her + vm.prank(slasher); + stake.slash(alice, FLOOR, bytes32("penalised")); + vm.prank(slasher); + stake.jailValidator(alice, 1 days, bytes32("offline")); + + vm.warp(block.timestamp + 1 days + 1); + vm.prank(alice); + vm.expectRevert(DinValidatorStake.StakeBelowFloor.selector); + stake.reactivate(); + } + + function test_reactivate_fullLifecycle() public { + // 1. Stake + vm.prank(alice); + stake.stake(FLOOR); + assertTrue(stake.isValidatorActive(alice)); + + // 2. Jail for 2 days + vm.prank(slasher); + stake.jailValidator(alice, 2 days, bytes32("liveness")); + assertFalse(stake.isValidatorActive(alice)); + + // 3. Early reactivate fails + vm.warp(block.timestamp + 1 days); + vm.prank(alice); + vm.expectRevert(DinValidatorStake.JailPeriodNotExpired.selector); + stake.reactivate(); + + // 4. After jail expires, reactivate succeeds + vm.warp(block.timestamp + 1 days + 1); + vm.prank(alice); + stake.reactivate(); + assertTrue(stake.isValidatorActive(alice)); + + // 5. jailedUntil cleared + (, , , uint64 jailedUntil, ) = stake.validators(alice); + assertEq(jailedUntil, 0); + } + + function test_reactivate_requiresTopUpIfSlashedBelowFloor() public { + vm.prank(alice); + stake.stake(FLOOR); + + // Slash half, then jail + vm.prank(slasher); + stake.slash(alice, FLOOR / 2, bytes32("penalised")); + vm.prank(slasher); + stake.jailValidator(alice, 1 days, bytes32("offline")); + + vm.warp(block.timestamp + 1 days + 1); + + // Still below floor — fails + vm.prank(alice); + vm.expectRevert(DinValidatorStake.StakeBelowFloor.selector); + stake.reactivate(); + + // Top up — each stake() call requires >= MIN_STAKE, so stake the full floor amount + vm.prank(alice); + stake.stake(FLOOR); + vm.prank(alice); + stake.reactivate(); + assertTrue(stake.isValidatorActive(alice)); + } +}