diff --git a/.env.example b/.env.example index 87adc4b1..df7fee0d 100644 --- a/.env.example +++ b/.env.example @@ -42,3 +42,5 @@ MAINNET_RPC_URL= # S5_RECIDIVISM_THRESHOLD=3 # slashes in window to trigger S5 jail (default: 3) # S5_JAIL_DURATION=604800 # jail duration in seconds (default: 604800 = 7 days) # S6_NO_PARTICIPATION_THRESHOLD=3 # no-participation events before S6 fires (default: 3) +# S5_GLOBAL_WINDOW=604800 # seconds over which S5 counts partial slashes from ALL slasher contracts (default: 604800 = 7 days; 0 with threshold 0 = off) +# S5_GLOBAL_THRESHOLD=6 # partial slashes in that window to trigger S5 jail (default: 6; 0 with window 0 = off) diff --git a/Developer/design/MECHANISM_DESIGN.md b/Developer/design/MECHANISM_DESIGN.md index 52775e85..d2b841b5 100644 --- a/Developer/design/MECHANISM_DESIGN.md +++ b/Developer/design/MECHANISM_DESIGN.md @@ -78,13 +78,14 @@ Grounding: everything below references `foundry/src/` as of `develop`. | S2 | Missed T1/T2 aggregation submission | Aggregator | On-chain (already implemented) | Partial | | S3 | Score deviation beyond threshold from cross-auditor median (per model, per GI) | Auditor | On-chain comparison at `finalizeEvaluation` — requires median computed on-chain | Partial → Full on repeat | | S4 | Invalid aggregation: submitted T1/T2 CID whose recomputation from inputs fails verification | Aggregator | Off-chain challenge + on-chain dispute (S4 is the main dispute-resolution consumer) | Full | -| S5 | Repeated liveness failures (≥ `r` partial slashes within `w` GIs) | Both | On-chain counter | Full + jail/blacklist | +| S5 | Repeated liveness failures (≥ `r` partial slashes within `w` GIs from one slasher contract, or ≥ `r_g` within a time window `w_g` across all models; issue No. 193) | Both | On-chain counter | Full + jail/blacklist | | S6 | Registration without capacity (registers, never participates, across models) | Both | On-chain counter | Partial, escalating | ### Penalty tiers - **Partial slash:** fixed fraction of `minStake` (e.g., 25–50%), not the flat `minStake()` used today — today's flat amount fully unstakes a floor-staked validator for one missed vote, which over-punishes liveness faults. Make the fraction a DAO parameter. - **Full slash:** entire slashable stake (active + unbonding) + blacklist. Reserved for provable malice (S4) and recidivism (S5). + - *Implementation note (issue No. 193 review):* the S5 escalation in `DinValidatorStake.slashPartial` slashes `MIN_STAKE` (not the entire slashable stake) and jails for `s5JailDuration` (7 days by default) rather than blacklisting. Recorded here, not changed; aligning the two is a mechanism decision. - **Jailing:** `_syncValidatorStatus` already produces a Jailed state below min stake; keep — jailed validators cannot register until they top up. ### Slashed-stake destination (resolved, P3-4.2) diff --git a/Developer/design/adversarial-threat-model.md b/Developer/design/adversarial-threat-model.md index 1b441003..bc48e808 100644 --- a/Developer/design/adversarial-threat-model.md +++ b/Developer/design/adversarial-threat-model.md @@ -44,7 +44,7 @@ entry in `foundry/test/P3Adversarial.t.sol`. |---|---| | **Attacker** | Validator repeatedly misses S1 votes (or S2 submissions) across GIs | | **Capability** | Registers each GI, never votes; tolerates a partial slash each time | -| **Protocol response today** | `slashPartial` in `DinValidatorStake.sol:294` maintains a per-validator, **per-slasher-contract** rolling GI list (`_partialSlashGIs[validator][msg.sender]`). When slashes within the last `s5RecidivismWindow` (default: 5) GIs for that slasher reach `s5RecidivismThreshold` (default: 3), the slash is escalated to full `MIN_STAKE` and the validator is jailed for `s5JailDuration` (default: 7 days). `isValidatorActive` returns `false` while jailed, blocking re-registration. | +| **Protocol response today** | `slashPartial` in `DinValidatorStake.sol:340` maintains a per-validator, **per-slasher-contract** rolling GI list (`_partialSlashGIs[validator][msg.sender]`). When slashes within the last `s5RecidivismWindow` (default: 5) GIs for that slasher reach `s5RecidivismThreshold` (default: 3), the slash is escalated to full `MIN_STAKE` and the validator is jailed for `s5JailDuration` (default: 7 days). `isValidatorActive` returns `false` while jailed, blocking re-registration. | | **Expected test outcome** | **DEFENDED** — the test runs the attacker through exactly `s5RecidivismThreshold` GIs of missed votes (3 missed GIs). Escalation fires on the **3rd miss** (the threshold-th slash, not threshold+1). After the 3rd slash, `isValidatorActive` is `false` and a 4th registration attempt reverts. | | **Trust assumption** | S5 counter is per-slasher-contract (per model). A validator who spreads misses across different models' task contracts (≤ `s5RecidivismThreshold − 1` per model per window) never reaches the escalation threshold for any single model. This is a gap in the multi-model case (see gaps-before-audit). | @@ -139,10 +139,10 @@ entry in `foundry/test/P3Adversarial.t.sol`. | Field | Value | |---|---| | **Attacker** | Validator registered across multiple models, spreading misses so no single model's S5 ring reaches threshold | -| **Capability** | Two or more models' task contracts are separate slashers; `_partialSlashGIs[validator][msg.sender]` is namespaced per slasher (`DinValidatorStake.sol:309`). With default threshold 3, ≤ 2 misses per model per window avoids S5 on every individual model. | -| **Protocol response today** | None — S5 is per-slasher-contract, not aggregate across models. A validator can miss `(s5RecidivismThreshold − 1) × N` total votes across N models before any single model escalates. | -| **Expected test outcome** | **KNOWN GAP** ([#193](https://github.com/InfiniteZeroFoundation/DevNet/issues/193)) — the test sets up two task contracts (two models), has a validator miss threshold−1 votes on each, and asserts S5 never fires and `isValidatorActive` remains true. | -| **Trust assumption** | S5 counter would need to be aggregated across all models (or moved to a global platform-level counter) to close this gap. | +| **Capability** | Two or more models' task contracts are separate slashers; `_partialSlashGIs[validator][msg.sender]` is namespaced per slasher (`DinValidatorStake.sol:355`). With default threshold 3, ≤ 2 misses per model per window avoids S5 on every individual model. | +| **Protocol response today** | Two-level S5 ([#193](https://github.com/InfiniteZeroFoundation/DevNet/issues/193)): besides the per-slasher GI ring, `slashPartial` counts every partial slash for the validator in one time-windowed ring shared by all slasher contracts (`_partialSlashTimes`; `s5GlobalWindow` 7 days, `s5GlobalThreshold` 6 by default). Reaching either threshold escalates (full `MIN_STAKE` + jail) and emits `ValidatorEscalatedS5Global` when only the global level fired. The same ring also adds up one model's S1 (auditor contract) and S2 (coordinator) misses. | +| **Expected test outcome** | **DEFENDED above the global threshold.** With defaults, a validator escalates on its 6th partial slash within 7 days, however the misses are spread (`SlashingInvariants.t.sol` `test_s5Global_escalatesAcrossSlashers`, `test_s5Global_addsUpTwoSlashersOfOneModel`). Below it the gap is bounded, not closed: up to 5 misses across models in a window only pay their partial slashes. PR #218's `test_knownGap_perSlasherS5Evasion` (2 models × 2 misses = 4 < 6) still passes as written; when it rebases it should spread misses until the global count reaches `s5GlobalThreshold` and assert escalation (`test_defended_…`). | +| **Trust assumption** | `s5GlobalWindow` / `s5GlobalThreshold` are calibrated (placeholders until [#155](https://github.com/InfiniteZeroFoundation/DevNet/issues/155)). On a proxy upgraded from an earlier version both read 0 (global level off) until the owner sets them. | --- @@ -169,7 +169,7 @@ entry in `foundry/test/P3Adversarial.t.sol`. | Decentralized dispute adjudication | [#181](https://github.com/InfiniteZeroFoundation/DevNet/issues/181) | Row 7 (owner suppresses dispute) and bond-locked-forever variant | `resolveDispute` gated by multi-sig, DAO committee, or fraud-proof; timeout added for unresolved disputes | | Dispute timeout for unresolved disputes | [#181](https://github.com/InfiniteZeroFoundation/DevNet/issues/181) | Row 7 (owner never resolves → bond locked) | `expireDispute` (or a new function) reachable when `d.resolved == false` after a deadline | | BlockFLow duplicate-update scoring | [#39](https://github.com/InfiniteZeroFoundation/DevNet/issues/39) | Row 10 (duplicate-update gaming) | BlockFLow fold-in scoring implemented | -| Per-slasher S5 ring evasion | [#193](https://github.com/InfiniteZeroFoundation/DevNet/issues/193) | Row 11 (multi-model S5 evasion) | S5 counter aggregated globally or per-validator across models | +| Per-slasher S5 ring evasion | [#193](https://github.com/InfiniteZeroFoundation/DevNet/issues/193) | Row 11 (multi-model S5 evasion) | **Flipped by task_061026_20 Part C:** per-validator global ring across all slashers (7 days / 6 by default). Misses below the global threshold remain bounded by partial slashes; thresholds pending #155 | | Mainnet VRF for dispute and batch seeds | [#178](https://github.com/InfiniteZeroFoundation/DevNet/issues/178) | Rows 2, 6, 8 (trust assumption TA-4) | `lockDisputeSeed`, `lockAggSeed` and `lockAuditSeed` upgraded to VRF or multi-party commit-reveal | --- diff --git a/Developer/tasks/task_061026_20.md b/Developer/tasks/task_061026_20.md index 810c8115..77f45075 100644 --- a/Developer/tasks/task_061026_20.md +++ b/Developer/tasks/task_061026_20.md @@ -128,7 +128,7 @@ There are two ways back out of the warn band: a fold that actually saves bytes, - This also closes the same-model split: one validator's S1 and S2 misses now add up. **Scope:** -- **Storage.** Append the new variables before `__gap` (`:174`, 50 slots) and shrink the gap by the slots used. `DinValidatorStakeV2` inherits them. `UpgradeValidation.t.sol` and the `DeployPlatform.t.sol` upgrade tests must stay green. +- **Storage.** Append the new variables before `__gap` (`:174`, 50 slots) and shrink the gap by the slots used. *(Amended at PR No. 236: `__gap` stays at 50. Nothing on `develop` is deployed yet, so per the "Core rules" in `storage_layout.md` new variables go above `__gap` without shrinking it, as `07fd597` did for `DinEmission`.)* `DinValidatorStakeV2` inherits them. `UpgradeValidation.t.sol` and the `DeployPlatform.t.sol` upgrade tests must stay green. - **Setter.** `setS5GlobalParams(window, threshold)`, validated like `setS5RecidivismParams` (`:583-594`). - **Defaults.** `s5GlobalWindow = 7 days` and `s5GlobalThreshold = 6` (2 × `s5RecidivismThreshold`). These are placeholders until #155. - Set them in `initialize`. diff --git a/Documentation/public/roles/dinrep.md b/Documentation/public/roles/dinrep.md index 6e60a0d2..4e518960 100644 --- a/Documentation/public/roles/dinrep.md +++ b/Documentation/public/roles/dinrep.md @@ -76,7 +76,7 @@ Optimism Sepolia is a public chain, so there is no anvil step. Instead you need - **Funds.** The address needs Optimism Sepolia ETH. A full deploy is 22 transactions: seven implementations, seven proxies, and eight wiring calls. Each proxy creates its own ProxyAdmin inside its constructor, so the ProxyAdmins need no transactions of their own. -- **Tokenomics parameters.** `DeployPlatform.s.sol` reads 11 tokenomics settings from the environment: `DIN_PER_ETH`, `MINT_CAP`, the four `EMISSION_*` keys, `MIN_STAKE`, the three `S5_*` keys and `S6_NO_PARTICIPATION_THRESHOLD`. Any key that isn't set falls back to the in-code default; for example, `MINT_CAP` defaults to `0`, which means uncapped. Set the values chosen for this network in `.env.sepolia_op_devnet` (loaded above) or in `foundry/.env` (forge loads it automatically). The script logs an `[INFO] not set` line for each missing key and ends with a `--- Effective tokenomics ---` block. See [DeployPlatform.md](../../technical/contracts/foundry/script/DeployPlatform.md) for every key and its default. +- **Tokenomics parameters.** `DeployPlatform.s.sol` reads 13 tokenomics settings from the environment: `DIN_PER_ETH`, `MINT_CAP`, the four `EMISSION_*` keys, `MIN_STAKE`, the three `S5_RECIDIVISM_*`/`S5_JAIL_DURATION` keys, the two `S5_GLOBAL_*` keys and `S6_NO_PARTICIPATION_THRESHOLD`. Any key that isn't set falls back to the in-code default; for example, `MINT_CAP` defaults to `0`, which means uncapped. Set the values chosen for this network in `.env.sepolia_op_devnet` (loaded above) or in `foundry/.env` (forge loads it automatically). The script logs an `[INFO] not set` line for each missing key and ends with a `--- Effective tokenomics ---` block. See [DeployPlatform.md](../../technical/contracts/foundry/script/DeployPlatform.md) for every key and its default. **2. Deploy:** diff --git a/Documentation/technical/contracts/DinValidatorStake.md b/Documentation/technical/contracts/DinValidatorStake.md index 71028deb..86a9428e 100644 --- a/Documentation/technical/contracts/DinValidatorStake.md +++ b/Documentation/technical/contracts/DinValidatorStake.md @@ -56,9 +56,11 @@ Its central safety property: a validator cannot misbehave and then withdraw befo | `s5RecidivismWindow` | `5` GIs | `setS5RecidivismParams` | Rolling window for counting partial slashes | | `s5RecidivismThreshold` | `3` | `setS5RecidivismParams` (`0 < threshold ≤ window`) | Partial slashes within the window that trigger escalation | | `s5JailDuration` | `7 days` | `setS5RecidivismParams` (non-zero) | Jail length applied on escalation | +| `s5GlobalWindow` | `7 days` | `setS5GlobalParams` (both 0 = off, or both > 0) | Time window over which partial slashes from **every** slasher contract are counted (issue No. 193) | +| `s5GlobalThreshold` | `6` | `setS5GlobalParams` | Partial slashes within `s5GlobalWindow`, from any slasher contract, that trigger escalation. `0` = global level off, which is also what a proxy upgraded from an earlier version reads until the setter is called | | `s6NoParticipationThreshold` | `3` | `setS6NoParticipationThreshold` (non-zero) | No-participation count at which S6 slashing starts | -`MIN_STAKE` (10 DIN) is one of the testnet values still to be decided (issue #155). `DeployPlatform.s.sol` can override `MIN_STAKE` and the S5/S6 parameters at deploy time from environment variables (`MIN_STAKE`, `S5_RECIDIVISM_WINDOW`, `S5_RECIDIVISM_THRESHOLD`, `S5_JAIL_DURATION`, `S6_NO_PARTICIPATION_THRESHOLD`). +`MIN_STAKE` (10 DIN) is one of the testnet values still to be decided (issue #155). `DeployPlatform.s.sol` can override `MIN_STAKE` and the S5/S6 parameters at deploy time from environment variables (`MIN_STAKE`, `S5_RECIDIVISM_WINDOW`, `S5_RECIDIVISM_THRESHOLD`, `S5_JAIL_DURATION`, `S5_GLOBAL_WINDOW`, `S5_GLOBAL_THRESHOLD`, `S6_NO_PARTICIPATION_THRESHOLD`). ### 3.3 Per-validator state @@ -69,7 +71,10 @@ Its central safety property: a validator cannot misbehave and then withdraw befo | `encryptionKeys` | `mapping(address => bytes)` | Registered 32-byte X25519 public keys | | `activeRegistrationCount` | `mapping(address => uint256)` | Open GI registrations across all task contracts | | `s6NoParticipationCount` | `mapping(address => uint256)` | Lifetime no-participation count (never decays) | -| `_partialSlashGIs` | `mapping(address => mapping(address => uint256[]))` (private) | Partial-slash GI indices per validator **per calling slasher contract** (S5 ring) | +| `_partialSlashGIs` | `mapping(address => mapping(address => uint256[]))` (private) | Partial-slash GI indices per validator **per calling slasher contract** (S5 per-slasher ring) | +| `_partialSlashTimes` | `mapping(address => uint256[])` (private) | Partial-slash block timestamps per validator across **all** slasher contracts (S5 global ring, issue No. 193) | +| `s5EscalationEpoch` | `mapping(address => uint256)` | Number of S5 escalations (either level) per validator; an escalation settles every slasher's ring | +| `_partialSlashEpoch` | `mapping(address => mapping(address => uint256))` (private) | The `s5EscalationEpoch` each per-slasher ring was last written in; a ring from an earlier epoch is stale | | `__gap` | `uint256[50]` | Reserved slots | Slot order is in [storage_layout.md](../storage_layout.md#dinvalidatorstake). @@ -120,7 +125,7 @@ Once a jail has expired, *any* call that syncs status (e.g. `stake`) recomputes | `stake`, `unstake`, `claimUnstaked`, `reactivate`, `registerEncryptionKey` | Any address, acting on its own record | | `addSlasherContract`, `removeSlasherContract` | `DIN_COORDINATOR` only (`NotDINCoordinator`) | | `slash`, `slashPartial`, `recordNoParticipation`, `jailValidator`, `incrementActiveRegistration`, `decrementActiveRegistration` | Registered slasher contracts only (`NotSlasherContract`) | -| `blacklistValidator`, `unblacklistValidator`, `setMinStake`, `setUnbondingPeriod`, `setModelStakeBounds`, `setMaxConcurrentRegistrationsPerStakeUnit`, `setSlashTreasury`, `setS5RecidivismParams`, `setS6NoParticipationThreshold` | `owner()` (DIN-Representative) | +| `blacklistValidator`, `unblacklistValidator`, `setMinStake`, `setUnbondingPeriod`, `setModelStakeBounds`, `setMaxConcurrentRegistrationsPerStakeUnit`, `setSlashTreasury`, `setS5RecidivismParams`, `setS5GlobalParams`, `setS6NoParticipationThreshold` | `owner()` (DIN-Representative) | --- @@ -157,11 +162,12 @@ For faults like bad consensus or S3 score deviation. Reverts on zero address / z Used by the task contracts for liveness faults (auditor didn't reveal a vote — S1; aggregator didn't reveal a CID — S2). `amount` is computed by the caller as a fraction of the global `MIN_STAKE`: `minStake() × s1SlashFractionBps / 10 000` (auditor) or `× s2SlashFractionBps` (aggregator), with the fractions set per model on the task contracts. -1. Appends `giIndex` to `_partialSlashGIs[validator][msg.sender]` and trims entries with `giIndex − entry ≥ s5RecidivismWindow`. -2. If the ring length reaches `s5RecidivismThreshold` → **S5 escalation**: slash a full `MIN_STAKE` (reason `S5_RECIDIVISM`), jail for `s5JailDuration`, emit `ValidatorEscalatedS5`, and clear this caller's ring. -3. Otherwise slash `amount` with the given reason. +1. If this caller's ring is from an earlier `s5EscalationEpoch`, drops it first. Then appends `giIndex` to `_partialSlashGIs[validator][msg.sender]` and trims entries with `giIndex − entry ≥ s5RecidivismWindow` (**per-slasher level**). +2. If `s5GlobalThreshold ≠ 0`, also appends `block.timestamp` to `_partialSlashTimes[validator]` and trims entries with `now − entry ≥ s5GlobalWindow` (**global level**, issue No. 193). +3. If the per-slasher ring reaches `s5RecidivismThreshold`, **or** the global ring reaches `s5GlobalThreshold` → **S5 escalation**: slash a full `MIN_STAKE` (reason `S5_RECIDIVISM`), jail for `s5JailDuration`, increment `s5EscalationEpoch[validator]`, and clear both this caller's ring and the global ring. The other slashers' rings are now stale, and each is dropped on that slasher's next call (step 1), so the misses that led to the escalation never count toward a second one. It emits `ValidatorEscalatedS5` when the per-slasher level fired, otherwise `ValidatorEscalatedS5Global`. +4. Otherwise slash `amount` with the given reason. -The ring is namespaced by the **calling task contract** because `giIndex` is a per-model counter: keying by validator alone would interleave different models' GI sequences and break the ascending-order trim. The consequence is that recidivism is counted **per model**, not across models. +The per-slasher ring is namespaced by the **calling task contract** because `giIndex` is a per-model counter: keying by validator alone would interleave different models' GI sequences and break the ascending-order trim. On its own that counts recidivism per task contract, so misses spread across models, or across one model's coordinator (S2) and auditor (S1), never add up. The global ring closes that gap: block timestamps never go backwards, so one ring per validator stays ascending across every slasher. ### 7.4 `recordNoParticipation(validator, reason)` — S6 @@ -203,7 +209,8 @@ Slasher-only. Reverts on zero address or zero duration (`InvalidJailDuration`); | `isSlasherContract(a)` | slasher flag | | `getEncryptionKey(v)` | registered key or empty bytes | | `getModelStakeMin(modelId)` | `modelMinStakeBounds[modelId].min` | -| `getPartialSlashGIs(v, slasher)` | the S5 ring for that validator/caller pair | +| `getPartialSlashGIs(v, slasher)` | the S5 per-slasher ring for that validator/caller pair (empty if it predates the validator's last S5 escalation) | +| `getPartialSlashTimes(v)` | the S5 global ring (timestamps, all slashers) | Plus the public getters for all state in §3. @@ -267,13 +274,13 @@ A validator with `20 DIN` misses a reveal on the same model in three consecutive - **GI 3:** the ring becomes `[1, 2, 3]`, reaching the threshold of 3 within the window of 5, so the call **escalates**. It slashes a full `MIN_STAKE` (10 DIN, reason `S5_RECIDIVISM`) instead of 3 DIN, jails the validator for 7 days, emits `ValidatorEscalatedS5` and clears the ring. The result is `activeStake = 4`, status `Jailed`. - **After the jail:** `reactivate()` reverts with `StakeBelowFloor` (4 < 10). The validator has to `stake(10)` first. Once the jail has expired, that `stake` call already recomputes the status to `Active`, so `reactivate()` is never needed (§14 No. 3). -The ring is kept per calling task contract, so the same three misses spread across three different models would not escalate (§14 No. 5). +The ring is kept per calling task contract, so the same three misses spread across three different models would not escalate on this level. The global level adds them up (§7.3, §14 No. 5). Either way, the escalation also settles the validator's rings with every other task contract: a miss after the jail starts a fresh count everywhere. --- ## 12. Events & Errors -**Events:** `ValidatorStaked`, `ValidatorUnstakeRequested`, `ValidatorWithdrawalClaimed`, `ValidatorSlashed`, `ValidatorJailed`, `ValidatorReactivated`, `ValidatorEscalatedS5`, `S6NoParticipationRecorded`, `S6PartialSlashFired`, `ValidatorBlacklisted`, `ValidatorUnblacklisted`, `SlasherContractAdded`, `SlasherContractRemoved`, `ActiveRegistrationIncremented`, `ActiveRegistrationDecremented`, `EncryptionKeyRegistered`, `MinStakeUpdated`, `UnbondingPeriodUpdated`, `ModelStakeBoundsUpdated`, `MaxConcurrentRegistrationsPerStakeUnitUpdated`, `SlashTreasuryUpdated`, `S5RecidivismParamsUpdated`, `S6ParamsUpdated`. +**Events:** `ValidatorStaked`, `ValidatorUnstakeRequested`, `ValidatorWithdrawalClaimed`, `ValidatorSlashed`, `ValidatorJailed`, `ValidatorReactivated`, `ValidatorEscalatedS5`, `ValidatorEscalatedS5Global`, `S6NoParticipationRecorded`, `S6PartialSlashFired`, `ValidatorBlacklisted`, `ValidatorUnblacklisted`, `SlasherContractAdded`, `SlasherContractRemoved`, `ActiveRegistrationIncremented`, `ActiveRegistrationDecremented`, `EncryptionKeyRegistered`, `MinStakeUpdated`, `UnbondingPeriodUpdated`, `ModelStakeBoundsUpdated`, `MaxConcurrentRegistrationsPerStakeUnitUpdated`, `SlashTreasuryUpdated`, `S5RecidivismParamsUpdated`, `S5GlobalParamsUpdated`, `S6ParamsUpdated`. **Errors:** `NotDINCoordinator`, `NotSlasherContract`, `InvalidAddress`, `ValidatorIsBlacklisted`, `ValidatorNotBlacklisted`, `AmountLessThanMinStake`, `NotEnoughStake`, `InvalidUnstakeAmount`, `PendingWithdrawalExists`, `NoPendingWithdrawal`, `WithdrawalNotReady`, `InvalidSlashAmount`, `SlasherContractAlreadyAdded`, `SlasherContractNotAdded`, `InvalidJailDuration`, `NotJailed`, `JailPeriodNotExpired`, `StakeBelowFloor`, `InvalidMinStake`, `InvalidUnbondingPeriod`, `InvalidStakeBounds`, `InvalidEncryptionKey`, `InvalidS5Params`, `InvalidS6Params`. @@ -300,6 +307,7 @@ Until step 8, slasher management through the coordinator reverts `ValidatorStake | ProxyAdmin | One per proxy, owned by the deployer | Implementation upgrades | - **Upgrade path:** `cd foundry && CONTRACT=DinValidatorStake forge script script/UpgradePlatform.s.sol ...` (see [UpgradePlatform](foundry/script/UpgradePlatform.md)); `foundry/test/UpgradeValidation.t.sol` runs `Upgrades.validateImplementation` on the implementation, and `DinValidatorStakeUpgradeTest` in `foundry/test/DeployPlatform.t.sol` upgrades to `foundry/src/upgrade/DinValidatorStakeV2.sol` and checks stakes and access control survive. +- **Storage:** the S5 global level appends five slots (`s5GlobalWindow`, `s5GlobalThreshold`, `_partialSlashTimes`, `s5EscalationEpoch`, `_partialSlashEpoch`) after the S6 block, above `__gap`. `__gap` stays at `[50]`: nothing on `develop` is deployed yet, so per the "Core rules" in [storage_layout.md](../storage_layout.md) new variables go above it without shrinking it. - **Trust implication:** this contract custodies all staked DIN; the ProxyAdmin owner can replace every rule here without moving the balance. --- @@ -310,7 +318,7 @@ Until step 8, slasher management through the coordinator reverts `ValidatorStake - **No. 2 — S5 escalation on a blacklisted validator reverts the whole slash:** escalation calls `_jailInternal`, which reverts `ValidatorIsBlacklisted`. A task contract's slashing loop hitting a blacklisted repeat offender would revert, not just skip that validator. - **No. 3 — Jail exit does not require `reactivate()`:** after `jailedUntil` passes, any status-syncing call (e.g. `stake`) recomputes the status, bypassing `reactivate()`'s `StakeBelowFloor` check (the later sync still requires `≥ MIN_STAKE` for `Active`). - **No. 4 — Stale NatSpec (partly fixed, issue No. 180):** the `setModelStakeBounds` / `setMaxConcurrentRegistrationsPerStakeUnit` NatSpec now says where the task contracts enforce both (§8), `activeRegistrationCount` names `releaseGIRegistrationSlots` as the decrement, and `getModelStakeMin` names the `onlyOwner` (DIN-Representative) setter instead of "the model owner". Still open: `modelMinStakeBounds[].max` is never read. -- **No. 5 — Recidivism is per model:** the S5 ring is keyed by calling contract, so a validator faulting across many models never escalates unless it hits the threshold within one model. +- **No. 5 — Recidivism is per model (fixed, issue No. 193):** the per-slasher ring is keyed by calling contract, so on its own a validator faulting across many models never escalates. The global ring (`s5GlobalWindow` / `s5GlobalThreshold`, defaults 7 days / 6) now counts every slasher's partial slashes together. Any S5 escalation increments `s5EscalationEpoch`, which settles every slasher's per-slasher ring, so misses that fed a global escalation are not counted again toward a per-slasher one. Defaults are placeholders until issue No. 155. A proxy upgraded from an earlier version reads `0` for both, so the global level stays off until the owner calls `setS5GlobalParams`. - **No. 6 — Blacklisted funds are frozen:** blacklisted validators cannot unstake or claim; there is no recovery path other than unblacklisting. - **No. 7 — Custody meets upgradeability:** see §13. @@ -318,6 +326,12 @@ Until step 8, slasher management through the coordinator reverts `ValidatorStake ## 15. Change Log +### Cross-model S5 (issue No. 193) + +- `slashPartial` keeps the per-slasher GI ring and adds a per-validator timestamp ring shared by every slasher contract (`_partialSlashTimes`). Either level reaching its threshold escalates (full `MIN_STAKE` + jail) and clears both rings. Every escalation also bumps the validator's `s5EscalationEpoch`, and per-slasher rings from an earlier epoch are dropped lazily, so no miss counts toward two escalations. +- New owner-settable `s5GlobalWindow` (7 days) / `s5GlobalThreshold` (6), set in `initialize`; `setS5GlobalParams(window, threshold)` with (0, 0) as the off switch. On an upgraded proxy both read 0 (off); no reinitializer. +- New events `ValidatorEscalatedS5Global` (emitted when only the global level fired; `ValidatorEscalatedS5` keeps its signature) and `S5GlobalParamsUpdated`; new view `getPartialSlashTimes`; new public getter `s5EscalationEpoch`. Five storage slots appended above `__gap` (kept at 50). + ### P3 — slashing, jailing, parameters (foundry) - Slashed DIN is now disposed of: 50% burned, 50% to `slashTreasury` (`setSlashTreasury`), or also burned if no treasury is set. diff --git a/Documentation/technical/contracts/foundry/script/DeployPlatform.md b/Documentation/technical/contracts/foundry/script/DeployPlatform.md index ee675116..dccc9cb4 100644 --- a/Documentation/technical/contracts/foundry/script/DeployPlatform.md +++ b/Documentation/technical/contracts/foundry/script/DeployPlatform.md @@ -11,7 +11,7 @@ The deployer becomes `owner()` of every contract and the owner of every ProxyAdm `run()` is what `forge script` calls. It does three things in order: -1. **`readTokenomics()`:** reads the 11 tokenomics keys below with `vm.envOr`, falling back to `defaultTokenomics()`, and logs `[INFO] not set - using default: ` for every key that is absent. +1. **`readTokenomics()`:** reads the 13 tokenomics keys below with `vm.envOr`, falling back to `defaultTokenomics()`, and logs `[INFO] not set - using default: ` for every key that is absent. 2. **`deploy(Tokenomics, deployer)`:** runs steps 1–15 under `vm.startBroadcast(deployer)`, and passes `deployer` as the owner of all seven ProxyAdmins. `run()` passes `msg.sender`, i.e. forge script's `--sender`. It returns a `Deployment` struct with the 7 proxies and 7 ProxyAdmins. 3. **`_writeDeployments(...)`:** step 16, after `vm.stopBroadcast()`. @@ -34,8 +34,10 @@ Tests call `deploy()` directly with explicit values, so they need neither `vm.se | `S5_RECIDIVISM_THRESHOLD` | `3` | step 15 | | `S5_JAIL_DURATION` | `604800` (7 days) | step 15 | | `S6_NO_PARTICIPATION_THRESHOLD` | `3` | step 15 | +| `S5_GLOBAL_WINDOW` | `604800` (7 days) | step 15 | +| `S5_GLOBAL_THRESHOLD` | `6` (with `S5_GLOBAL_WINDOW=0`, `0` turns the global level off) | step 15 | -The script ends the broadcast by logging a `--- Effective tokenomics ---` block with all 11 values. +The script ends the broadcast by logging a `--- Effective tokenomics ---` block with all 13 values. ## Sequence @@ -55,7 +57,7 @@ The script ends the broadcast by logging a `--- Effective tokenomics ---` block | 12 | `dinModelRegistry.setFeeRouter(dinFeeRouter)` | | | 13 | `DinEmission` proxy — `initialize(dinCoordinator, dinToken, dinModelRegistry, emissionPerGI, emissionDecayBps, emissionEpochLength, emissionMaxEpochs)` | Schedule from the env keys; defaults are 100 DIN/GI, 80% retained per epoch, 100 GIs per epoch, 10 epochs | | 14 | `dinCoordinator.setEmissionContract(dinEmission)` | Authorises emission minting | -| 15 | Apply each post-deploy override only when it differs from its default: `updateDinPerEth`, `setMintCap`, `setMinStake`, `setS5RecidivismParams` (all three S5 values together, if any one differs), `setS6NoParticipationThreshold`. Then record the seven ProxyAdmins (`Upgrades.getAdminAddress`) | Invalid values revert the whole deploy (e.g. `InvalidMinStake`, `InvalidS5Params`, `InvalidS6Params`) | +| 15 | Apply each post-deploy override only when it differs from its default: `updateDinPerEth`, `setMintCap`, `setMinStake`, `setS5RecidivismParams` (all three S5 values together, if any one differs), `setS6NoParticipationThreshold`, `setS5GlobalParams` (window and threshold together, if either differs). Then record the seven ProxyAdmins (`Upgrades.getAdminAddress`) | Invalid values revert the whole deploy (e.g. `InvalidMinStake`, `InvalidS5Params`, `InvalidS6Params`) | | 16 | `run()` writes the JSON, after `vm.stopBroadcast()` | | ## Output @@ -96,7 +98,7 @@ This writes `foundry/deployments/sepolia_op_devnet.json` and `foundry/broadcast/ ## Tested by -[`foundry/test/DeployPlatform.t.sol`](../../../../../foundry/test/DeployPlatform.t.sol) reproduces this sequence in its `PlatformTest._deployPlatform()` fixture and checks every wiring edge (`ProxyWiringTest`), initializer protection (`ReInitializerProtectionTest`), and V2 upgrades (`*UpgradeTest`). [`foundry/test/DeployPlatformScript.t.sol`](../../../../../foundry/test/DeployPlatformScript.t.sol) (`DeployPlatformScriptTest`) runs the script's own `deploy()`: defaults, all 11 overrides and a single S5 override land on chain, invalid overrides revert the deploy, every contract and ProxyAdmin is owned by the deployer, and `readTokenomics()` returns the defaults and then the values set with `vm.setEnv`. The `tests/dincli/` integration harness runs this script against a local chain (`PLATFORM_DEPLOY_TOOLCHAIN=foundry`). +[`foundry/test/DeployPlatform.t.sol`](../../../../../foundry/test/DeployPlatform.t.sol) reproduces this sequence in its `PlatformTest._deployPlatform()` fixture and checks every wiring edge (`ProxyWiringTest`), initializer protection (`ReInitializerProtectionTest`), and V2 upgrades (`*UpgradeTest`). [`foundry/test/DeployPlatformScript.t.sol`](../../../../../foundry/test/DeployPlatformScript.t.sol) (`DeployPlatformScriptTest`) runs the script's own `deploy()`: defaults, all 13 overrides, a single S5 override, a single S5 global override and the S5 global off switch (0, 0) land on chain, invalid overrides revert the deploy, every contract and ProxyAdmin is owned by the deployer, and `readTokenomics()` returns the defaults and then the values set with `vm.setEnv`. The `tests/dincli/` integration harness runs this script against a local chain (`PLATFORM_DEPLOY_TOOLCHAIN=foundry`). ## Notes diff --git a/Documentation/technical/storage_layout.md b/Documentation/technical/storage_layout.md index 502b016a..f657625f 100644 --- a/Documentation/technical/storage_layout.md +++ b/Documentation/technical/storage_layout.md @@ -104,7 +104,12 @@ above for why that's fine pre-deployment. _partialSlashGIs : mapping(address => mapping(address => uint256[])) (slot 14) s6NoParticipationThreshold : uint256 (slot 15) s6NoParticipationCount : mapping(address => uint256) (slot 16) - __gap : uint256[50] (slot 17) + s5GlobalWindow : uint256 (slot 17, issue #193) + s5GlobalThreshold : uint256 (slot 18, issue #193) + _partialSlashTimes : mapping(address => uint256[]) (slot 19, issue #193) + s5EscalationEpoch : mapping(address => uint256) (slot 20, issue #193) + _partialSlashEpoch : mapping(address => mapping(address => uint256)) (slot 21, issue #193) + __gap : uint256[50] (slot 22) ``` `DIN_TOKEN` / `DIN_COORDINATOR` are set once in `initialize` (upper-case names, but @@ -114,7 +119,9 @@ to `10 DIN` / `7 days` and owner-settable (`setMinStake`, `setUnbondingPeriod`), `UNBONDING_PERIOD` is a `uint64` but still takes a full slot: the next variable is a mapping, which always starts a new slot. `ValidatorInfo` / `ModelStakeBounds` are structs stored inside mapping entries; their internal layout does not affect the -contract's top-level slot numbering. +contract's top-level slot numbering. `s5GlobalWindow` / `s5GlobalThreshold` are set +in `initialize` and by `setS5GlobalParams`. `__gap` stays at `[50]` rather than +shrinking — see "Core rules" above for why that's fine pre-deployment. --- diff --git a/dincli/abis/DinValidatorStake.json b/dincli/abis/DinValidatorStake.json index 022ec4ec..4f1c2bef 100644 --- a/dincli/abis/DinValidatorStake.json +++ b/dincli/abis/DinValidatorStake.json @@ -203,6 +203,25 @@ ], "stateMutability": "view" }, + { + "type": "function", + "name": "getPartialSlashTimes", + "inputs": [ + { + "name": "validator", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256[]", + "internalType": "uint256[]" + } + ], + "stateMutability": "view" + }, { "type": "function", "name": "getStake", @@ -441,6 +460,51 @@ "outputs": [], "stateMutability": "nonpayable" }, + { + "type": "function", + "name": "s5EscalationEpoch", + "inputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "s5GlobalThreshold", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "s5GlobalWindow", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, { "type": "function", "name": "s5JailDuration", @@ -561,6 +625,24 @@ "outputs": [], "stateMutability": "nonpayable" }, + { + "type": "function", + "name": "setS5GlobalParams", + "inputs": [ + { + "name": "window", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "threshold", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, { "type": "function", "name": "setS5RecidivismParams", @@ -956,6 +1038,25 @@ ], "anonymous": false }, + { + "type": "event", + "name": "S5GlobalParamsUpdated", + "inputs": [ + { + "name": "window", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + }, + { + "name": "threshold", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + } + ], + "anonymous": false + }, { "type": "event", "name": "S5RecidivismParamsUpdated", @@ -1146,6 +1247,37 @@ ], "anonymous": false }, + { + "type": "event", + "name": "ValidatorEscalatedS5Global", + "inputs": [ + { + "name": "validator", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "slashedAmount", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + }, + { + "name": "globalCount", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + }, + { + "name": "slasher", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false + }, { "type": "event", "name": "ValidatorJailed", diff --git a/foundry/script/DeployPlatform.s.sol b/foundry/script/DeployPlatform.s.sol index 0fa6e430..826231b7 100644 --- a/foundry/script/DeployPlatform.s.sol +++ b/foundry/script/DeployPlatform.s.sol @@ -43,6 +43,8 @@ import {DeploymentsPath} from "./DeploymentsPath.sol"; /// S5_RECIDIVISM_THRESHOLD — slashes in window to trigger S5 jail (default: 3) /// S5_JAIL_DURATION — jail duration in seconds (default: 604800 = 7 days) /// S6_NO_PARTICIPATION_THRESHOLD — no-participation events before S6 fires (default: 3) +/// S5_GLOBAL_WINDOW — seconds over which S5 counts slashes from all slasher contracts (default: 604800 = 7 days) +/// S5_GLOBAL_THRESHOLD — slashes in that window to trigger S5 jail, 0 with window 0 = off (default: 6) /// /// Usage (from repo root): /// ./foundry/anvil.sh & @@ -82,6 +84,8 @@ contract DeployPlatform is DeploymentsPath { uint256 internal constant DEFAULT_S5_THRESHOLD = 3; uint256 internal constant DEFAULT_S5_JAIL_DURATION = 7 days; uint256 internal constant DEFAULT_S6_THRESHOLD = 3; + uint256 internal constant DEFAULT_S5_GLOBAL_WINDOW = 7 days; + uint256 internal constant DEFAULT_S5_GLOBAL_THRESHOLD = 6; /// @notice Tokenomics values the deploy applies; see the env keys in the header. struct Tokenomics { @@ -96,6 +100,8 @@ contract DeployPlatform is DeploymentsPath { uint256 s5Threshold; uint256 s5JailDuration; uint256 s6Threshold; + uint256 s5GlobalWindow; + uint256 s5GlobalThreshold; } /// @notice Deployed proxy and ProxyAdmin addresses, as written to the deployments JSON. @@ -152,7 +158,9 @@ contract DeployPlatform is DeploymentsPath { s5Window: DEFAULT_S5_WINDOW, s5Threshold: DEFAULT_S5_THRESHOLD, s5JailDuration: DEFAULT_S5_JAIL_DURATION, - s6Threshold: DEFAULT_S6_THRESHOLD + s6Threshold: DEFAULT_S6_THRESHOLD, + s5GlobalWindow: DEFAULT_S5_GLOBAL_WINDOW, + s5GlobalThreshold: DEFAULT_S5_GLOBAL_THRESHOLD }); } @@ -171,6 +179,8 @@ contract DeployPlatform is DeploymentsPath { t.s5Threshold = vm.envOr("S5_RECIDIVISM_THRESHOLD", dflt.s5Threshold); t.s5JailDuration = vm.envOr("S5_JAIL_DURATION", dflt.s5JailDuration); t.s6Threshold = vm.envOr("S6_NO_PARTICIPATION_THRESHOLD", dflt.s6Threshold); + t.s5GlobalWindow = vm.envOr("S5_GLOBAL_WINDOW", dflt.s5GlobalWindow); + t.s5GlobalThreshold = vm.envOr("S5_GLOBAL_THRESHOLD", dflt.s5GlobalThreshold); _logIfUnset("DIN_PER_ETH", t.dinPerEth); _logIfUnset("MINT_CAP", t.mintCap); @@ -183,6 +193,8 @@ contract DeployPlatform is DeploymentsPath { _logIfUnset("S5_RECIDIVISM_THRESHOLD", t.s5Threshold); _logIfUnset("S5_JAIL_DURATION", t.s5JailDuration); _logIfUnset("S6_NO_PARTICIPATION_THRESHOLD", t.s6Threshold); + _logIfUnset("S5_GLOBAL_WINDOW", t.s5GlobalWindow); + _logIfUnset("S5_GLOBAL_THRESHOLD", t.s5GlobalThreshold); } /// @notice Deploys and wires the platform with the given tokenomics (steps @@ -327,6 +339,15 @@ contract DeployPlatform is DeploymentsPath { DinValidatorStake(dinValidatorStakeProxy).setS6NoParticipationThreshold(t.s6Threshold); console.log("DinValidatorStake.s6NoParticipationThreshold set to:", t.s6Threshold); } + if ( + t.s5GlobalWindow != DEFAULT_S5_GLOBAL_WINDOW || + t.s5GlobalThreshold != DEFAULT_S5_GLOBAL_THRESHOLD + ) { + DinValidatorStake(dinValidatorStakeProxy).setS5GlobalParams( + t.s5GlobalWindow, t.s5GlobalThreshold + ); + console.log("DinValidatorStake S5 global params updated"); + } console.log("--- Effective tokenomics ---"); console.log("dinPerEth: ", t.dinPerEth); @@ -340,6 +361,8 @@ contract DeployPlatform is DeploymentsPath { console.log("s5Threshold: ", t.s5Threshold); console.log("s5JailDuration: ", t.s5JailDuration); console.log("s6Threshold: ", t.s6Threshold); + console.log("s5GlobalWindow: ", t.s5GlobalWindow); + console.log("s5GlobalThreshold: ", t.s5GlobalThreshold); // ProxyAdmin — OZ v5 deploys one ProxyAdmin per proxy; record all seven address proxyAdminTreasury = Upgrades.getAdminAddress(dinTreasuryProxy); diff --git a/foundry/src/DinValidatorStake.sol b/foundry/src/DinValidatorStake.sol index 12ce88bf..3685634c 100644 --- a/foundry/src/DinValidatorStake.sol +++ b/foundry/src/DinValidatorStake.sol @@ -172,6 +172,45 @@ contract DinValidatorStake is ); event S6PartialSlashFired(address indexed validator, uint256 slashedAmount, address indexed slasher); + // ── S5 — global (cross-model) recidivism level (issue No. 193) ───────── + /// @notice Rolling time window (seconds) for the per-validator S5 count + /// that spans every slasher contract. DAO-settable. 0 = off. + uint256 public s5GlobalWindow; + /// @notice Number of partial slashes, from any slasher contract, within + /// s5GlobalWindow that triggers S5 escalation. DAO-settable. 0 = off. + /// Reads 0 on a proxy upgraded from a version without this field, + /// so the global level stays off until setS5GlobalParams is called. + uint256 public s5GlobalThreshold; + /// @dev Per-validator ordered list of block timestamps at which a partial + /// slash was recorded, from any slasher contract. Unlike + /// _partialSlashGIs this ring is shared across models: timestamps + /// never decrease, so the ascending-order trim stays safe without + /// namespacing by caller. Entries older than s5GlobalWindow are trimmed. + mapping(address => uint256[]) private _partialSlashTimes; + + event S5GlobalParamsUpdated(uint256 window, uint256 threshold); + /// @notice Emitted when the global (cross-model) S5 level escalates a + /// partial slash and the per-slasher level did not. The per-slasher + /// escalation keeps emitting ValidatorEscalatedS5. + event ValidatorEscalatedS5Global( + address indexed validator, + uint256 slashedAmount, + uint256 globalCount, + address indexed slasher + ); + + /// @notice Number of S5 escalations (either level) applied to each + /// validator. An escalation settles every partial slash recorded + /// before it, in every slasher's ring, not only the caller's. + mapping(address => uint256) public s5EscalationEpoch; + /// @dev The s5EscalationEpoch at which each [validator][slasher] ring in + /// _partialSlashGIs was last written. A ring from an earlier epoch is + /// stale: it is dropped on that slasher's next partial slash and + /// reads as empty through getPartialSlashGIs. Without this, misses + /// that fed a global escalation would stay in the other slashers' + /// rings and count a second time toward per-slasher escalation. + mapping(address => mapping(address => uint256)) private _partialSlashEpoch; + // Reserved for future state variables at this inheritance level. uint256[50] private __gap; @@ -198,6 +237,8 @@ contract DinValidatorStake is s5RecidivismWindow = 5; s5RecidivismThreshold = 3; s5JailDuration = 7 days; + s5GlobalWindow = 7 days; + s5GlobalThreshold = 6; s6NoParticipationThreshold = 3; } @@ -283,8 +324,11 @@ contract DinValidatorStake is /// recidivism tracking. Applies a fraction of MIN_STAKE rather than /// the full amount. If the validator's partial-slash count within /// s5RecidivismWindow GIs (tracked per calling slasher contract, - /// see _partialSlashGIs) reaches s5RecidivismThreshold, the slash - /// is automatically escalated to MIN_STAKE and the validator is jailed. + /// see _partialSlashGIs) reaches s5RecidivismThreshold, or its + /// count across all slasher contracts within s5GlobalWindow seconds + /// reaches s5GlobalThreshold (the global level, issue No. 193; see + /// _partialSlashTimes), the slash is automatically escalated to + /// MIN_STAKE and the validator is jailed. /// @param validator Address of the validator to slash. /// @param amount Partial slash amount (computed by the calling task contract /// as a fraction of minStake). Ignored on S5 escalation — full MIN_STAKE @@ -310,13 +354,31 @@ contract DinValidatorStake is // Count entries now in the window (includes the one just added). uint256 countInWindow = _partialSlashGIs[validator][msg.sender].length; - if (countInWindow >= s5RecidivismThreshold) { + // Global level (issue No. 193): the same partial slash also counts in + // a per-validator, time-windowed ring shared by every slasher contract, + // so misses spread across models (or across one model's coordinator + // and auditor) still add up. Off while s5GlobalThreshold is 0. + uint256 globalCount; + if (s5GlobalThreshold != 0) { + globalCount = _trimAndRecordGlobalPartialSlash(validator); + } + bool perSlasherHit = countInWindow >= s5RecidivismThreshold; + + if (perSlasherHit || (s5GlobalThreshold != 0 && globalCount >= s5GlobalThreshold)) { // Escalate: full MIN_STAKE slash + jail. uint256 escalatedAmount = _applySlash(validator, MIN_STAKE, "S5_RECIDIVISM"); _jailInternal(validator, uint64(s5JailDuration), "S5_RECIDIVISM"); - emit ValidatorEscalatedS5(validator, escalatedAmount, giIndex, msg.sender); - // Clear this caller's ring so its next GI starts a fresh window after jail exit. + if (perSlasherHit) { + emit ValidatorEscalatedS5(validator, escalatedAmount, giIndex, msg.sender); + } else { + emit ValidatorEscalatedS5Global(validator, escalatedAmount, globalCount, msg.sender); + } + // Start a new epoch so every slasher's ring counts from zero after + // jail exit (others are dropped lazily, see _partialSlashEpoch), + // and clear this caller's ring and the global ring now. + s5EscalationEpoch[validator]++; delete _partialSlashGIs[validator][msg.sender]; + delete _partialSlashTimes[validator]; return escalatedAmount; } @@ -601,6 +663,19 @@ contract DinValidatorStake is emit S5RecidivismParamsUpdated(window, threshold, jailDuration); } + /// @notice Updates the global (cross-model) S5 level (issue No. 193). + /// @param window Time window in seconds over which partial slashes from + /// every slasher contract are counted. + /// @param threshold Partial slashes within the window that trigger + /// escalation. Pass (0, 0) to turn the global level off; otherwise + /// both must be > 0. + function setS5GlobalParams(uint256 window, uint256 threshold) external onlyOwner { + if ((window == 0) != (threshold == 0)) revert InvalidS5Params(); + s5GlobalWindow = window; + s5GlobalThreshold = threshold; + emit S5GlobalParamsUpdated(window, threshold); + } + /// @notice Updates the S6 no-participation threshold. /// @param threshold Number of no-show GIs before an escalating slash fires. function setS6NoParticipationThreshold(uint256 threshold) external onlyOwner { @@ -618,9 +693,19 @@ contract DinValidatorStake is address validator, address slasherContract ) external view returns (uint256[] memory) { + if (_partialSlashEpoch[validator][slasherContract] != s5EscalationEpoch[validator]) { + return new uint256[](0); + } return _partialSlashGIs[validator][slasherContract]; } + /// @notice Returns a validator's global partial-slash timestamp ring + /// (all slasher contracts; for testing/inspection). + /// @param validator Validator address to query. + function getPartialSlashTimes(address validator) external view returns (uint256[] memory) { + return _partialSlashTimes[validator]; + } + // ─── Internal helpers ───────────────────────────────────────────────── function _applySlash( @@ -687,6 +772,12 @@ contract DinValidatorStake is /// arbitrarily relative to each other. function _trimAndRecordPartialSlash(address validator, uint256 giIndex) internal { uint256[] storage ring = _partialSlashGIs[validator][msg.sender]; + // Drop a ring left over from before the validator's last S5 escalation. + uint256 epoch = s5EscalationEpoch[validator]; + if (_partialSlashEpoch[validator][msg.sender] != epoch) { + delete _partialSlashGIs[validator][msg.sender]; + _partialSlashEpoch[validator][msg.sender] = epoch; + } ring.push(giIndex); uint256 window = s5RecidivismWindow; @@ -713,6 +804,37 @@ contract DinValidatorStake is } } + /// @dev Pushes block.timestamp onto the validator's global ring, trims + /// entries older than s5GlobalWindow from the front, and returns the + /// count left in the window (including the one just added). The ring + /// is non-decreasing because block timestamps never go backwards. + function _trimAndRecordGlobalPartialSlash(address validator) internal returns (uint256) { + uint256[] storage ring = _partialSlashTimes[validator]; + uint256 nowTs = block.timestamp; + ring.push(nowTs); + + uint256 window = s5GlobalWindow; + uint256 len = ring.length; + uint256 removeCount = 0; + for (uint256 i = 0; i < len - 1; i++) { + if (nowTs - ring[i] >= window) { + removeCount++; + } else { + break; + } + } + if (removeCount > 0) { + uint256 newLen = len - removeCount; + for (uint256 i = 0; i < newLen; i++) { + ring[i] = ring[i + removeCount]; + } + for (uint256 i = 0; i < removeCount; i++) { + ring.pop(); + } + } + return ring.length; + } + function _syncValidatorStatus(ValidatorInfo storage validator) internal { if (validator.status == ValidatorStatus.Blacklisted) { return; diff --git a/foundry/test/DeployPlatform.t.sol b/foundry/test/DeployPlatform.t.sol index 8319609a..c51b16c9 100644 --- a/foundry/test/DeployPlatform.t.sol +++ b/foundry/test/DeployPlatform.t.sol @@ -649,6 +649,11 @@ contract DeployPlatformTokenomicsOverrideTest is PlatformTest { assertEq(p.dinValidatorStake.s5JailDuration(), 7 days, "default s5JailDuration"); } + function test_dinValidatorStake_defaultS5GlobalParams() public view { + assertEq(p.dinValidatorStake.s5GlobalWindow(), 7 days, "default s5GlobalWindow"); + assertEq(p.dinValidatorStake.s5GlobalThreshold(), 6, "default s5GlobalThreshold"); + } + function test_dinValidatorStake_defaultS6Threshold() public view { assertEq(p.dinValidatorStake.s6NoParticipationThreshold(), 3, "default s6Threshold"); } @@ -678,6 +683,12 @@ contract DeployPlatformTokenomicsOverrideTest is PlatformTest { assertEq(p.dinValidatorStake.s5JailDuration(), 14 days, "updated s5JailDuration"); } + function test_dinValidatorStake_overrideS5GlobalParams_appliesAfterDeploy() public { + p.dinValidatorStake.setS5GlobalParams(3 days, 9); + assertEq(p.dinValidatorStake.s5GlobalWindow(), 3 days, "updated s5GlobalWindow"); + assertEq(p.dinValidatorStake.s5GlobalThreshold(), 9, "updated s5GlobalThreshold"); + } + function test_dinValidatorStake_overrideS6Threshold_appliesAfterDeploy() public { p.dinValidatorStake.setS6NoParticipationThreshold(5); assertEq(p.dinValidatorStake.s6NoParticipationThreshold(), 5, "updated s6Threshold"); diff --git a/foundry/test/DeployPlatformScript.t.sol b/foundry/test/DeployPlatformScript.t.sol index 830df250..cf218fb6 100644 --- a/foundry/test/DeployPlatformScript.t.sol +++ b/foundry/test/DeployPlatformScript.t.sol @@ -43,7 +43,9 @@ contract DeployPlatformScriptTest is Test { s5Window: 10, s5Threshold: 4, s5JailDuration: 14 days, - s6Threshold: 5 + s6Threshold: 5, + s5GlobalWindow: 3 days, + s5GlobalThreshold: 8 }); } @@ -74,6 +76,8 @@ contract DeployPlatformScriptTest is Test { assertEq(stake.s5RecidivismThreshold(), t.s5Threshold, "s5RecidivismThreshold"); assertEq(stake.s5JailDuration(), t.s5JailDuration, "s5JailDuration"); assertEq(stake.s6NoParticipationThreshold(), t.s6Threshold, "s6NoParticipationThreshold"); + assertEq(stake.s5GlobalWindow(), t.s5GlobalWindow, "s5GlobalWindow"); + assertEq(stake.s5GlobalThreshold(), t.s5GlobalThreshold, "s5GlobalThreshold"); _assertOwnedByDeployer(d.dinTreasury, "DinTreasury"); _assertOwnedByDeployer(d.dinToken, "DinToken"); @@ -120,6 +124,31 @@ contract DeployPlatformScriptTest is Test { script.deploy(t, deployer); } + /// @dev The S5 global setter takes window and threshold together; + /// overriding only one must apply both, with the other at its default. + function test_singleS5GlobalOverride_appliesWithOtherDefault() public { + DeployPlatform.Tokenomics memory t = script.defaultTokenomics(); + t.s5GlobalThreshold = 9; + DeployPlatform.Deployment memory d = script.deploy(t, deployer); + _assertOnChain(d, t); + } + + /// @dev (0, 0) turns the global S5 level off at deploy time. + function test_s5GlobalOff_landsOnChain() public { + DeployPlatform.Tokenomics memory t = script.defaultTokenomics(); + t.s5GlobalWindow = 0; + t.s5GlobalThreshold = 0; + DeployPlatform.Deployment memory d = script.deploy(t, deployer); + _assertOnChain(d, t); + } + + function test_s5GlobalHalfZero_revertsDeploy() public { + DeployPlatform.Tokenomics memory t = script.defaultTokenomics(); + t.s5GlobalWindow = 0; + vm.expectRevert(DinValidatorStake.InvalidS5Params.selector); + script.deploy(t, deployer); + } + function test_zeroS6Threshold_revertsDeploy() public { DeployPlatform.Tokenomics memory t = script.defaultTokenomics(); t.s6Threshold = 0; @@ -140,11 +169,11 @@ contract DeployPlatformScriptTest is Test { /// Skipped when the caller's shell or foundry/.env already sets a key, /// so the defaults half stays deterministic. function test_readTokenomics_defaultsThenEnvOverrides() public { - string[11] memory keys = [ + string[13] memory keys = [ "DIN_PER_ETH", "MINT_CAP", "EMISSION_PER_GI", "EMISSION_DECAY_BPS", "EMISSION_EPOCH_LENGTH", "EMISSION_MAX_EPOCHS", "MIN_STAKE", "S5_RECIDIVISM_WINDOW", "S5_RECIDIVISM_THRESHOLD", "S5_JAIL_DURATION", - "S6_NO_PARTICIPATION_THRESHOLD" + "S6_NO_PARTICIPATION_THRESHOLD", "S5_GLOBAL_WINDOW", "S5_GLOBAL_THRESHOLD" ]; for (uint256 i = 0; i < keys.length; i++) { if (vm.envExists(keys[i])) vm.skip(true, "tokenomics env key set"); @@ -152,11 +181,11 @@ contract DeployPlatformScriptTest is Test { assertEq(abi.encode(script.readTokenomics()), abi.encode(script.defaultTokenomics()), "defaults"); DeployPlatform.Tokenomics memory t = _overrides(); - uint256[11] memory values = [ + uint256[13] memory values = [ t.dinPerEth, t.mintCap, t.emissionPerGI, t.emissionDecayBps, t.emissionEpochLength, t.emissionMaxEpochs, t.minStake, t.s5Window, t.s5Threshold, t.s5JailDuration, - t.s6Threshold + t.s6Threshold, t.s5GlobalWindow, t.s5GlobalThreshold ]; for (uint256 i = 0; i < keys.length; i++) { vm.setEnv(keys[i], vm.toString(values[i])); diff --git a/foundry/test/SlashingInvariants.t.sol b/foundry/test/SlashingInvariants.t.sol index 7c781af0..996e398a 100644 --- a/foundry/test/SlashingInvariants.t.sol +++ b/foundry/test/SlashingInvariants.t.sol @@ -553,6 +553,197 @@ contract SlashingInvariantsTest is StdInvariant, Test { vm.stopPrank(); } + // ───────────────────────────────────────────────────────────────────── + // S5 global (cross-model) level tests (issue No. 193) + // ───────────────────────────────────────────────────────────────────── + + /// @dev Registers a second slasher contract, standing in for another + /// model's task contract (or the same model's other task contract). + function _addSecondSlasher() internal returns (address slasher2) { + slasher2 = makeAddr("slasher2"); + vm.prank(admin); + coordinator.addSlasherContract(slasher2); + } + + function test_s5Global_defaults() public view { + assertEq(stake.s5GlobalWindow(), 7 days, "default s5GlobalWindow"); + assertEq(stake.s5GlobalThreshold(), 6, "default s5GlobalThreshold"); + } + + /// @dev The #193 attack: (threshold - 1) misses on each of several models + /// never trips a per-slasher ring, but the global level adds them up. + function test_s5Global_escalatesAcrossSlashers() public { + _setUpSingleValidator(stake.MIN_STAKE() * 20); + address slasher2 = _addSecondSlasher(); + address slasher3 = makeAddr("slasher3"); + vm.prank(admin); + coordinator.addSlasherContract(slasher3); + + // Per-slasher: window 5, threshold 3. Global: 7 days, threshold 6. + uint256 partialAmt = (stake.MIN_STAKE() * 3000) / 10_000; + address[3] memory slashers = [slasher, slasher2, slasher3]; + + // Five partial slashes, at most 2 per slasher: no level reached yet. + uint256 n; + for (uint256 gi = 1; gi <= 2; gi++) { + for (uint256 k = 0; k < 3; k++) { + if (n == 5) break; + vm.warp(block.timestamp + 1 hours); + vm.prank(slashers[k]); + uint256 actual = stake.slashPartial(validator1, partialAmt, "AUD_NO_VOTE", gi); + assertEq(actual, partialAmt, "below both thresholds: partial amount"); + n++; + } + } + assertEq(stake.getPartialSlashTimes(validator1).length, 5, "global ring holds 5"); + + // Sixth, from slasher3 (its 2nd): per-slasher count 2 < 3, global 6 >= 6. + vm.warp(block.timestamp + 1 hours); + vm.expectEmit(true, true, false, true, address(stake)); + emit DinValidatorStake.ValidatorEscalatedS5Global(validator1, stake.MIN_STAKE(), 6, slasher3); + vm.prank(slasher3); + uint256 escalated = stake.slashPartial(validator1, partialAmt, "AUD_NO_VOTE", 2); + assertEq(escalated, stake.MIN_STAKE(), "global level: full MIN_STAKE slashed"); + + (, , , uint64 jailedUntil, ) = stake.validators(validator1); + assertGt(jailedUntil, block.timestamp, "validator jailed after global S5 escalation"); + assertEq(stake.getPartialSlashTimes(validator1).length, 0, "global ring cleared"); + assertEq(stake.getPartialSlashGIs(validator1, slasher3).length, 0, "caller ring cleared"); + } + + /// @dev The misses that fed a global escalation must not count again in + /// the other slashers' per-slasher rings: one new miss after the jail + /// is a partial slash, not a second MIN_STAKE escalation. + function test_s5Global_escalationSettlesOtherSlashersRings() public { + _setUpSingleValidator(stake.MIN_STAKE() * 20); + address slasher2 = _addSecondSlasher(); + address slasher3 = makeAddr("slasher3"); + vm.prank(admin); + coordinator.addSlasherContract(slasher3); + + uint256 partialAmt = (stake.MIN_STAKE() * 3000) / 10_000; + address[3] memory slashers = [slasher, slasher2, slasher3]; + + // Two misses per slasher; the sixth (slasher3, GI 2) escalates globally. + for (uint256 gi = 1; gi <= 2; gi++) { + for (uint256 k = 0; k < 3; k++) { + vm.prank(slashers[k]); + stake.slashPartial(validator1, partialAmt, "AUD_NO_VOTE", gi); + } + } + assertEq(stake.s5EscalationEpoch(validator1), 1, "one escalation recorded"); + assertEq(stake.getPartialSlashGIs(validator1, slasher).length, 0, "other slasher's ring settled"); + assertEq(stake.getPartialSlashGIs(validator1, slasher2).length, 0, "other slasher's ring settled"); + + // Jail served; one new miss on slasher at GI 3. + vm.warp(block.timestamp + stake.s5JailDuration() + 1); + vm.prank(slasher); + uint256 actual = stake.slashPartial(validator1, partialAmt, "AUD_NO_VOTE", 3); + assertEq(actual, partialAmt, "pre-escalation misses not counted twice"); + assertEq(stake.getPartialSlashGIs(validator1, slasher).length, 1, "ring restarts from the new miss"); + assertEq(stake.s5EscalationEpoch(validator1), 1, "no second escalation"); + } + + /// @dev A per-slasher escalation also clears the global ring, so it settles + /// the other slashers' rings too, for the same reason. + function test_s5_perSlasherEscalation_settlesOtherSlashersRings() public { + _setUpSingleValidator(stake.MIN_STAKE() * 20); + address slasher2 = _addSecondSlasher(); + + uint256 partialAmt = (stake.MIN_STAKE() * 3000) / 10_000; + vm.prank(slasher2); stake.slashPartial(validator1, partialAmt, "AUD_NO_VOTE", 1); + vm.prank(slasher2); stake.slashPartial(validator1, partialAmt, "AUD_NO_VOTE", 2); + // Third miss on slasher escalates through its own ring (global count 5 < 6). + for (uint256 gi = 1; gi <= 3; gi++) { + vm.prank(slasher); + stake.slashPartial(validator1, partialAmt, "AUD_NO_VOTE", gi); + } + assertEq(stake.s5EscalationEpoch(validator1), 1, "per-slasher escalation recorded"); + + vm.warp(block.timestamp + stake.s5JailDuration() + 1); + vm.prank(slasher2); + uint256 actual = stake.slashPartial(validator1, partialAmt, "AUD_NO_VOTE", 3); + assertEq(actual, partialAmt, "slasher2's earlier misses settled by the escalation"); + assertEq(stake.getPartialSlashGIs(validator1, slasher2).length, 1, "ring restarts from the new miss"); + } + + /// @dev Same-model split: S1 (auditor contract) and S2 (coordinator) are + /// separate slashers, so their misses used to sit in separate rings. + function test_s5Global_addsUpTwoSlashersOfOneModel() public { + _setUpSingleValidator(stake.MIN_STAKE() * 20); + address coordinatorLike = _addSecondSlasher(); + vm.prank(admin); + stake.setS5GlobalParams(7 days, 4); + + uint256 partialAmt = (stake.MIN_STAKE() * 3000) / 10_000; + vm.prank(slasher); stake.slashPartial(validator1, partialAmt, "AUD_NO_VOTE", 1); + vm.prank(coordinatorLike); stake.slashPartial(validator1, partialAmt, "AGG_T1_NO_SUBMISSION", 1); + vm.prank(slasher); stake.slashPartial(validator1, partialAmt, "AUD_NO_VOTE", 2); + + vm.prank(coordinatorLike); + uint256 escalated = stake.slashPartial(validator1, partialAmt, "AGG_T1_NO_SUBMISSION", 2); + assertEq(escalated, stake.MIN_STAKE(), "S1 + S2 misses add up on the global level"); + } + + function test_s5Global_windowExpiry_resetsCount() public { + _setUpSingleValidator(stake.MIN_STAKE() * 20); + address slasher2 = _addSecondSlasher(); + vm.prank(admin); + stake.setS5GlobalParams(1 days, 3); + + uint256 partialAmt = (stake.MIN_STAKE() * 3000) / 10_000; + vm.prank(slasher); stake.slashPartial(validator1, partialAmt, "AUD_NO_VOTE", 1); + vm.prank(slasher2); stake.slashPartial(validator1, partialAmt, "AUD_NO_VOTE", 1); + + // A full window later both entries have expired: count is 1, not 3. + vm.warp(block.timestamp + 1 days); + vm.prank(slasher2); + uint256 actual = stake.slashPartial(validator1, partialAmt, "AUD_NO_VOTE", 2); + assertEq(actual, partialAmt, "expired entries trimmed: partial amount applied"); + assertEq(stake.getPartialSlashTimes(validator1).length, 1, "only the new entry remains"); + } + + /// @dev 0/0 is the off switch, and what an upgraded proxy reads for the + /// new fields until setS5GlobalParams is called. + function test_s5Global_offWhenZero() public { + _setUpSingleValidator(stake.MIN_STAKE() * 20); + address slasher2 = _addSecondSlasher(); + address slasher3 = makeAddr("slasher3"); + vm.prank(admin); + coordinator.addSlasherContract(slasher3); + vm.prank(admin); + stake.setS5GlobalParams(0, 0); + + uint256 partialAmt = (stake.MIN_STAKE() * 3000) / 10_000; + address[3] memory slashers = [slasher, slasher2, slasher3]; + for (uint256 gi = 1; gi <= 2; gi++) { + for (uint256 k = 0; k < 3; k++) { + vm.prank(slashers[k]); + uint256 actual = stake.slashPartial(validator1, partialAmt, "AUD_NO_VOTE", gi); + assertEq(actual, partialAmt, "global level off: partial amount only"); + } + } + assertEq(stake.getPartialSlashTimes(validator1).length, 0, "global ring untouched when off"); + } + + function test_s5Global_setterValidates() public { + vm.startPrank(admin); + vm.expectRevert(DinValidatorStake.InvalidS5Params.selector); + stake.setS5GlobalParams(0, 6); + vm.expectRevert(DinValidatorStake.InvalidS5Params.selector); + stake.setS5GlobalParams(7 days, 0); + stake.setS5GlobalParams(3 days, 9); + assertEq(stake.s5GlobalWindow(), 3 days); + assertEq(stake.s5GlobalThreshold(), 9); + stake.setS5GlobalParams(0, 0); + assertEq(stake.s5GlobalThreshold(), 0); + vm.stopPrank(); + + vm.prank(validator1); + vm.expectRevert(); // OwnableUnauthorizedAccount + stake.setS5GlobalParams(1 days, 2); + } + // ───────────────────────────────────────────────────────────────────── // S6 no-participation counter tests // ─────────────────────────────────────────────────────────────────────