diff --git a/Developer/issues/decentralized-governance.md b/Developer/issues/decentralized-governance.md index bf49378..3dce06c 100644 --- a/Developer/issues/decentralized-governance.md +++ b/Developer/issues/decentralized-governance.md @@ -630,3 +630,69 @@ The recommended default is not raw quadratic voting on transferable DIN. A stron - timelocked execution `DINModelRegistry` fee updates and validator blacklisting are good examples, but the broader scope is all platform-level authority that should eventually be governed by the DAO rather than a centralized admin. + +--- + +## Decisions Made (feat/din-dao) + +The following decisions were reached during the design phase and are recorded here as +the rationale record. The specification is in `Documentation/technical/din-dao/README.md`. + +### Voting power + +**Decision: locked DIN (stDIN).** +Implemented in `DinGovernanceStaking.sol`. Voting power comes from locking DIN +tokens into a non-transferable ERC-20 (stDIN) implementing OZ `IVotes`. Validator +stake in `DinValidatorStake` is not counted in v1 to avoid conflicts of interest when +governance votes on slashing conditions or blacklisting appeals. + +### Quadratic voting + +**Decision: no on-chain quadratic voting.** +Non-binding signaling off-chain only, if ever. Raw quadratic voting on freely +transferable DIN would be trivially gameable via address splitting. + +### Proxy pattern (from Discussion #17) + +**Decision: UUPS with timelock-gated `_authorizeUpgrade` once Stage B is live.** +Transparent proxy is retained for the current devnet deployment (PR #13). Migration +to UUPS is planned before mainnet deployment via a governance proposal through +`DinTimelockLong`. Upgrade validity is enforced by `upgrades.validateUpgrade()` as a +CI gate before any proposal reaches the timelock. + +### Own multisig vs. Gnosis Safe + +**Decision: build `DinMultisig` for devnet; plan Safe migration for mainnet.** +See `Documentation/technical/din-dao/README.md §6`. + +### Timelock delays + +**Decision: two instances — 24 h (short) and 48 h (long).** +Short for Parameter and Operational proposals; long for Treasury and Upgrade proposals. +See design doc §7 for rationale. + +### Blacklisting vs. unblacklisting threshold + +**Decision: different thresholds.** +Blacklisting is `Operational` category (lower threshold; also available as guardian +emergency action). Unblacklisting is `Operational` via full governance only — no +emergency path for restorative actions. + +### Treasury and upgrade supermajority + +**Decision: yes, ≥66% of participating votes with higher quorum (15–20%).** +See design doc §4.2. + +### One chamber vs. separated by role + +**Decision: one chamber for v1.** Bicameral governance (validators vs. token holders) +is deferred to Phase 4 in the suggested governance roadmap. + +### Stage activation schedule + +| Stage | Contracts | Target milestone | +|-------|--------------------------------------------|------------------| +| A | DinMultisig | Devnet 2.0 | +| B | DinTimelockShort, DinTimelockLong | Devnet 3.0 | +| C | DinGovernanceStaking, DinGovernor | Testnet 1.0 | +| D | DinGuardian | Testnet 1.0–2.0 | diff --git a/Documentation/dindao.md b/Documentation/dindao.md index aea035b..3f7bb4b 100644 --- a/Documentation/dindao.md +++ b/Documentation/dindao.md @@ -203,6 +203,130 @@ dincli dindao registry set-admin --- +## 5a. Ownership-Transfer Runbook — Stage B Activation (devnet 3.0) + +This runbook transfers `owner()` of each platform contract from the DIN-Representative +EOA to the appropriate `DinTimelock` instance. Execute steps in the exact order listed. +The entire sequence must be rehearsed on Optimism Sepolia devnet before any testnet +deployment. + +### Prerequisites + +- `DinMultisig` deployed with production signers and thresholds verified. +- `DinTimelockShort` (24 h delay) deployed; `DinMultisig` holds `PROPOSER_ROLE` and + `CANCELLER_ROLE`; `DEFAULT_ADMIN_ROLE` renounced. +- `DinTimelockLong` (48 h delay) deployed; same role wiring as above. +- Both timelocks verified on Optimism Sepolia Blockscout. +- All four platform contract proxies deployed (PR #13): `DinCoordinator`, + `DinToken`, `DinValidatorStake`, `DINModelRegistry`. + +### Step 1 — Transfer DINModelRegistry ownership + +`DINModelRegistry` uses OZ two-step `Ownable2Step`. Initiate from the DIN-Representative +EOA, then accept from the timelock side via a scheduled call. + +```bash +# 1a. Initiate transfer (DIN-Representative signs) +dincli dindao registry set-admin + +# 1b. Build the acceptance call and schedule it through DinMultisig +# Target: DINModelRegistry proxy address +# Calldata: acceptOwnership() +# Category: Upgrade +# Timelock: DinTimelockLong (48 h) +``` + +After the 48 h delay, execute the scheduled timelock operation. Verify: +```bash +# Confirm new owner +cast call "owner()(address)" --rpc-url $RPC +# Expected: DinTimelockLong address +``` + +> [!IMPORTANT] +> Do not proceed to Step 2 until Step 1 is confirmed on-chain. + +### Step 2 — Transfer DinValidatorStake ownership + +```bash +# DIN-Representative initiates +cast send "transferOwnership(address)" \ + \ + --private-key $DIN_REP_KEY --rpc-url $RPC + +# Schedule acceptance through DinMultisig (Upgrade category → DinTimelockLong) +# Calldata: acceptOwnership() on DinValidatorStake proxy +``` + +Verify after 48 h execution: +```bash +cast call "owner()(address)" --rpc-url $RPC +# Expected: DinTimelockLong address +``` + +### Step 3 — Transfer DinCoordinator ownership + +```bash +cast send "transferOwnership(address)" \ + \ + --private-key $DIN_REP_KEY --rpc-url $RPC + +# Schedule acceptance (Upgrade category → DinTimelockLong) +``` + +### Step 4 — Transfer DinToken ownership + +`DinToken` ownership controls the mint-authority wiring to `DinCoordinator`. +Transfer to `DinTimelockLong`. + +```bash +cast send "transferOwnership(address)" \ + \ + --private-key $DIN_REP_KEY --rpc-url $RPC +``` + +### Step 5 — Transfer ProxyAdmin ownership (upgrade governance) + +The `ProxyAdmin` created in PR #13 controls implementation upgrades for all four +proxy contracts. Transfer it to `DinTimelockLong`. + +```bash +cast send "transferOwnership(address)" \ + \ + --private-key $DIN_REP_KEY --rpc-url $RPC +``` + +> [!CAUTION] +> After this step, no platform contract upgrade can proceed without a successful +> governance proposal through `DinTimelockLong`. Ensure the multisig signers are +> active and the timelock role wiring is verified before completing this step. + +### Step 6 — Verify end state + +```bash +# All four contracts should report DinTimelockLong as owner +for PROXY in $COORDINATOR $VALIDATOR_STAKE $MODEL_REGISTRY $DIN_TOKEN; do + echo "Owner of $PROXY:" + cast call $PROXY "owner()(address)" --rpc-url $RPC +done + +# ProxyAdmin owner +cast call $PROXY_ADMIN "owner()(address)" --rpc-url $RPC +``` + +### Rollback + +If the ownership transfer must be reversed before `acceptOwnership()` is called: +1. The pending transfer can be cancelled by calling `transferOwnership(address(0))` on + the relevant contract from the still-active DIN-Representative EOA. +2. Once `acceptOwnership()` has been called by the timelock (Step 1b etc.), the transfer + is complete and cannot be rolled back via EOA. Recovery requires a governance proposal + through the timelock to call `transferOwnership` again. + +This is why the runbook must be rehearsed end-to-end on devnet before mainnet execution. + +--- + ## Workflow 1. **Deploy** — Coordinator → Validator Stake → Model Registry (in order). @@ -211,4 +335,5 @@ dincli dindao registry set-admin 4. **Process Manifest Update Requests** — Review pending `ManifestUpdateRequest` entries. 5. **Monitor** — Use registry commands to track network growth and model status. 6. **Emergency** — Use `disable-model` if a model needs to be stopped immediately. +7. **Stage B Activation** — See §5a for the ordered runbook to transfer ownership to the timelocks. diff --git a/Documentation/technical/din-dao/README.md b/Documentation/technical/din-dao/README.md new file mode 100644 index 0000000..5ec2289 --- /dev/null +++ b/Documentation/technical/din-dao/README.md @@ -0,0 +1,332 @@ +# DIN DAO — Architecture & Staged Rollout + +> **Spec status:** Initial design. Decisions recorded here supersede open questions in +> `Developer/issues/decentralized-governance.md`. +> **Branch:** `feat/din-dao` +> **Roadmap ref:** P3-5.1 · ROADMAP Discussion §1 + +--- + +## 1. Purpose + +The DIN platform is currently governed by a single DIN-Representative admin key that +holds `owner()` on `DinCoordinator`, `DinValidatorStake`, and `DINModelRegistry`, and +will hold ownership of the `ProxyAdmin` created in PR #13. This document specifies how +that authority is progressively transferred to a DAO-governed contract stack. + +DIN does **not** need live DAO governance today. The activation schedule is: + +| Network milestone | DAO stage active | +|---------------------|---------------------| +| Devnet 1.0 | None (admin key) | +| Devnet 2.0 | Stage A shadow-only | +| Devnet 3.0 | Stage B live | +| Testnet 1.0 | Stage C live | +| Testnet 2.0 / audit | Stage D live | + +Contracts ship behind `feat/din-dao` now so the architecture can be reviewed and +hardened before any network milestone requires them. + +--- + +## 2. Stage Overview + +``` +Stage A ────────────────────────────────────────────────────────────── + DinMultisig + N-of-M typed-proposal multisig. Replaces single admin key. + Shadow-operates in devnet 2.0 (no on-chain authority yet). + +Stage B ────────────────────────────────────────────────────────────── + DinTimelockShort (24 h delay) ← parameter + operational proposals + DinTimelockLong (48 h delay) ← treasury + upgrade proposals + DinMultisig → PROPOSER_ROLE + CANCELLER_ROLE on both timelocks. + Timelocks receive owner() of platform contracts + ProxyAdmin. + Live at devnet 3.0. + +Stage C ────────────────────────────────────────────────────────────── + DinGovernanceStaking (stDIN) ← lock DIN → non-transferable votes + DinGovernor ← OZ Governor executing through timelocks + Token-vote governance replaces multisig as primary proposer. + DinMultisig retains CANCELLER_ROLE as safety brake. + Live at testnet 1.0. + +Stage D ────────────────────────────────────────────────────────────── + DinGuardian ← narrow, time-limited emergency authority + Guardian role held by DinMultisig; revocable by DinGovernor. + Live alongside Stage C at testnet 1.0 → 2.0. +``` + +--- + +## 3. Contract Architecture + +### 3.1 Deployed contracts (foundry/src/dao/) + +| Contract | Stage | Description | +|------------------------|-------|-----------------------------------------------------------| +| `DinMultisig.sol` | A | N-of-M multisig with per-category typed proposals | +| `DinTimelock.sol` | B | Thin OZ `TimelockController` wrapper; deployed twice | +| `DinGovernanceStaking` | C | Lock DIN → stDIN voting power (non-transferable, IVotes) | +| `DinGovernor.sol` | C | OZ Governor executing through DinTimelockLong/Short | +| `DinGuardian.sol` | D | Narrow emergency authority with ratification window | + +### 3.2 Governance flow at each stage + +**Stage A / B** (multisig-led) +``` +Signer → DinMultisig.propose() + → confirm × threshold + → DinMultisig.execute() + → DinTimelockShort.schedule() or DinTimelockLong.schedule() + → (delay elapses) + → DinTimelock.execute() + → platform contract call +``` + +**Stage C / D** (token-vote-led, multisig as safety brake) +``` +Proposer → DinGovernor.propose() + → voting delay + → token holders vote + → DinGovernor.queue() → DinTimelockShort or DinTimelockLong + → (delay elapses) + → DinGovernor.execute() + → platform contract call + +Emergency path (Stage D only): +DinMultisig → DinGuardian.performAction() + → (ratification window: 7 days) + → DinGovernor ratifies or action is reversed +``` + +### 3.3 Role wiring + +``` + ┌──────────────────────────┐ + │ DinMultisig │ + │ (3-of-5 for v1) │ + └────────┬─────────┬────────┘ + │ │ + PROPOSER_ROLE │ │ CANCELLER_ROLE (retained at Stage C+) + ▼ ▼ + ┌──────────────────────────────────┐ + │ DinTimelockShort DinTimelockLong│ + │ (24 h delay) (48 h delay) │ + └──────────┬───────────────┬────────┘ + │ │ + owner() ───┴───────────────┘ + │ + ┌───────────────┼──────────────────┐ + ▼ ▼ ▼ + DinCoordinator DinValidatorStake DINModelRegistry ProxyAdmin +``` + +At Stage C, `DinGovernor` is added as `PROPOSER_ROLE` on both timelocks alongside +`DinMultisig`. The multisig retains `CANCELLER_ROLE` to veto a proposal before its +delay elapses. + +--- + +## 4. Proposal Categories & Thresholds + +### 4.1 DinMultisig (Stage A / B) + +| Category | Examples | Confirmations required | +|---------------|----------------------------------------------------|------------------------| +| `Parameter` | fee updates, dinPerEth, stake thresholds | 2-of-N | +| `Operational` | slasher auth, model disable/enable, blacklisting | 2-of-N | +| `Treasury` | ETH/fee withdrawal, grant disbursement | 3-of-N | +| `Upgrade` | platform contract upgrade, ProxyAdmin transfer | N-of-N (unanimous) | + +N = number of signers at deployment (v1: 3 signers). + +### 4.2 DinGovernor (Stage C) + +| Category | Quorum | Threshold | Timelock | +|---------------|--------|------------------|----------------| +| `Parameter` | 4 % | Simple majority | Short (24 h) | +| `Operational` | 10 % | Simple majority | Short (24 h) | +| `Treasury` | 15 % | Supermajority ≥66%| Long (48 h) | +| `Upgrade` | 20 % | Supermajority ≥66%| Long (48 h) | + +DinGovernor is single-track (one Governor instance). Routing to the correct timelock +is handled by the proposer encoding the timelock address as the execution target of the +queued operation. This avoids two Governor instances while preserving the two-delay +model. + +All governance parameters (`votingDelay`, `votingPeriod`, `proposalThreshold`, +`quorumNumerator`) are `GovernorSettings`-settable by governance itself after +deployment. + +--- + +## 5. Voting Power Model + +**Decision: locked DIN (stDIN).** + +`DinGovernanceStaking` locks DIN and mints a non-transferable ERC-20 (stDIN) that +implements OZ `IVotes` via `ERC20Votes`. Voting power is checkpointed at the block the +proposal becomes active. + +### Rationale + +- Free-balance DIN voting is rejected because DIN is a freely transferable ERC-20; + snapshot-at-proposal without a locking commitment invites flash-loan and last-minute + purchase attacks. +- Validator stake in `DinValidatorStake` is **not** counted toward governance power in + v1. Hybrid stake-voting would create a conflict of interest when governance votes on + slashing conditions or blacklisting appeals. Validators can participate by separately + locking DIN in `DinGovernanceStaking`. +- Quadratic voting is explicitly excluded. Non-binding signaling off-chain only, if + ever introduced. + +### Delegation + +Full OZ `ERC20Votes` delegation is supported. An address must call `delegate(self)` to +activate its own voting power; undelegated stDIN does not count toward quorum. This +matches the OZ Governor standard expectation. + +### Ossification path + +Because `DinGovernanceStaking` mints the stDIN that governs DinGovernor, governance can +vote to lock the upgrade path permanently by: +1. Removing `UPGRADER_ROLE` from all addresses on the ProxyAdmin (if UUPS) or + renouncing ProxyAdmin ownership. +2. Removing the `PROPOSER_ROLE` from itself on the timelocks. + +This is a one-proposal endgame — no migration required. + +--- + +## 6. Own Multisig vs. Gnosis Safe + +### 6.1 Build our own (chosen for devnet) + +`DinMultisig` is an in-tree, in-scope contract written to DIN's conventions (solc +0.8.28, custom errors, NatSpec, events-first). This gives: + +- full auditability alongside the contracts it governs +- typed proposals with per-category thresholds not available out of the box in Safe +- no external deploy dependency for devnet self-containment + +### 6.2 Gnosis Safe for mainnet treasury + +For mainnet, the multisig signers should migrate to a Gnosis Safe for: +- battle-tested security and audits +- existing tooling (Safe UI, SDK, transaction service) +- hardware-wallet signing support + +The migration path: `DinMultisig` transfers `PROPOSER_ROLE` and `CANCELLER_ROLE` on +both timelocks to a Safe address via a normal `Upgrade`-category proposal. No platform +contracts change. + +### 6.3 Recommendation + +Run `DinMultisig` for devnet 2.0 through testnet 1.0. Begin Safe migration planning +during testnet 2.0 alongside Stage D activation and the audit preparation window. + +--- + +## 7. Timelock Delay Rationale + +Two delay tiers rather than per-operation salt conventions because: +- the timelock delay is set globally on `TimelockController` and cannot be overridden + per-operation without a custom extension +- two independent `DinTimelock` instances each with a fixed delay is simpler to reason + about, deploy, and audit than a single instance with conditional delay logic + +Short delay (24 h): bounded parameter changes whose blast radius is limited. A wrong +fee can be corrected in the next proposal. + +Long delay (48 h): treasury withdrawals and upgrade proposals. On Optimism Sepolia the +execution gas is nearly free; 48 h gives DIN participants (validators, clients, +aggregators) adequate time to observe a pending upgrade and exit if they disagree. A +compromised multisig cannot swap in malicious logic silently — there is a 48 h window +to observe and cancel. + +--- + +## 8. CLI Command Mapping (dincli dindao) + +Current `dincli dindao ...` commands map to DAO flows as follows. CLI implementation +is out of scope for this issue but must be documented here so DIN-SDK (#20) and +DIN-daemon (#21) can plan for it. + +| Current dincli command | DAO equivalent (Stage B+) | +|--------------------------------------------|-----------------------------------------| +| `dindao approve-model-request` | `propose` + `confirm` + `execute` via multisig → timelock | +| `dindao reject-model-request` | Same | +| `dindao approve-manifest-update` | Same | +| `dindao add-slasher-contract` | `Operational` proposal through multisig | +| `dindao remove-slasher-contract` | Same | +| `dindao withdraw-eth` | `Treasury` proposal through multisig | +| `dindao update-din-per-eth` | `Parameter` proposal through multisig | +| `dindao blacklist-validator` | `Operational` or guardian emergency | +| `dindao unblacklist-validator` | Full governance proposal only (Stage C) | +| Contract upgrade (no current CLI command) | `Upgrade` proposal; unanimous multisig | + +--- + +## 9. Integration Points + +### 9.1 PR #13 (upgradeable contracts) + +The `ProxyAdmin` created in PR #13 is the handle for upgrade governance. Its ownership +transfers to `DinTimelockLong` at Stage B. No changes to the four platform contracts are +required for Stages A or B; Stage D requires platform contracts to expose an +`onlyGuardian` path for emergency protective actions (tracked separately). + +### 9.2 P3-5.x (token utility, emission, fees) + +`DinCoordinator.updateDinPerEth`, `DINModelRegistry` fee setters, and future emission +rate setters (P3-5.2) are `onlyOwner` parameter setters. They are the primary `Parameter` +and `Treasury` category targets for DAO proposals. No signature changes are required if +they follow the plain-setter convention; any setter that bundles side effects beyond the +parameter update should be flagged before Stage B activation. + +### 9.3 Slashing / dispute resolution (P3-4.x) + +The blacklisting and confiscation-of-blacklisted-stake path (described in +`Developer/issues/staking-mechanism.md`) must execute via accepted governance proposal +in the long run. `DinGuardian` covers the emergency-blacklist case at Stage D; the +confiscation path goes through `DinGovernor` as a `Treasury` category proposal. + +### 9.4 Indexer (P4-IDX) + +All DAO contracts emit events on every state transition. No on-chain enumeration helpers +are added — proposal lists, voter histories, quorum checks, and guardian action +dashboards are indexer responsibilities. Refer to `Developer/issues/indexer.md`. + +--- + +## 10. Open Design Questions — Resolved + +Answers to the open questions from `Developer/issues/decentralized-governance.md`: + +| Question | Decision | +|----------|----------| +| Model approval / manifest approval — direct DAO vote or elected committee? | Direct multisig proposal (Stage B). Move to Governor (Stage C) with `Operational` threshold. No elected committee in v1. | +| Validator blacklisting and unblacklisting — same threshold? | Different. Blacklisting is `Operational` (lower threshold; also available as guardian emergency action). Unblacklisting is `Operational` via full governance only — no emergency path. Restorative actions always go through normal governance. | +| Treasury and upgrade proposals — supermajority? | Yes. ≥66 % of participating votes, higher quorum (15–20 %). See §4.2. | +| Voting power: staked DIN, locked DIN, or hybrid? | Locked DIN (stDIN). Validator stake not counted in v1. See §5. | +| Quadratic voting anywhere? | No. Non-binding signaling off-chain only, if ever introduced. | +| One chamber or separated by role? | One chamber for v1. Bicameral governance (validators vs. token holders) is a Phase 4 consideration per `decentralized-governance.md`. | + +--- + +## 11. Non-Goals (this issue) + +- Quadratic voting (on-chain or off-chain) +- Task-level governance — task contracts remain model-owner-driven +- On-chain proposal enumeration helpers (indexer responsibility) +- Mainnet treasury custody decisions (Safe migration documented but not committed) +- Cross-layer governance (L1 ↔ L2 bridge-routed upgrades) — out of scope for v1 + +--- + +## 12. Ownership-Transfer Runbook + +See `Documentation/dindao.md §5` (to be updated). The exact ordered steps to transfer +`owner()` of each platform contract to the appropriate timelock, with rollback notes, +will be added there once Stage B contracts are finalized and reviewed. diff --git a/FUNDING.json b/FUNDING.json new file mode 100644 index 0000000..bfc35a5 --- /dev/null +++ b/FUNDING.json @@ -0,0 +1,7 @@ +{ + "drips": { + "ethereum": { + "ownedBy": "0xe516723D5fE6B6fe1fD8Da8753eBEf6c8f4904fD" + } + } +} diff --git a/foundry/src/dao/DinGovernanceStaking.sol b/foundry/src/dao/DinGovernanceStaking.sol new file mode 100644 index 0000000..3176d7f --- /dev/null +++ b/foundry/src/dao/DinGovernanceStaking.sol @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.28; + +import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Votes.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; +import "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; +import "./interfaces/IDinGovernanceStaking.sol"; + +// ───────────────────────────────────────────────────────────────────────────── +// Custom errors +// ───────────────────────────────────────────────────────────────────────────── + +/// @dev Lock amount must be greater than zero. +error GS_ZeroAmount(); +/// @dev Caller holds insufficient stDIN to unlock the requested amount. +error GS_InsufficientBalance(); +/// @dev stDIN is non-transferable; direct transfers are not permitted. +error GS_NonTransferable(); + +// ───────────────────────────────────────────────────────────────────────────── +// DinGovernanceStaking +// ───────────────────────────────────────────────────────────────────────────── + +/// @title DIN Governance Staking (stDIN) +/// @notice Lock DIN tokens to receive non-transferable, checkpointed voting power +/// for use with DinGovernor. Implements OZ IVotes via ERC20Votes. +/// @dev Voting power is based on locked DIN, not free balances or validator +/// stake, enforcing the two standing decisions from the governance spec: +/// no free-balance voting, no raw quadratic voting. +/// +/// stDIN is 1:1 with locked DIN but non-transferable — `transfer` and +/// `transferFrom` always revert. Accounts must self-delegate or delegate +/// to another address to activate voting power; undelegated stDIN does +/// not count toward Governor quorum. +/// +/// Validator stake in DinValidatorStake is explicitly excluded from +/// governance power in v1 to avoid conflicts of interest when governance +/// votes on slashing conditions or blacklisting appeals. +contract DinGovernanceStaking is ERC20, ERC20Votes, ReentrancyGuard, IDinGovernanceStaking { + using SafeERC20 for IERC20; + + // ─── Immutables ─────────────────────────────────────────────────────────── + + IERC20 private immutable _dinToken; + + // ─── Constructor ────────────────────────────────────────────────────────── + + /// @notice Deploy stDIN bound to a specific DIN token address. + /// @param dinToken_ Address of the DIN ERC-20 token to accept as collateral. + constructor(address dinToken_) + ERC20("Staked DIN", "stDIN") + EIP712("Staked DIN", "1") + { + require(dinToken_ != address(0)); + _dinToken = IERC20(dinToken_); + } + + // ─── Core actions ───────────────────────────────────────────────────────── + + /// @inheritdoc IDinGovernanceStaking + function lock(uint256 amount) external nonReentrant { + if (amount == 0) revert GS_ZeroAmount(); + _dinToken.safeTransferFrom(msg.sender, address(this), amount); + _mint(msg.sender, amount); + emit Locked(msg.sender, amount); + } + + /// @inheritdoc IDinGovernanceStaking + function unlock(uint256 amount) external nonReentrant { + if (amount == 0) revert GS_ZeroAmount(); + if (balanceOf(msg.sender) < amount) revert GS_InsufficientBalance(); + _burn(msg.sender, amount); + _dinToken.safeTransfer(msg.sender, amount); + emit Unlocked(msg.sender, amount); + } + + // ─── Non-transferability ────────────────────────────────────────────────── + + /// @dev Reverts unconditionally; stDIN cannot be moved between accounts. + function transfer(address, uint256) public pure override(ERC20, IERC20) returns (bool) { + revert GS_NonTransferable(); + } + + /// @dev Reverts unconditionally; stDIN cannot be moved between accounts. + function transferFrom(address, address, uint256) + public + pure + override(ERC20, IERC20) + returns (bool) + { + revert GS_NonTransferable(); + } + + // ─── Views ──────────────────────────────────────────────────────────────── + + /// @inheritdoc IDinGovernanceStaking + function dinToken() external view returns (address) { + return address(_dinToken); + } + + /// @inheritdoc IDinGovernanceStaking + function totalLocked() external view returns (uint256) { + return _dinToken.balanceOf(address(this)); + } + + // ─── ERC20Votes overrides ───────────────────────────────────────────────── + + /// @dev Required by ERC20Votes to compute voting units from balance. + function _update(address from, address to, uint256 value) + internal + override(ERC20, ERC20Votes) + { + super._update(from, to, value); + } + + /// @dev Required by ERC20Votes; returns the block number as the clock value. + function clock() public view override returns (uint48) { + return uint48(block.number); + } + + /// @dev Declares that this contract uses block numbers as the clock mode. + // solhint-disable-next-line func-name-mixedcase + function CLOCK_MODE() public pure override returns (string memory) { + return "mode=blocknumber&from=default"; + } +} diff --git a/foundry/src/dao/DinGovernor.sol b/foundry/src/dao/DinGovernor.sol new file mode 100644 index 0000000..8a05fda --- /dev/null +++ b/foundry/src/dao/DinGovernor.sol @@ -0,0 +1,173 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.28; + +import "@openzeppelin/contracts/governance/Governor.sol"; +import "@openzeppelin/contracts/governance/extensions/GovernorSettings.sol"; +import "@openzeppelin/contracts/governance/extensions/GovernorCountingSimple.sol"; +import "@openzeppelin/contracts/governance/extensions/GovernorVotes.sol"; +import "@openzeppelin/contracts/governance/extensions/GovernorVotesQuorumFraction.sol"; +import "@openzeppelin/contracts/governance/extensions/GovernorTimelockControl.sol"; + +// ───────────────────────────────────────────────────────────────────────────── +// DinGovernor +// ───────────────────────────────────────────────────────────────────────────── + +/// @title DIN Governor +/// @notice On-chain token-vote governor for the DIN DAO. Stage C of the +/// progressive-decentralisation rollout (testnet 1.0). +/// @dev Composition: +/// - GovernorSettings — votingDelay, votingPeriod, proposalThreshold +/// (all governance-settable post-deployment) +/// - GovernorCountingSimple — For / Against / Abstain counting +/// - GovernorVotes — voting power from DinGovernanceStaking (IVotes) +/// - GovernorVotesQuorumFraction — quorumNumerator as % of total supply +/// - GovernorTimelockControl — queues and executes through DinTimelock +/// +/// Proposal routing: the proposer encodes the target timelock address +/// in the proposal's targets array. Short-delay proposals target +/// DinTimelockShort; long-delay proposals target DinTimelockLong. +/// This avoids two Governor instances while preserving the two-delay model. +/// +/// DinMultisig retains CANCELLER_ROLE on both timelocks as a safety +/// brake — it can cancel a queued proposal before execution if the +/// technical team identifies a critical flaw. +/// +/// All governance parameters are adjustable by governance itself after +/// deployment. Initial values should be conservative and tightened once +/// token distribution matures. +contract DinGovernor is + Governor, + GovernorSettings, + GovernorCountingSimple, + GovernorVotes, + GovernorVotesQuorumFraction, + GovernorTimelockControl +{ + // ─── Constructor ────────────────────────────────────────────────────────── + + /// @notice Deploy DinGovernor bound to a stDIN voting token and a timelock. + /// @dev Pass DinTimelockLong as the primary timelock. Short-delay proposals + /// are routed by encoding DinTimelockShort as the call target in the + /// proposal's targets array rather than requiring a second Governor. + /// @param token_ DinGovernanceStaking (stDIN) address — the IVotes source. + /// @param timelock_ DinTimelockLong address for proposal execution. + /// @param initialVotingDelay Blocks before voting opens after a proposal is created. + /// Suggested: 7200 (≈ 24 h at 12-second blocks on L1; + /// use 1800 for OP-stack L2 with 2-second blocks). + /// @param initialVotingPeriod Blocks the voting window stays open. + /// Suggested: 50400 (≈ 7 days on L1; 151200 on OP-stack). + /// @param initialProposalThreshold Minimum voting power required to submit a proposal. + /// @param initialQuorumFraction Quorum as a percentage of total stDIN supply (e.g. 4 + /// for 4%). Upgrade and treasury proposals should be + /// submitted with a higher quorum target encoded in the + /// proposal description until per-category quorum is + /// implemented in a future Governor extension. + constructor( + IVotes token_, + TimelockController timelock_, + uint48 initialVotingDelay, + uint32 initialVotingPeriod, + uint256 initialProposalThreshold, + uint256 initialQuorumFraction + ) + Governor("DIN Governor") + GovernorSettings(initialVotingDelay, initialVotingPeriod, initialProposalThreshold) + GovernorVotes(token_) + GovernorVotesQuorumFraction(initialQuorumFraction) + GovernorTimelockControl(timelock_) + {} + + // ─── Required overrides ─────────────────────────────────────────────────── + + /// @inheritdoc IGovernor + function votingDelay() public view override(Governor, GovernorSettings) returns (uint256) { + return super.votingDelay(); + } + + /// @inheritdoc IGovernor + function votingPeriod() public view override(Governor, GovernorSettings) returns (uint256) { + return super.votingPeriod(); + } + + /// @inheritdoc IGovernor + function quorum(uint256 blockNumber) + public + view + override(Governor, GovernorVotesQuorumFraction) + returns (uint256) + { + return super.quorum(blockNumber); + } + + /// @inheritdoc Governor + function proposalThreshold() + public + view + override(Governor, GovernorSettings) + returns (uint256) + { + return super.proposalThreshold(); + } + + /// @inheritdoc Governor + function state(uint256 proposalId) + public + view + override(Governor, GovernorTimelockControl) + returns (ProposalState) + { + return super.state(proposalId); + } + + /// @inheritdoc Governor + function proposalNeedsQueuing(uint256 proposalId) + public + view + override(Governor, GovernorTimelockControl) + returns (bool) + { + return super.proposalNeedsQueuing(proposalId); + } + + /// @inheritdoc Governor + function _queueOperations( + uint256 proposalId, + address[] memory targets, + uint256[] memory values, + bytes[] memory calldatas, + bytes32 descriptionHash + ) internal override(Governor, GovernorTimelockControl) returns (uint48) { + return super._queueOperations(proposalId, targets, values, calldatas, descriptionHash); + } + + /// @inheritdoc Governor + function _executeOperations( + uint256 proposalId, + address[] memory targets, + uint256[] memory values, + bytes[] memory calldatas, + bytes32 descriptionHash + ) internal override(Governor, GovernorTimelockControl) { + super._executeOperations(proposalId, targets, values, calldatas, descriptionHash); + } + + /// @inheritdoc Governor + function _cancel( + address[] memory targets, + uint256[] memory values, + bytes[] memory calldatas, + bytes32 descriptionHash + ) internal override(Governor, GovernorTimelockControl) returns (uint256) { + return super._cancel(targets, values, calldatas, descriptionHash); + } + + /// @inheritdoc Governor + function _executor() + internal + view + override(Governor, GovernorTimelockControl) + returns (address) + { + return super._executor(); + } +} diff --git a/foundry/src/dao/DinGuardian.sol b/foundry/src/dao/DinGuardian.sol new file mode 100644 index 0000000..37e00ec --- /dev/null +++ b/foundry/src/dao/DinGuardian.sol @@ -0,0 +1,211 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.28; + +import "./interfaces/IDinGuardian.sol"; + +// ───────────────────────────────────────────────────────────────────────────── +// Custom errors +// ───────────────────────────────────────────────────────────────────────────── + +/// @dev Caller is not the designated guardian (DinMultisig). +error DG_NotGuardian(); +/// @dev Caller is not the designated governor (DinGovernor). +error DG_NotGovernor(); +/// @dev The action ID does not correspond to any existing emergency action. +error DG_InvalidActionId(); +/// @dev The action is not in the Active state and cannot be ratified. +error DG_ActionNotActive(); +/// @dev The ratification window has not elapsed; the action cannot be expired yet. +error DG_WindowNotElapsed(); +/// @dev The action was already ratified or expired; it cannot be expired again. +error DG_ActionNotExpirable(); +/// @dev The emergency call dispatched to the target reverted. +error DG_ActionCallFailed(); +/// @dev The reversal call dispatched on expiry reverted. +error DG_ReversalCallFailed(); +/// @dev The supplied address is the zero address. +error DG_ZeroAddress(); + +// ───────────────────────────────────────────────────────────────────────────── +// DinGuardian +// ───────────────────────────────────────────────────────────────────────────── + +/// @title DIN Guardian +/// @notice Narrow-scope emergency authority for the DIN platform. Stage D of +/// the progressive-decentralisation rollout (testnet 1.0 alongside Stage C). +/// @dev Every emergency action has a fixed ratification window. If DinGovernor +/// does not ratify within the window, any address may call `expireAction` +/// to dispatch the stored reversal calldata and undo the original action. +/// +/// Scope is deliberately limited to protective operations: +/// - disable a malicious model in DINModelRegistry +/// - deauthorize a dangerous slasher contract in DinCoordinator +/// - trigger a guardian-gated pause on any platform contract that +/// exposes one +/// +/// Restorative actions (re-enable model, re-authorize slasher, +/// unblacklist validator) are never permitted as emergency actions — +/// they must always go through normal governance. +/// +/// Platform contracts must expose an `onlyGuardian` path for each +/// emergency-eligible function before Stage D activation. That integration +/// is tracked as a separate follow-up task. +/// +/// The guardian role is held by DinMultisig and is revocable by governance +/// via a standard Upgrade-category proposal that deploys a new guardian and +/// transfers the guardian address on each platform contract. +contract DinGuardian is IDinGuardian { + // ─── Storage ────────────────────────────────────────────────────────────── + + struct Action { + address target; + bytes data; + bytes reversalData; + string description; + ActionState state; + uint256 expiry; + } + + address private _guardian; + address private _governor; + uint256 private immutable _ratificationWindow; + uint256 private _actionCount; + mapping(uint256 => Action) private _actions; + + // ─── Constructor ────────────────────────────────────────────────────────── + + /// @notice Deploy the guardian with a fixed guardian address and ratification window. + /// @dev `governor_` may be address(0) at deployment if Stage C is not yet live. + /// Call `setGovernor` once DinGovernor is deployed. + /// @param guardian_ Address of DinMultisig (holds the guardian role). + /// @param governor_ Address of DinGovernor (permitted to ratify actions). + /// @param ratificationWindow_ Seconds after an action is performed during which + /// governance must ratify it. 7 days is the recommended + /// initial value — long enough for a governance vote to + /// complete, short enough to avoid indefinitely suspended + /// emergency state. + constructor(address guardian_, address governor_, uint256 ratificationWindow_) { + if (guardian_ == address(0)) revert DG_ZeroAddress(); + _guardian = guardian_; + _governor = governor_; + _ratificationWindow = ratificationWindow_; + } + + // ─── Modifiers ──────────────────────────────────────────────────────────── + + modifier onlyGuardian() { + if (msg.sender != _guardian) revert DG_NotGuardian(); + _; + } + + modifier onlyGovernor() { + if (msg.sender != _governor) revert DG_NotGovernor(); + _; + } + + // ─── Core actions ───────────────────────────────────────────────────────── + + /// @inheritdoc IDinGuardian + function performAction( + address target, + bytes calldata data, + bytes calldata reversalData, + string calldata description + ) external onlyGuardian returns (uint256 actionId) { + if (target == address(0)) revert DG_ZeroAddress(); + + actionId = _actionCount++; + uint256 expiry = block.timestamp + _ratificationWindow; + + _actions[actionId] = Action({ + target: target, + data: data, + reversalData: reversalData, + description: description, + state: ActionState.Active, + expiry: expiry + }); + + (bool ok, ) = target.call(data); + if (!ok) revert DG_ActionCallFailed(); + + emit EmergencyActionPerformed(actionId, target, data, expiry, description); + } + + /// @inheritdoc IDinGuardian + function ratifyAction(uint256 actionId) external onlyGovernor { + Action storage a = _actions[actionId]; + if (a.state == ActionState.NotExist) revert DG_InvalidActionId(); + if (a.state != ActionState.Active) revert DG_ActionNotActive(); + + a.state = ActionState.Ratified; + emit ActionRatified(actionId); + } + + /// @inheritdoc IDinGuardian + function expireAction(uint256 actionId) external { + Action storage a = _actions[actionId]; + if (a.state == ActionState.NotExist) revert DG_InvalidActionId(); + if (a.state != ActionState.Active) revert DG_ActionNotExpirable(); + if (block.timestamp < a.expiry) revert DG_WindowNotElapsed(); + + a.state = ActionState.Expired; + + bytes memory reversal = a.reversalData; + if (reversal.length > 0) { + (bool ok, ) = a.target.call(reversal); + if (!ok) revert DG_ReversalCallFailed(); + } + + emit ActionExpired(actionId, reversal); + } + + // ─── Admin ──────────────────────────────────────────────────────────────── + + /// @inheritdoc IDinGuardian + function setGovernor(address newGovernor) external onlyGuardian { + if (newGovernor == address(0)) revert DG_ZeroAddress(); + _governor = newGovernor; + emit GovernorUpdated(newGovernor); + } + + // ─── Views ──────────────────────────────────────────────────────────────── + + /// @inheritdoc IDinGuardian + function guardian() external view returns (address) { + return _guardian; + } + + /// @inheritdoc IDinGuardian + function governor() external view returns (address) { + return _governor; + } + + /// @inheritdoc IDinGuardian + function ratificationWindow() external view returns (uint256) { + return _ratificationWindow; + } + + /// @inheritdoc IDinGuardian + function getAction(uint256 actionId) + external + view + returns ( + address target, + bytes memory data, + bytes memory reversalData, + string memory description, + ActionState state, + uint256 expiry + ) + { + if (actionId >= _actionCount) revert DG_InvalidActionId(); + Action storage a = _actions[actionId]; + return (a.target, a.data, a.reversalData, a.description, a.state, a.expiry); + } + + /// @inheritdoc IDinGuardian + function actionCount() external view returns (uint256) { + return _actionCount; + } +} diff --git a/foundry/src/dao/DinMultisig.sol b/foundry/src/dao/DinMultisig.sol new file mode 100644 index 0000000..120d30a --- /dev/null +++ b/foundry/src/dao/DinMultisig.sol @@ -0,0 +1,238 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.28; + +import "./interfaces/IDinMultisig.sol"; + +// ───────────────────────────────────────────────────────────────────────────── +// Custom errors +// ───────────────────────────────────────────────────────────────────────────── + +/// @dev Caller is not an authorised signer on this multisig. +error MS_NotSigner(); +/// @dev The proposal is not in the Open state; confirmation cannot be added. +error MS_ProposalNotOpen(); +/// @dev The proposal is not in the Executable state; it cannot be dispatched. +error MS_ProposalNotExecutable(); +/// @dev The proposal is not in a cancellable state (Open or Executable). +error MS_ProposalNotCancellable(); +/// @dev This signer has already confirmed the proposal. +error MS_AlreadyConfirmed(); +/// @dev This signer has not confirmed the proposal; nothing to revoke. +error MS_NotConfirmed(); +/// @dev The supplied address is the zero address. +error MS_ZeroAddress(); +/// @dev The signer list must contain at least one address. +error MS_EmptySignerList(); +/// @dev The signer list contains a duplicate address. +error MS_DuplicateSigner(); +/// @dev A confirmation threshold must be at least 1 and at most the signer count. +error MS_InvalidThreshold(); +/// @dev The proposal ID does not correspond to any existing proposal. +error MS_InvalidProposalId(); +/// @dev The low-level call dispatched by execute() reverted. +error MS_ExecutionFailed(); + +// ───────────────────────────────────────────────────────────────────────────── +// DinMultisig +// ───────────────────────────────────────────────────────────────────────────── + +/// @title DIN Multisig +/// @notice N-of-M multisig with per-category typed proposals for DAO governance +/// of the DIN platform. Stage A of the progressive-decentralisation rollout. +/// @dev Proposal categories carry independent confirmation thresholds so that +/// high-risk actions (treasury withdrawals, contract upgrades) require broader +/// signer consensus than routine parameter changes. After Stage B is deployed, +/// this contract holds PROPOSER_ROLE and CANCELLER_ROLE on DinTimelock. +/// +/// Signers and per-category thresholds are immutable after construction. +/// To change signers, deploy a new DinMultisig and transfer roles on the +/// timelocks via an Upgrade-category proposal through the existing instance. +contract DinMultisig is IDinMultisig { + // ─── Storage ────────────────────────────────────────────────────────────── + + struct Proposal { + address target; + bytes data; + uint256 value; + ProposalCategory category; + ProposalState state; + uint256 confirmCount; + uint256 createdAt; + } + + address[] private _signers; + mapping(address => bool) private _isSigner; + mapping(ProposalCategory => uint256) private _threshold; + + uint256 private _proposalCount; + mapping(uint256 => Proposal) private _proposals; + mapping(uint256 => mapping(address => bool)) private _confirmed; + + // ─── Constructor ────────────────────────────────────────────────────────── + + /// @notice Deploy the multisig with a fixed signer set and per-category thresholds. + /// @dev Thresholds are supplied as a fixed-length array indexed by + /// `ProposalCategory`: [Parameter, Operational, Treasury, Upgrade]. + /// Each threshold must be in [1, signers.length]. + /// @param signers_ Ordered list of authorised signer addresses (no duplicates). + /// @param thresholds_ Confirmation counts required per category (4-element array). + constructor(address[] memory signers_, uint256[4] memory thresholds_) { + uint256 n = signers_.length; + if (n == 0) revert MS_EmptySignerList(); + + for (uint256 i; i < n; ++i) { + address s = signers_[i]; + if (s == address(0)) revert MS_ZeroAddress(); + if (_isSigner[s]) revert MS_DuplicateSigner(); + _isSigner[s] = true; + _signers.push(s); + } + + for (uint256 c; c < 4; ++c) { + uint256 t = thresholds_[c]; + if (t == 0 || t > n) revert MS_InvalidThreshold(); + _threshold[ProposalCategory(c)] = t; + } + } + + // ─── Modifiers ──────────────────────────────────────────────────────────── + + modifier onlySigner() { + if (!_isSigner[msg.sender]) revert MS_NotSigner(); + _; + } + + // ─── Core actions ───────────────────────────────────────────────────────── + + /// @inheritdoc IDinMultisig + function propose( + address target, + bytes calldata data, + uint256 value, + ProposalCategory category + ) external onlySigner returns (uint256 proposalId) { + if (target == address(0)) revert MS_ZeroAddress(); + + proposalId = _proposalCount++; + + Proposal storage p = _proposals[proposalId]; + p.target = target; + p.data = data; + p.value = value; + p.category = category; + p.state = ProposalState.Open; + p.createdAt = block.timestamp; + + emit ProposalCreated(proposalId, msg.sender, target, category); + } + + /// @inheritdoc IDinMultisig + function confirm(uint256 proposalId) external onlySigner { + Proposal storage p = _proposals[proposalId]; + if (p.state != ProposalState.Open) revert MS_ProposalNotOpen(); + if (_confirmed[proposalId][msg.sender]) revert MS_AlreadyConfirmed(); + + _confirmed[proposalId][msg.sender] = true; + uint256 count = ++p.confirmCount; + + emit ProposalConfirmed(proposalId, msg.sender, count); + + if (count >= _threshold[p.category]) { + p.state = ProposalState.Executable; + emit ProposalExecutable(proposalId); + } + } + + /// @inheritdoc IDinMultisig + function revoke(uint256 proposalId) external onlySigner { + Proposal storage p = _proposals[proposalId]; + if (p.state != ProposalState.Open && p.state != ProposalState.Executable) { + revert MS_ProposalNotCancellable(); + } + if (!_confirmed[proposalId][msg.sender]) revert MS_NotConfirmed(); + + _confirmed[proposalId][msg.sender] = false; + uint256 count = --p.confirmCount; + + emit ProposalRevoked(proposalId, msg.sender, count); + + if (p.state == ProposalState.Executable && count < _threshold[p.category]) { + p.state = ProposalState.Open; + } + } + + /// @inheritdoc IDinMultisig + function execute(uint256 proposalId) external payable onlySigner { + Proposal storage p = _proposals[proposalId]; + if (p.state != ProposalState.Executable) revert MS_ProposalNotExecutable(); + + p.state = ProposalState.Executed; + + (bool success, ) = p.target.call{value: p.value}(p.data); + if (!success) revert MS_ExecutionFailed(); + + emit ProposalExecuted(proposalId, msg.sender); + } + + /// @inheritdoc IDinMultisig + function cancel(uint256 proposalId) external onlySigner { + Proposal storage p = _proposals[proposalId]; + if (p.state != ProposalState.Open && p.state != ProposalState.Executable) { + revert MS_ProposalNotCancellable(); + } + + p.state = ProposalState.Cancelled; + emit ProposalCancelled(proposalId, msg.sender); + } + + // ─── Views ──────────────────────────────────────────────────────────────── + + /// @inheritdoc IDinMultisig + function getProposal(uint256 proposalId) + external + view + returns ( + address target, + bytes memory data, + uint256 value, + ProposalCategory category, + ProposalState state, + uint256 confirmCount, + uint256 createdAt + ) + { + if (proposalId >= _proposalCount) revert MS_InvalidProposalId(); + Proposal storage p = _proposals[proposalId]; + return (p.target, p.data, p.value, p.category, p.state, p.confirmCount, p.createdAt); + } + + /// @inheritdoc IDinMultisig + function signers() external view returns (address[] memory) { + return _signers; + } + + /// @inheritdoc IDinMultisig + function threshold(ProposalCategory category) external view returns (uint256) { + return _threshold[category]; + } + + /// @inheritdoc IDinMultisig + function isSigner(address account) external view returns (bool) { + return _isSigner[account]; + } + + /// @inheritdoc IDinMultisig + function hasConfirmed(uint256 proposalId, address signer) external view returns (bool) { + return _confirmed[proposalId][signer]; + } + + /// @inheritdoc IDinMultisig + function proposalCount() external view returns (uint256) { + return _proposalCount; + } + + // ─── ETH receiver ───────────────────────────────────────────────────────── + + /// @notice Allows the multisig to receive ETH (e.g. for value-bearing proposals). + receive() external payable {} +} diff --git a/foundry/src/dao/DinTimelock.sol b/foundry/src/dao/DinTimelock.sol new file mode 100644 index 0000000..8d2fdca --- /dev/null +++ b/foundry/src/dao/DinTimelock.sol @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.28; + +import "@openzeppelin/contracts/governance/TimelockController.sol"; + +// ───────────────────────────────────────────────────────────────────────────── +// DinTimelock +// ───────────────────────────────────────────────────────────────────────────── + +/// @title DIN Timelock +/// @notice Thin instantiation of OZ TimelockController for DIN DAO governance. +/// @dev Deployed as two independent instances with different minimum delays: +/// +/// - DinTimelockShort (24 h) — Parameter and Operational proposals. +/// Bounded parameter changes whose blast radius is limited; a wrong +/// fee can be corrected in the next proposal cycle. +/// +/// - DinTimelockLong (48 h) — Treasury and Upgrade proposals. +/// Higher blast radius; 48 h gives DIN participants (validators, +/// clients, aggregators) adequate time to observe a pending upgrade +/// and exit if they disagree. +/// +/// Both instances should be deployed with: +/// - proposers = [DinMultisig] +/// - executors = [address(0)] (open execution — anyone can execute +/// once the delay has elapsed) +/// - admin = address(0) (renounce DEFAULT_ADMIN_ROLE at +/// construction; no post-deploy admin) +/// +/// At Stage C, DinGovernor is added as an additional proposer on both +/// instances via a governance proposal routed through the existing multisig. +/// +/// The governance contracts themselves (Governor, Timelock) are +/// non-upgradeable — the contract guarding upgrades must not itself be +/// upgradeable or the trust problem shifts up a level. +contract DinTimelock is TimelockController { + /// @notice Deploy a DIN Timelock instance. + /// @param minDelay_ Minimum seconds between scheduling and execution. + /// Pass 86400 (24 h) for the short instance or + /// 172800 (48 h) for the long instance. + /// @param proposers_ Addresses granted PROPOSER_ROLE (initially DinMultisig). + /// @param executors_ Addresses granted EXECUTOR_ROLE. Pass [address(0)] for + /// open execution (anyone may execute after the delay). + /// @param admin_ Address granted DEFAULT_ADMIN_ROLE. Pass address(0) to + /// renounce all admin authority at construction. + constructor( + uint256 minDelay_, + address[] memory proposers_, + address[] memory executors_, + address admin_ + ) TimelockController(minDelay_, proposers_, executors_, admin_) {} +} diff --git a/foundry/src/dao/interfaces/IDinGovernanceStaking.sol b/foundry/src/dao/interfaces/IDinGovernanceStaking.sol new file mode 100644 index 0000000..7428a98 --- /dev/null +++ b/foundry/src/dao/interfaces/IDinGovernanceStaking.sol @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.28; + +/// @title IDinGovernanceStaking +/// @notice Interface for the DIN Governance Staking contract (stDIN). +/// Locking DIN mints non-transferable stDIN that carries checkpointed +/// voting power for DinGovernor. +interface IDinGovernanceStaking { + // ─── Events ────────────────────────────────────────────────────────────── + + /// @notice Emitted when a user locks DIN and receives stDIN. + /// @param account Address that performed the lock. + /// @param amount DIN amount locked (and stDIN minted). + event Locked(address indexed account, uint256 amount); + + /// @notice Emitted when a user unlocks DIN by burning stDIN. + /// @param account Address that performed the unlock. + /// @param amount DIN amount returned (and stDIN burned). + event Unlocked(address indexed account, uint256 amount); + + // ─── Core actions ───────────────────────────────────────────────────────── + + /// @notice Lock DIN and receive an equivalent amount of stDIN voting power. + /// @dev Caller must have approved this contract for at least `amount` DIN. + /// stDIN is non-transferable; voting power is immediately checkpointed. + /// Caller must call `delegate(self)` to activate their own votes. + /// @param amount DIN amount to lock. + function lock(uint256 amount) external; + + /// @notice Unlock DIN by burning an equivalent amount of stDIN. + /// @dev Voting power is immediately reduced. Any active delegation is + /// automatically adjusted by the underlying ERC20Votes checkpoint. + /// @param amount stDIN amount to burn (equal to DIN returned). + function unlock(uint256 amount) external; + + // ─── Views ──────────────────────────────────────────────────────────────── + + /// @notice Returns the address of the underlying DIN ERC-20 token. + function dinToken() external view returns (address); + + /// @notice Returns the total DIN currently held in this contract. + function totalLocked() external view returns (uint256); +} diff --git a/foundry/src/dao/interfaces/IDinGuardian.sol b/foundry/src/dao/interfaces/IDinGuardian.sol new file mode 100644 index 0000000..0db300d --- /dev/null +++ b/foundry/src/dao/interfaces/IDinGuardian.sol @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.28; + +/// @notice Lifecycle state of an emergency action. +enum ActionState { + NotExist, // 0 — action ID not allocated + Active, // 1 — action dispatched; ratification window open + Ratified, // 2 — DinGovernor ratified the action; no reversal needed + Expired // 3 — window elapsed without ratification; action reversed +} + +/// @title IDinGuardian +/// @notice Interface for the DIN Guardian emergency authority contract. +/// Emergency actions are narrow, time-limited, and require governance +/// ratification within a fixed window or the action is reversed. +interface IDinGuardian { + // ─── Events ────────────────────────────────────────────────────────────── + + /// @notice Emitted when an emergency action is dispatched. + /// @param actionId Auto-incremented action identifier. + /// @param target Contract the emergency call was made against. + /// @param data ABI-encoded calldata that was dispatched. + /// @param expiry Timestamp by which governance must ratify the action. + /// @param description Human-readable description of the emergency. + event EmergencyActionPerformed( + uint256 indexed actionId, + address indexed target, + bytes data, + uint256 expiry, + string description + ); + + /// @notice Emitted when DinGovernor ratifies an active emergency action. + /// @param actionId Identifier of the ratified action. + event ActionRatified(uint256 indexed actionId); + + /// @notice Emitted when an action expires without ratification and is reversed. + /// @param actionId Identifier of the expired action. + /// @param reversalData Calldata dispatched to reverse the original action. + event ActionExpired(uint256 indexed actionId, bytes reversalData); + + /// @notice Emitted when the governor address is updated. + /// @param newGovernor Address of the new DinGovernor. + event GovernorUpdated(address indexed newGovernor); + + // ─── Core actions ───────────────────────────────────────────────────────── + + /// @notice Dispatch an emergency protective action. + /// @dev Only callable by the guardian (DinMultisig). The action is + /// recorded and a ratification window starts. If governance does + /// not ratify within the window, `expireAction` may be called to + /// reverse it. + /// + /// Scope is deliberately narrow — protective actions only: + /// - disable a model in DINModelRegistry + /// - deauthorize a slasher in DinCoordinator + /// - pause a dangerous flow (any contract exposing a guardian-gated pause) + /// + /// The reversal calldata must undo the original action exactly. + /// Platform contracts must expose an `onlyGuardian` path for each + /// emergency-eligible function (tracked as a follow-up integration task). + /// + /// @param target Contract to call. + /// @param data ABI-encoded calldata for the emergency call. + /// @param reversalData ABI-encoded calldata that undoes the action if not ratified. + /// @param description Human-readable description (emitted in event for indexers). + /// @return actionId Auto-incremented identifier assigned to this action. + function performAction( + address target, + bytes calldata data, + bytes calldata reversalData, + string calldata description + ) external returns (uint256 actionId); + + /// @notice Ratify an active emergency action, preventing reversal. + /// @dev Only callable by DinGovernor (after a successful governance vote + /// confirming the emergency action was appropriate). + /// @param actionId Identifier of the action to ratify. + function ratifyAction(uint256 actionId) external; + + /// @notice Reverse an emergency action that was not ratified within the window. + /// @dev Callable by anyone after the ratification window has elapsed. + /// Dispatches the stored reversal calldata against the original target. + /// @param actionId Identifier of the expired action to reverse. + function expireAction(uint256 actionId) external; + + // ─── Admin ──────────────────────────────────────────────────────────────── + + /// @notice Update the DinGovernor address that is permitted to ratify actions. + /// @dev Only callable by the guardian (DinMultisig). Should be called once + /// Stage C (DinGovernor) is deployed. Revocable by governance via a + /// standard governance proposal. + /// @param newGovernor Address of the deployed DinGovernor. + function setGovernor(address newGovernor) external; + + // ─── Views ──────────────────────────────────────────────────────────────── + + /// @notice Returns the guardian address (DinMultisig). + function guardian() external view returns (address); + + /// @notice Returns the DinGovernor address authorised to ratify actions. + function governor() external view returns (address); + + /// @notice Returns the ratification window in seconds. + function ratificationWindow() external view returns (uint256); + + /// @notice Returns the full details of an emergency action. + /// @param actionId Identifier to query. + /// @return target Contract the emergency call was dispatched to. + /// @return data Calldata that was dispatched. + /// @return reversalData Calldata to undo the action if not ratified. + /// @return description Human-readable description. + /// @return state Current action lifecycle state. + /// @return expiry Timestamp after which expireAction may be called. + function getAction(uint256 actionId) + external + view + returns ( + address target, + bytes memory data, + bytes memory reversalData, + string memory description, + ActionState state, + uint256 expiry + ); + + /// @notice Returns the total number of emergency actions created. + function actionCount() external view returns (uint256); +} diff --git a/foundry/src/dao/interfaces/IDinMultisig.sol b/foundry/src/dao/interfaces/IDinMultisig.sol new file mode 100644 index 0000000..e4705c0 --- /dev/null +++ b/foundry/src/dao/interfaces/IDinMultisig.sol @@ -0,0 +1,148 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.28; + +/// @notice Risk category of a multisig proposal. +/// @dev Maps to per-category confirmation thresholds stored in DinMultisig. +enum ProposalCategory { + Parameter, // 0 — fee updates, rate changes, bounded numeric parameters + Operational, // 1 — slasher auth, model disable/enable, validator blacklisting + Treasury, // 2 — ETH or token withdrawals, grant disbursements + Upgrade // 3 — platform contract upgrades, ProxyAdmin transfer +} + +/// @notice Lifecycle state of a multisig proposal. +enum ProposalState { + NotExist, // 0 — proposal ID not yet allocated + Open, // 1 — created; collecting confirmations + Executable, // 2 — confirmation threshold reached; ready to execute + Executed, // 3 — call dispatched successfully + Cancelled // 4 — cancelled by any signer before execution +} + +/// @title IDinMultisig +/// @notice Interface for the DIN N-of-M multisig with typed, per-category proposals. +interface IDinMultisig { + // ─── Events ────────────────────────────────────────────────────────────── + + /// @notice Emitted when a new proposal is created. + /// @param proposalId Auto-incremented proposal identifier. + /// @param proposer Signer who submitted the proposal. + /// @param target Contract the proposal will call. + /// @param category Risk category governing the confirmation threshold. + event ProposalCreated( + uint256 indexed proposalId, + address indexed proposer, + address target, + ProposalCategory category + ); + + /// @notice Emitted when a signer confirms a proposal. + /// @param proposalId Identifier of the confirmed proposal. + /// @param signer Address that added its confirmation. + /// @param confirmCount Running total of confirmations after this one. + event ProposalConfirmed( + uint256 indexed proposalId, + address indexed signer, + uint256 confirmCount + ); + + /// @notice Emitted when a signer revokes a previously cast confirmation. + /// @param proposalId Identifier of the affected proposal. + /// @param signer Address that withdrew its confirmation. + /// @param confirmCount Running total of confirmations after the revocation. + event ProposalRevoked( + uint256 indexed proposalId, + address indexed signer, + uint256 confirmCount + ); + + /// @notice Emitted when a proposal transitions from Open to Executable. + /// @param proposalId Identifier of the proposal that reached its threshold. + event ProposalExecutable(uint256 indexed proposalId); + + /// @notice Emitted when an executable proposal is successfully dispatched. + /// @param proposalId Identifier of the executed proposal. + /// @param executor Address that triggered execution. + event ProposalExecuted(uint256 indexed proposalId, address indexed executor); + + /// @notice Emitted when a proposal is cancelled. + /// @param proposalId Identifier of the cancelled proposal. + /// @param canceller Signer who cancelled it. + event ProposalCancelled(uint256 indexed proposalId, address indexed canceller); + + // ─── Core actions ───────────────────────────────────────────────────────── + + /// @notice Submit a new proposal. + /// @param target Contract address to call upon execution. + /// @param data ABI-encoded calldata for the target call. + /// @param value ETH value to forward with the call. + /// @param category Risk category that determines the required confirmation count. + /// @return proposalId Auto-incremented identifier assigned to this proposal. + function propose( + address target, + bytes calldata data, + uint256 value, + ProposalCategory category + ) external returns (uint256 proposalId); + + /// @notice Add the caller's confirmation to an open proposal. + /// @dev Automatically transitions state to Executable when the threshold is met. + /// @param proposalId Identifier of the proposal to confirm. + function confirm(uint256 proposalId) external; + + /// @notice Withdraw the caller's confirmation from a proposal. + /// @dev If the proposal was Executable, it reverts to Open. + /// @param proposalId Identifier of the proposal to revoke confirmation for. + function revoke(uint256 proposalId) external; + + /// @notice Dispatch an executable proposal. + /// @param proposalId Identifier of the proposal to execute. + function execute(uint256 proposalId) external payable; + + /// @notice Cancel an open or executable proposal. + /// @param proposalId Identifier of the proposal to cancel. + function cancel(uint256 proposalId) external; + + // ─── Views ──────────────────────────────────────────────────────────────── + + /// @notice Returns the full details of a proposal. + /// @param proposalId Identifier to query. + /// @return target Contract to be called. + /// @return data Calldata for the target. + /// @return value ETH value attached to the call. + /// @return category Risk category of the proposal. + /// @return state Current lifecycle state. + /// @return confirmCount Number of confirmations currently held. + /// @return createdAt Block timestamp when the proposal was created. + function getProposal(uint256 proposalId) + external + view + returns ( + address target, + bytes memory data, + uint256 value, + ProposalCategory category, + ProposalState state, + uint256 confirmCount, + uint256 createdAt + ); + + /// @notice Returns the full signer list. + function signers() external view returns (address[] memory); + + /// @notice Returns the confirmation threshold for a given category. + /// @param category Category to query. + function threshold(ProposalCategory category) external view returns (uint256); + + /// @notice Returns whether the given address is an authorized signer. + /// @param account Address to check. + function isSigner(address account) external view returns (bool); + + /// @notice Returns whether a specific signer has confirmed a proposal. + /// @param proposalId Proposal to query. + /// @param signer Signer address to check. + function hasConfirmed(uint256 proposalId, address signer) external view returns (bool); + + /// @notice Returns the total number of proposals created (including cancelled/executed). + function proposalCount() external view returns (uint256); +} diff --git a/foundry/test/dao/DaoInvariants.t.sol b/foundry/test/dao/DaoInvariants.t.sol new file mode 100644 index 0000000..112b968 --- /dev/null +++ b/foundry/test/dao/DaoInvariants.t.sol @@ -0,0 +1,375 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.28; + +import "forge-std/Test.sol"; +import "../../src/dao/DinGovernanceStaking.sol"; +import "../../src/dao/DinMultisig.sol"; +import "../../src/dao/DinTimelock.sol"; +import "../../src/dao/DinGuardian.sol"; +import "../../src/dao/interfaces/IDinMultisig.sol"; +import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +// ───────────────────────────────────────────────────────────────────────────── +// Shared mock DIN token +// ───────────────────────────────────────────────────────────────────────────── + +contract InvMockDIN is ERC20 { + constructor() ERC20("DIN Token", "DIN") {} + function mint(address to, uint256 amount) external { _mint(to, amount); } +} + +// ───────────────────────────────────────────────────────────────────────────── +// I. Staking invariants +// Invariant A: totalSupply of stDIN always equals DIN held by the contract +// Invariant B: total delegated voting power never exceeds stDIN totalSupply +// ───────────────────────────────────────────────────────────────────────────── + +/// @dev Handler called by the Foundry fuzzer for staking invariant tests. +/// Maintains a list of up to 4 actors and ghost variables tracking the +/// expected locked amount so invariant assertions have a ground truth. +contract StakingHandler is Test { + InvMockDIN internal din; + DinGovernanceStaking internal stDIN; + + address[4] internal actors; + uint256 public ghostTotalLocked; + + constructor(InvMockDIN din_, DinGovernanceStaking stDIN_) { + din = din_; + stDIN = stDIN_; + + actors[0] = makeAddr("inv_alice"); + actors[1] = makeAddr("inv_bob"); + actors[2] = makeAddr("inv_carol"); + actors[3] = makeAddr("inv_dave"); + + // Pre-fund every actor with 10 000 DIN + for (uint256 i; i < 4; ++i) { + din_.mint(actors[i], 10_000e18); + vm.prank(actors[i]); + din_.approve(address(stDIN_), type(uint256).max); + } + } + + // ─── Actions ────────────────────────────────────────────────────────────── + + /// @dev Lock a bounded amount of DIN for a random actor. + function lock(uint256 actorSeed, uint256 amount) external { + address actor = actors[actorSeed % 4]; + uint256 cap = din.balanceOf(actor); + if (cap == 0) return; + amount = bound(amount, 1, cap); + + vm.prank(actor); + stDIN.lock(amount); + ghostTotalLocked += amount; + } + + /// @dev Unlock a bounded amount of stDIN for a random actor. + function unlock(uint256 actorSeed, uint256 amount) external { + address actor = actors[actorSeed % 4]; + uint256 cap = stDIN.balanceOf(actor); + if (cap == 0) return; + amount = bound(amount, 1, cap); + + vm.prank(actor); + stDIN.unlock(amount); + ghostTotalLocked -= amount; + } + + /// @dev Delegate voting power between random actors to exercise checkpoints. + function delegate(uint256 actorSeed, uint256 delegateeSeed) external { + address actor = actors[actorSeed % 4]; + address delegatee = actors[delegateeSeed % 4]; + vm.prank(actor); + stDIN.delegate(delegatee); + } +} + +contract StakingInvariantTest is Test { + InvMockDIN internal din; + DinGovernanceStaking internal stDIN; + StakingHandler internal handler; + + function setUp() public { + din = new InvMockDIN(); + stDIN = new DinGovernanceStaking(address(din)); + handler = new StakingHandler(din, stDIN); + + targetContract(address(handler)); + } + + /// @dev Invariant A: stDIN total supply equals DIN locked in the contract. + /// Any discrepancy indicates a mint or burn without a matching transfer, + /// which would violate 1:1 conservation. + function invariant_TotalSupplyEqualsLockedDIN() public view { + assertEq( + stDIN.totalSupply(), + din.balanceOf(address(stDIN)), + "stDIN supply != DIN balance" + ); + } + + /// @dev Invariant B: ghost variable tracks every lock/unlock; it must + /// match both the contract's totalLocked view and the stDIN supply. + function invariant_GhostLockedMatchesContract() public view { + assertEq( + handler.ghostTotalLocked(), + stDIN.totalLocked(), + "ghost != totalLocked" + ); + assertEq( + stDIN.totalLocked(), + stDIN.totalSupply(), + "totalLocked != totalSupply" + ); + } + + /// @dev Invariant C: total votes delegated to all actors never exceeds + /// the stDIN total supply. Voting power cannot be conjured. + function invariant_TotalVotesNeverExceedSupply() public view { + address[4] memory actors_ = [ + makeAddr("inv_alice"), + makeAddr("inv_bob"), + makeAddr("inv_carol"), + makeAddr("inv_dave") + ]; + uint256 totalVotes; + for (uint256 i; i < 4; ++i) { + totalVotes += stDIN.getVotes(actors_[i]); + } + assertLe(totalVotes, stDIN.totalSupply(), "total votes > supply"); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// II. Timelock invariant / fuzz +// Nothing can execute before minDelay has elapsed from scheduling. +// ───────────────────────────────────────────────────────────────────────────── + +/// @dev Minimal call target used by timelock tests. +contract InvTimelockTarget { + uint256 public value; + function setValue(uint256 v) external { value = v; } +} + +contract TimelockFuzzTest is Test { + DinTimelock internal tl; + InvTimelockTarget internal target; + + address internal proposer_ = makeAddr("tl_proposer"); + + uint256 internal constant MIN_DELAY = 1 days; + + function setUp() public { + address[] memory proposers = new address[](1); + proposers[0] = proposer_; + address[] memory executors = new address[](1); + executors[0] = address(0); + + tl = new DinTimelock(MIN_DELAY, proposers, executors, address(0)); + target = new InvTimelockTarget(); + } + + /// @dev Fuzz: for any elapsed time strictly less than MIN_DELAY, execution + /// must revert. The fuzzer explores the entire [0, MIN_DELAY) range. + function testFuzz_CannotExecuteBeforeMinDelay(uint256 elapsed) public { + elapsed = bound(elapsed, 0, MIN_DELAY - 1); + + bytes memory data = abi.encodeCall(InvTimelockTarget.setValue, (1)); + bytes32 salt = bytes32(uint256(elapsed)); // unique salt per run + + vm.prank(proposer_); + tl.schedule(address(target), 0, data, bytes32(0), salt, MIN_DELAY); + + vm.warp(block.timestamp + elapsed); + + vm.expectRevert(); + tl.execute(address(target), 0, data, bytes32(0), salt); + } + + /// @dev Fuzz: for any elapsed time >= MIN_DELAY, execution must succeed. + function testFuzz_AlwaysExecutesAfterDelay(uint256 extra) public { + extra = bound(extra, 0, 365 days); + + bytes memory data = abi.encodeCall(InvTimelockTarget.setValue, (42)); + bytes32 salt = bytes32(extra); + + vm.prank(proposer_); + tl.schedule(address(target), 0, data, bytes32(0), salt, MIN_DELAY); + + vm.warp(block.timestamp + MIN_DELAY + extra); + tl.execute(address(target), 0, data, bytes32(0), salt); + + assertEq(target.value(), 42); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// III. Multisig fuzz +// Confirm/revoke sequences never produce a count outside [0, signerCount]. +// A proposal cannot reach Executable with fewer confirms than its threshold. +// ───────────────────────────────────────────────────────────────────────────── + +/// @dev Minimal call target for multisig fuzz tests. +contract InvMultisigTarget { + uint256 public value; + function setValue(uint256 v) external { value = v; } +} + +contract MultisigFuzzTest is Test { + DinMultisig internal ms; + InvMultisigTarget internal msTarget; + + address internal s0 = makeAddr("ms_s0"); + address internal s1 = makeAddr("ms_s1"); + address internal s2 = makeAddr("ms_s2"); + + // Thresholds: Parameter=2, Operational=2, Treasury=3, Upgrade=3 + uint256[4] internal thresholds = [uint256(2), 2, 3, 3]; + + function setUp() public { + address[] memory signers_ = new address[](3); + signers_[0] = s0; signers_[1] = s1; signers_[2] = s2; + ms = new DinMultisig(signers_, thresholds); + msTarget = new InvMultisigTarget(); + } + + /// @dev Fuzz: confirm count after a random sequence of confirms and revokes + /// from distinct signers must always be in [0, 3]. + function testFuzz_ConfirmCountBounded( + bool confirmS0, + bool confirmS1, + bool confirmS2, + bool revokeS0, + bool revokeS1 + ) public { + bytes memory data = abi.encodeCall(InvMultisigTarget.setValue, (1)); + vm.prank(s0); + uint256 id = ms.propose(address(msTarget), data, 0, ProposalCategory.Parameter); + + if (confirmS0) { vm.prank(s0); ms.confirm(id); } + if (confirmS1) { vm.prank(s1); ms.confirm(id); } + if (confirmS2) { vm.prank(s2); ms.confirm(id); } + + // Revoke only if previously confirmed to avoid revert on NotConfirmed + if (revokeS0 && confirmS0) { + (, , , , ProposalState st, , ) = ms.getProposal(id); + if (st == ProposalState.Open || st == ProposalState.Executable) { + vm.prank(s0); ms.revoke(id); + } + } + if (revokeS1 && confirmS1) { + (, , , , ProposalState st, , ) = ms.getProposal(id); + if (st == ProposalState.Open || st == ProposalState.Executable) { + vm.prank(s1); ms.revoke(id); + } + } + + (, , , , , uint256 count, ) = ms.getProposal(id); + assertLe(count, 3, "confirm count exceeds signer count"); + } + + /// @dev Fuzz: a Parameter proposal (threshold=2) can only become Executable + /// once at least 2 signers have confirmed without revoking. + function testFuzz_ExecutableRequiresThreshold(bool s1Confirms, bool s0Revokes) public { + bytes memory data = abi.encodeCall(InvMultisigTarget.setValue, (7)); + vm.prank(s0); + uint256 id = ms.propose(address(msTarget), data, 0, ProposalCategory.Parameter); + + vm.prank(s0); ms.confirm(id); + + if (s0Revokes) { + vm.prank(s0); ms.revoke(id); + } + + if (s1Confirms) { + vm.prank(s1); ms.confirm(id); + } + + (, , , , ProposalState state, uint256 count, ) = ms.getProposal(id); + + if (state == ProposalState.Executable) { + assertGe(count, 2, "Executable with fewer than threshold confirms"); + } + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// IV. Guardian fuzz +// expireAction always reverts before the ratification window elapses. +// ratifyAction always reverts after the action has already been ratified. +// ───────────────────────────────────────────────────────────────────────────── + +contract GuardianFuzz_Target { + bool public flag; + function setFlag() external { flag = true; } + function clearFlag() external { flag = false; } +} + +contract GuardianFuzzTest is Test { + DinGuardian internal grd; + GuardianFuzz_Target internal gTarget; + + address internal multisig_ = makeAddr("grd_multisig"); + address internal governor_ = makeAddr("grd_governor"); + + uint256 internal constant WINDOW = 7 days; + + function setUp() public { + grd = new DinGuardian(multisig_, governor_, WINDOW); + gTarget = new GuardianFuzz_Target(); + } + + /// @dev Fuzz: expireAction always reverts for any elapsed time before the + /// ratification window closes, regardless of how close to the boundary. + function testFuzz_CannotExpireBeforeWindow(uint256 elapsed) public { + elapsed = bound(elapsed, 0, WINDOW - 1); + + bytes memory data = abi.encodeCall(GuardianFuzz_Target.setFlag, ()); + bytes memory reversal = abi.encodeCall(GuardianFuzz_Target.clearFlag, ()); + + vm.prank(multisig_); + uint256 id = grd.performAction(address(gTarget), data, reversal, "fuzz"); + + vm.warp(block.timestamp + elapsed); + vm.expectRevert(DG_WindowNotElapsed.selector); + grd.expireAction(id); + } + + /// @dev Fuzz: for any elapsed time >= WINDOW, expireAction dispatches the + /// reversal and the target state is restored. + function testFuzz_AlwaysExpiresAfterWindow(uint256 extra) public { + extra = bound(extra, 0, 365 days); + + bytes memory data = abi.encodeCall(GuardianFuzz_Target.setFlag, ()); + bytes memory reversal = abi.encodeCall(GuardianFuzz_Target.clearFlag, ()); + + vm.prank(multisig_); + uint256 id = grd.performAction(address(gTarget), data, reversal, "fuzz"); + + assertTrue(gTarget.flag()); // action applied + + vm.warp(block.timestamp + WINDOW + extra); + grd.expireAction(id); + + assertFalse(gTarget.flag()); // reversal applied + } + + /// @dev Fuzz: double-ratify always reverts — an action cannot be ratified twice. + function testFuzz_CannotRatifyTwice(uint256 elapsed) public { + elapsed = bound(elapsed, 0, WINDOW - 1); + + bytes memory data = abi.encodeCall(GuardianFuzz_Target.setFlag, ()); + + vm.prank(multisig_); + uint256 id = grd.performAction(address(gTarget), data, "", "fuzz"); + + vm.warp(block.timestamp + elapsed); + vm.prank(governor_); grd.ratifyAction(id); + + vm.prank(governor_); + vm.expectRevert(DG_ActionNotActive.selector); + grd.ratifyAction(id); + } +} diff --git a/foundry/test/dao/DinGovernanceStaking.t.sol b/foundry/test/dao/DinGovernanceStaking.t.sol new file mode 100644 index 0000000..4a673a3 --- /dev/null +++ b/foundry/test/dao/DinGovernanceStaking.t.sol @@ -0,0 +1,166 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.28; + +import "forge-std/Test.sol"; +import "../../src/dao/DinGovernanceStaking.sol"; +import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +/// @dev Minimal ERC-20 used as the DIN stand-in in tests. +contract MockDIN is ERC20 { + constructor() ERC20("DIN Token", "DIN") {} + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } +} + +contract DinGovernanceStakingTest is Test { + MockDIN internal din; + DinGovernanceStaking internal stDIN; + + address internal alice = makeAddr("alice"); + address internal bob = makeAddr("bob"); + + uint256 internal constant INITIAL = 1_000e18; + + function setUp() public { + din = new MockDIN(); + stDIN = new DinGovernanceStaking(address(din)); + + din.mint(alice, INITIAL); + din.mint(bob, INITIAL); + } + + // ─── Constructor ────────────────────────────────────────────────────────── + + function test_DinTokenAddress() public view { + assertEq(stDIN.dinToken(), address(din)); + } + + function test_NameAndSymbol() public view { + assertEq(stDIN.name(), "Staked DIN"); + assertEq(stDIN.symbol(), "stDIN"); + } + + // ─── lock ───────────────────────────────────────────────────────────────── + + function test_Lock_MintsStDIN() public { + vm.startPrank(alice); + din.approve(address(stDIN), 100e18); + stDIN.lock(100e18); + vm.stopPrank(); + + assertEq(stDIN.balanceOf(alice), 100e18); + assertEq(din.balanceOf(address(stDIN)), 100e18); + assertEq(stDIN.totalLocked(), 100e18); + } + + function test_Lock_RevertsOnZero() public { + vm.prank(alice); + vm.expectRevert(GS_ZeroAmount.selector); + stDIN.lock(0); + } + + // ─── unlock ─────────────────────────────────────────────────────────────── + + function test_Unlock_BurnsStDINAndReturnsDIN() public { + _lock(alice, 100e18); + + vm.prank(alice); + stDIN.unlock(60e18); + + assertEq(stDIN.balanceOf(alice), 40e18); + assertEq(din.balanceOf(alice), INITIAL - 100e18 + 60e18); + } + + function test_Unlock_RevertsOnInsufficientBalance() public { + _lock(alice, 50e18); + vm.prank(alice); + vm.expectRevert(GS_InsufficientBalance.selector); + stDIN.unlock(51e18); + } + + function test_Unlock_RevertsOnZero() public { + _lock(alice, 50e18); + vm.prank(alice); + vm.expectRevert(GS_ZeroAmount.selector); + stDIN.unlock(0); + } + + // ─── Non-transferability ────────────────────────────────────────────────── + + function test_Transfer_AlwaysReverts() public { + _lock(alice, 100e18); + vm.prank(alice); + vm.expectRevert(GS_NonTransferable.selector); + stDIN.transfer(bob, 10e18); + } + + function test_TransferFrom_AlwaysReverts() public { + _lock(alice, 100e18); + vm.prank(alice); + stDIN.approve(bob, 50e18); + vm.prank(bob); + vm.expectRevert(GS_NonTransferable.selector); + stDIN.transferFrom(alice, bob, 50e18); + } + + // ─── Delegation + voting power checkpoints ──────────────────────────────── + + function test_VotingPowerZeroWithoutDelegation() public { + _lock(alice, 100e18); + // No self-delegation → getVotes returns 0 + assertEq(stDIN.getVotes(alice), 0); + } + + function test_VotingPowerAfterSelfDelegate() public { + _lock(alice, 100e18); + vm.prank(alice); + stDIN.delegate(alice); + assertEq(stDIN.getVotes(alice), 100e18); + } + + function test_DelegationTransfersVotingPower() public { + _lock(alice, 100e18); + vm.prank(alice); + stDIN.delegate(bob); + assertEq(stDIN.getVotes(alice), 0); + assertEq(stDIN.getVotes(bob), 100e18); + } + + function test_PastVotesCheckpointed() public { + _lock(alice, 200e18); + vm.prank(alice); + stDIN.delegate(alice); + + uint256 snap = vm.getBlockNumber(); + vm.roll(snap + 1); + + // Unlock half — past votes at `snap` should still reflect 200e18 + vm.prank(alice); + stDIN.unlock(100e18); + + assertEq(stDIN.getPastVotes(alice, snap), 200e18); + assertEq(stDIN.getVotes(alice), 100e18); + } + + // ─── Invariant: totalLocked == DIN held by contract ─────────────────────── + + function test_TotalLockedMatchesDINBalance() public { + _lock(alice, 300e18); + _lock(bob, 150e18); + vm.prank(alice); stDIN.unlock(100e18); + + assertEq(stDIN.totalLocked(), din.balanceOf(address(stDIN))); + assertEq(stDIN.totalLocked(), 350e18); + } + + // ─── Helpers ────────────────────────────────────────────────────────────── + + function _lock(address account, uint256 amount) internal { + vm.startPrank(account); + din.approve(address(stDIN), amount); + stDIN.lock(amount); + vm.stopPrank(); + } +} diff --git a/foundry/test/dao/DinGovernor.t.sol b/foundry/test/dao/DinGovernor.t.sol new file mode 100644 index 0000000..6302d40 --- /dev/null +++ b/foundry/test/dao/DinGovernor.t.sol @@ -0,0 +1,204 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.28; + +import "forge-std/Test.sol"; +import "../../src/dao/DinGovernor.sol"; +import "../../src/dao/DinGovernanceStaking.sol"; +import "../../src/dao/DinTimelock.sol"; +import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +/// @dev Minimal ERC-20 used as the DIN stand-in in tests. +contract GovMockDIN is ERC20 { + constructor() ERC20("DIN Token", "DIN") {} + function mint(address to, uint256 amount) external { _mint(to, amount); } +} + +/// @dev Minimal call target whose state changes verify proposal execution. +contract GovTarget { + uint256 public value; + function setValue(uint256 v) external { value = v; } +} + +contract DinGovernorTest is Test { + GovMockDIN internal din; + DinGovernanceStaking internal stDIN; + DinTimelock internal timelock; + DinGovernor internal gov; + GovTarget internal target; + + address internal alice = makeAddr("alice"); + address internal bob = makeAddr("bob"); + address internal carol = makeAddr("carol"); + address internal deployer = makeAddr("deployer"); + + // Governor settings (block-based) + uint48 internal constant VOTING_DELAY = 1; // 1 block + uint32 internal constant VOTING_PERIOD = 50; // 50 blocks + uint256 internal constant PROPOSAL_THRESH = 1e18; // 1 stDIN + uint256 internal constant QUORUM_FRACTION = 4; // 4% + uint256 internal constant TIMELOCK_DELAY = 2 days; + + function setUp() public { + vm.startPrank(deployer); + + din = new GovMockDIN(); + stDIN = new DinGovernanceStaking(address(din)); + + // Build timelock with governor as proposer (wired below after deploy) + address[] memory noProposers = new address[](0); + address[] memory openExec = new address[](1); + openExec[0] = address(0); + + timelock = new DinTimelock(TIMELOCK_DELAY, noProposers, openExec, deployer); + + gov = new DinGovernor( + IVotes(address(stDIN)), + TimelockController(payable(address(timelock))), + VOTING_DELAY, + VOTING_PERIOD, + PROPOSAL_THRESH, + QUORUM_FRACTION + ); + + // Grant governor PROPOSER_ROLE and CANCELLER_ROLE on the timelock + timelock.grantRole(timelock.PROPOSER_ROLE(), address(gov)); + timelock.grantRole(timelock.CANCELLER_ROLE(), address(gov)); + // Renounce deployer's admin + timelock.renounceRole(timelock.DEFAULT_ADMIN_ROLE(), deployer); + + target = new GovTarget(); + + vm.stopPrank(); + + // Mint DIN and lock stDIN so voters have voting power + din.mint(alice, 1_000e18); + din.mint(bob, 1_000e18); + _lockAndDelegate(alice, 600e18, alice); + _lockAndDelegate(bob, 400e18, bob); + } + + // ─── Governor settings ──────────────────────────────────────────────────── + + function test_VotingDelay() public view { + assertEq(gov.votingDelay(), VOTING_DELAY); + } + + function test_VotingPeriod() public view { + assertEq(gov.votingPeriod(), VOTING_PERIOD); + } + + function test_ProposalThreshold() public view { + assertEq(gov.proposalThreshold(), PROPOSAL_THRESH); + } + + // ─── Full proposal lifecycle ─────────────────────────────────────────────── + + function test_ProposalLifecycle_ProposeVoteQueueExecute() public { + // Build a proposal to call target.setValue(77) through the timelock + bytes memory data = abi.encodeCall(GovTarget.setValue, (77)); + + address[] memory targets = new address[](1); + uint256[] memory values = new uint256[](1); + bytes[] memory calldatas = new bytes[](1); + targets[0] = address(target); + values[0] = 0; + calldatas[0] = data; + + string memory desc = "Set target value to 77"; + + // 1. Propose + vm.prank(alice); + uint256 proposalId = gov.propose(targets, values, calldatas, desc); + assertEq(uint8(gov.state(proposalId)), uint8(IGovernor.ProposalState.Pending)); + + // 2. Advance past voting delay + vm.roll(block.number + VOTING_DELAY + 1); + assertEq(uint8(gov.state(proposalId)), uint8(IGovernor.ProposalState.Active)); + + // 3. Vote + vm.prank(alice); gov.castVote(proposalId, 1); // For + vm.prank(bob); gov.castVote(proposalId, 1); // For + + // 4. Advance past voting period + vm.roll(block.number + VOTING_PERIOD + 1); + assertEq(uint8(gov.state(proposalId)), uint8(IGovernor.ProposalState.Succeeded)); + + // 5. Queue into timelock + gov.queue(targets, values, calldatas, keccak256(bytes(desc))); + assertEq(uint8(gov.state(proposalId)), uint8(IGovernor.ProposalState.Queued)); + + // 6. Advance past timelock delay + vm.warp(block.timestamp + TIMELOCK_DELAY + 1); + + // 7. Execute + gov.execute(targets, values, calldatas, keccak256(bytes(desc))); + assertEq(uint8(gov.state(proposalId)), uint8(IGovernor.ProposalState.Executed)); + assertEq(target.value(), 77); + } + + // ─── Quorum ─────────────────────────────────────────────────────────────── + + function test_ProposalDefeatedWhenQuorumNotMet() public { + // Deploy fresh stDIN with tiny supply so quorum fraction fails + GovMockDIN smallDin = new GovMockDIN(); + DinGovernanceStaking smallStDIN = new DinGovernanceStaking(address(smallDin)); + smallDin.mint(alice, 10e18); + + vm.startPrank(alice); + smallDin.approve(address(smallStDIN), 10e18); + smallStDIN.lock(10e18); + smallStDIN.delegate(alice); + vm.stopPrank(); + + address[] memory noProposers = new address[](0); + address[] memory openExec = new address[](1); + openExec[0] = address(0); + DinTimelock tl2 = new DinTimelock(0, noProposers, openExec, address(this)); + + DinGovernor gov2 = new DinGovernor( + IVotes(address(smallStDIN)), + TimelockController(payable(address(tl2))), + 1, + 50, + 0, + 50 // 50% quorum — impossible to reach with 10e18 / 10e18 if alice votes only 1 For + ); + + tl2.grantRole(tl2.PROPOSER_ROLE(), address(gov2)); + + bytes memory data = abi.encodeCall(GovTarget.setValue, (1)); + address[] memory targets = new address[](1); targets[0] = address(target); + uint256[] memory vals = new uint256[](1); vals[0] = 0; + bytes[] memory cds = new bytes[](1); cds[0] = data; + + vm.prank(alice); + uint256 pid = gov2.propose(targets, vals, cds, "test"); + vm.roll(block.number + 2); + + // Alice votes Against — should remain below quorum for For side + vm.prank(alice); + gov2.castVote(pid, 0); // Against + + vm.roll(block.number + 51); + assertEq(uint8(gov2.state(pid)), uint8(IGovernor.ProposalState.Defeated)); + } + + // ─── Proposal below threshold ────────────────────────────────────────────── + + function test_ProposeRevertsWhenBelowThreshold() public { + address poorAccount = makeAddr("poor"); + vm.prank(poorAccount); + vm.expectRevert(); + gov.propose(new address[](1), new uint256[](1), new bytes[](1), "no power"); + } + + // ─── Helpers ────────────────────────────────────────────────────────────── + + function _lockAndDelegate(address account, uint256 amount, address delegatee) internal { + vm.startPrank(account); + din.approve(address(stDIN), amount); + stDIN.lock(amount); + stDIN.delegate(delegatee); + vm.stopPrank(); + } +} diff --git a/foundry/test/dao/DinGuardian.t.sol b/foundry/test/dao/DinGuardian.t.sol new file mode 100644 index 0000000..fc5c564 --- /dev/null +++ b/foundry/test/dao/DinGuardian.t.sol @@ -0,0 +1,225 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.28; + +import "forge-std/Test.sol"; +import "../../src/dao/DinGuardian.sol"; +import "../../src/dao/interfaces/IDinGuardian.sol"; + +/// @dev Minimal target that records guardian calls and supports reversal. +contract GuardianTarget { + bool public modelDisabled; + bool public slasherDeauthorized; + + function disableModel() external { + modelDisabled = true; + } + + function enableModel() external { + modelDisabled = false; + } + + function deauthorizeSlasher() external { + slasherDeauthorized = true; + } + + function reauthorizeSlasher() external { + slasherDeauthorized = false; + } + + function revertAlways() external pure { + revert("always fails"); + } +} + +contract DinGuardianTest is Test { + DinGuardian internal guardian; + GuardianTarget internal target; + + address internal multisig = makeAddr("multisig"); // guardian role + address internal governor = makeAddr("governor"); // ratifier role + address internal anyone = makeAddr("anyone"); + + uint256 internal constant WINDOW = 7 days; + + function setUp() public { + guardian = new DinGuardian(multisig, governor, WINDOW); + target = new GuardianTarget(); + } + + // ─── Constructor ────────────────────────────────────────────────────────── + + function test_GuardianAddress() public view { + assertEq(guardian.guardian(), multisig); + } + + function test_GovernorAddress() public view { + assertEq(guardian.governor(), governor); + } + + function test_RatificationWindow() public view { + assertEq(guardian.ratificationWindow(), WINDOW); + } + + function test_Constructor_RejectsZeroGuardian() public { + vm.expectRevert(DG_ZeroAddress.selector); + new DinGuardian(address(0), governor, WINDOW); + } + + // ─── performAction ──────────────────────────────────────────────────────── + + function test_PerformAction_DispatchesCall() public { + bytes memory data = abi.encodeCall(GuardianTarget.disableModel, ()); + bytes memory reversal = abi.encodeCall(GuardianTarget.enableModel, ()); + + vm.prank(multisig); + uint256 id = guardian.performAction(address(target), data, reversal, "disable model 1"); + + assertEq(id, 0); + assertTrue(target.modelDisabled()); + + ( , , , , ActionState state, uint256 expiry) = guardian.getAction(id); + assertEq(uint8(state), uint8(ActionState.Active)); + assertEq(expiry, block.timestamp + WINDOW); + } + + function test_PerformAction_RevertsForNonGuardian() public { + vm.prank(anyone); + vm.expectRevert(DG_NotGuardian.selector); + guardian.performAction(address(target), "", "", ""); + } + + function test_PerformAction_RevertsOnZeroTarget() public { + vm.prank(multisig); + vm.expectRevert(DG_ZeroAddress.selector); + guardian.performAction(address(0), "", "", ""); + } + + function test_PerformAction_RevertsWhenCallFails() public { + bytes memory data = abi.encodeCall(GuardianTarget.revertAlways, ()); + vm.prank(multisig); + vm.expectRevert(DG_ActionCallFailed.selector); + guardian.performAction(address(target), data, "", "bad call"); + } + + // ─── ratifyAction ───────────────────────────────────────────────────────── + + function test_RatifyAction_Succeeds() public { + uint256 id = _performDisableModel(); + + vm.prank(governor); + guardian.ratifyAction(id); + + ( , , , , ActionState state, ) = guardian.getAction(id); + assertEq(uint8(state), uint8(ActionState.Ratified)); + } + + function test_RatifyAction_RevertsForNonGovernor() public { + uint256 id = _performDisableModel(); + vm.prank(anyone); + vm.expectRevert(DG_NotGovernor.selector); + guardian.ratifyAction(id); + } + + function test_RatifyAction_RevertsIfNotActive() public { + uint256 id = _performDisableModel(); + vm.prank(governor); guardian.ratifyAction(id); // Ratified + vm.prank(governor); + vm.expectRevert(DG_ActionNotActive.selector); + guardian.ratifyAction(id); + } + + // ─── expireAction ───────────────────────────────────────────────────────── + + function test_ExpireAction_ReversesAfterWindow() public { + uint256 id = _performDisableModel(); + assertTrue(target.modelDisabled()); // action took effect + + vm.warp(block.timestamp + WINDOW + 1); + vm.prank(anyone); + guardian.expireAction(id); + + assertFalse(target.modelDisabled()); // reversal applied + + ( , , , , ActionState state, ) = guardian.getAction(id); + assertEq(uint8(state), uint8(ActionState.Expired)); + } + + function test_ExpireAction_RevertsBeforeWindow() public { + uint256 id = _performDisableModel(); + vm.warp(block.timestamp + WINDOW - 1); + vm.expectRevert(DG_WindowNotElapsed.selector); + guardian.expireAction(id); + } + + function test_ExpireAction_RevertsIfRatified() public { + uint256 id = _performDisableModel(); + vm.prank(governor); guardian.ratifyAction(id); + vm.warp(block.timestamp + WINDOW + 1); + vm.expectRevert(DG_ActionNotExpirable.selector); + guardian.expireAction(id); + } + + function test_ExpireAction_NoReversalDataIsNoop() public { + bytes memory data = abi.encodeCall(GuardianTarget.disableModel, ()); + vm.prank(multisig); + uint256 id = guardian.performAction(address(target), data, "", "no reversal"); + + vm.warp(block.timestamp + WINDOW + 1); + guardian.expireAction(id); // should not revert even with empty reversal + + ( , , , , ActionState state, ) = guardian.getAction(id); + assertEq(uint8(state), uint8(ActionState.Expired)); + } + + // ─── setGovernor ────────────────────────────────────────────────────────── + + function test_SetGovernor_UpdatesAddress() public { + address newGov = makeAddr("newGov"); + vm.prank(multisig); + guardian.setGovernor(newGov); + assertEq(guardian.governor(), newGov); + } + + function test_SetGovernor_RevertsForNonGuardian() public { + vm.prank(anyone); + vm.expectRevert(DG_NotGuardian.selector); + guardian.setGovernor(makeAddr("x")); + } + + function test_SetGovernor_RevertsOnZeroAddress() public { + vm.prank(multisig); + vm.expectRevert(DG_ZeroAddress.selector); + guardian.setGovernor(address(0)); + } + + // ─── Action count ───────────────────────────────────────────────────────── + + function test_ActionCountIncrements() public { + _performDisableModel(); + _performDeauthorizeSlasher(); + assertEq(guardian.actionCount(), 2); + } + + // ─── Invalid action ID ──────────────────────────────────────────────────── + + function test_GetAction_RevertsOnInvalidId() public { + vm.expectRevert(DG_InvalidActionId.selector); + guardian.getAction(999); + } + + // ─── Helpers ────────────────────────────────────────────────────────────── + + function _performDisableModel() internal returns (uint256 id) { + bytes memory data = abi.encodeCall(GuardianTarget.disableModel, ()); + bytes memory reversal = abi.encodeCall(GuardianTarget.enableModel, ()); + vm.prank(multisig); + id = guardian.performAction(address(target), data, reversal, "disable model"); + } + + function _performDeauthorizeSlasher() internal returns (uint256 id) { + bytes memory data = abi.encodeCall(GuardianTarget.deauthorizeSlasher, ()); + bytes memory reversal = abi.encodeCall(GuardianTarget.reauthorizeSlasher, ()); + vm.prank(multisig); + id = guardian.performAction(address(target), data, reversal, "deauthorize slasher"); + } +} diff --git a/foundry/test/dao/DinMultisig.t.sol b/foundry/test/dao/DinMultisig.t.sol new file mode 100644 index 0000000..d148422 --- /dev/null +++ b/foundry/test/dao/DinMultisig.t.sol @@ -0,0 +1,255 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.28; + +import "forge-std/Test.sol"; +import "../../src/dao/DinMultisig.sol"; +import "../../src/dao/interfaces/IDinMultisig.sol"; + +/// @dev Minimal call target used to verify proposal dispatch. +contract CallTarget { + uint256 public value; + + function setValue(uint256 v) external { + value = v; + } + + function revertAlways() external pure { + revert("always"); + } +} + +contract DinMultisigTest is Test { + DinMultisig internal ms; + CallTarget internal target; + + address internal alice = makeAddr("alice"); + address internal bob = makeAddr("bob"); + address internal carol = makeAddr("carol"); + address internal eve = makeAddr("eve"); // non-signer + + // Thresholds: Parameter=2, Operational=2, Treasury=3, Upgrade=3 + uint256[4] internal thresholds = [uint256(2), 2, 3, 3]; + + function setUp() public { + address[] memory s = new address[](3); + s[0] = alice; + s[1] = bob; + s[2] = carol; + ms = new DinMultisig(s, thresholds); + target = new CallTarget(); + } + + // ─── Constructor ────────────────────────────────────────────────────────── + + function test_Constructor_SetsSigners() public view { + assertTrue(ms.isSigner(alice)); + assertTrue(ms.isSigner(bob)); + assertTrue(ms.isSigner(carol)); + assertFalse(ms.isSigner(eve)); + } + + function test_Constructor_SetsThresholds() public view { + assertEq(ms.threshold(ProposalCategory.Parameter), 2); + assertEq(ms.threshold(ProposalCategory.Operational), 2); + assertEq(ms.threshold(ProposalCategory.Treasury), 3); + assertEq(ms.threshold(ProposalCategory.Upgrade), 3); + } + + function test_Constructor_RejectsEmptySigners() public { + address[] memory empty = new address[](0); + vm.expectRevert(MS_EmptySignerList.selector); + new DinMultisig(empty, thresholds); + } + + function test_Constructor_RejectsZeroAddress() public { + address[] memory s = new address[](1); + s[0] = address(0); + vm.expectRevert(MS_ZeroAddress.selector); + new DinMultisig(s, thresholds); + } + + function test_Constructor_RejectsDuplicateSigner() public { + address[] memory s = new address[](2); + s[0] = alice; + s[1] = alice; + vm.expectRevert(MS_DuplicateSigner.selector); + new DinMultisig(s, thresholds); + } + + function test_Constructor_RejectsZeroThreshold() public { + uint256[4] memory bad = [uint256(0), 1, 1, 1]; + address[] memory s = new address[](2); + s[0] = alice; s[1] = bob; + vm.expectRevert(MS_InvalidThreshold.selector); + new DinMultisig(s, bad); + } + + function test_Constructor_RejectsThresholdAboveSignerCount() public { + uint256[4] memory bad = [uint256(4), 1, 1, 1]; + address[] memory s = new address[](3); + s[0] = alice; s[1] = bob; s[2] = carol; + vm.expectRevert(MS_InvalidThreshold.selector); + new DinMultisig(s, bad); + } + + // ─── propose ────────────────────────────────────────────────────────────── + + function test_Propose_SucceedsForSigner() public { + bytes memory data = abi.encodeCall(CallTarget.setValue, (42)); + vm.prank(alice); + uint256 id = ms.propose(address(target), data, 0, ProposalCategory.Parameter); + assertEq(id, 0); + assertEq(ms.proposalCount(), 1); + + (, , , ProposalCategory cat, ProposalState state, , ) = ms.getProposal(id); + assertEq(uint8(cat), uint8(ProposalCategory.Parameter)); + assertEq(uint8(state), uint8(ProposalState.Open)); + } + + function test_Propose_RevertsForNonSigner() public { + vm.prank(eve); + vm.expectRevert(MS_NotSigner.selector); + ms.propose(address(target), "", 0, ProposalCategory.Parameter); + } + + function test_Propose_RevertsOnZeroTarget() public { + vm.prank(alice); + vm.expectRevert(MS_ZeroAddress.selector); + ms.propose(address(0), "", 0, ProposalCategory.Parameter); + } + + // ─── confirm / threshold transition ─────────────────────────────────────── + + function test_Confirm_IncreasesCount() public { + uint256 id = _createProposal(ProposalCategory.Parameter); + + vm.prank(alice); + ms.confirm(id); + (, , , , , uint256 count, ) = ms.getProposal(id); + assertEq(count, 1); + } + + function test_Confirm_TransitionsToExecutable() public { + uint256 id = _createProposal(ProposalCategory.Parameter); // threshold=2 + vm.prank(alice); ms.confirm(id); + vm.prank(bob); ms.confirm(id); + (, , , , ProposalState state, , ) = ms.getProposal(id); + assertEq(uint8(state), uint8(ProposalState.Executable)); + } + + function test_Confirm_RevertsOnNonOpen() public { + uint256 id = _createProposal(ProposalCategory.Parameter); + vm.prank(alice); ms.confirm(id); + vm.prank(bob); ms.confirm(id); // now Executable + vm.prank(carol); + vm.expectRevert(MS_ProposalNotOpen.selector); + ms.confirm(id); + } + + function test_Confirm_RevertsOnDuplicate() public { + uint256 id = _createProposal(ProposalCategory.Parameter); + vm.prank(alice); ms.confirm(id); + vm.prank(alice); + vm.expectRevert(MS_AlreadyConfirmed.selector); + ms.confirm(id); + } + + // ─── revoke ─────────────────────────────────────────────────────────────── + + function test_Revoke_DecreasesCount() public { + uint256 id = _createProposal(ProposalCategory.Parameter); + vm.prank(alice); ms.confirm(id); + vm.prank(alice); ms.revoke(id); + (, , , , , uint256 count, ) = ms.getProposal(id); + assertEq(count, 0); + } + + function test_Revoke_FromExecutableBackToOpen() public { + uint256 id = _createProposal(ProposalCategory.Parameter); + vm.prank(alice); ms.confirm(id); + vm.prank(bob); ms.confirm(id); // Executable + vm.prank(alice); ms.revoke(id); // drops below threshold → Open + (, , , , ProposalState state, , ) = ms.getProposal(id); + assertEq(uint8(state), uint8(ProposalState.Open)); + } + + function test_Revoke_RevertsIfNotConfirmed() public { + uint256 id = _createProposal(ProposalCategory.Parameter); + vm.prank(alice); + vm.expectRevert(MS_NotConfirmed.selector); + ms.revoke(id); + } + + // ─── execute ────────────────────────────────────────────────────────────── + + function test_Execute_DispatchesCall() public { + bytes memory data = abi.encodeCall(CallTarget.setValue, (99)); + vm.prank(alice); + uint256 id = ms.propose(address(target), data, 0, ProposalCategory.Parameter); + vm.prank(alice); ms.confirm(id); + vm.prank(bob); ms.confirm(id); + vm.prank(carol); ms.execute(id); + assertEq(target.value(), 99); + + (, , , , ProposalState state, , ) = ms.getProposal(id); + assertEq(uint8(state), uint8(ProposalState.Executed)); + } + + function test_Execute_RevertsIfNotExecutable() public { + uint256 id = _createProposal(ProposalCategory.Parameter); + vm.prank(alice); + vm.expectRevert(MS_ProposalNotExecutable.selector); + ms.execute(id); + } + + function test_Execute_RevertsOnFailedCall() public { + bytes memory data = abi.encodeCall(CallTarget.revertAlways, ()); + vm.prank(alice); + uint256 id = ms.propose(address(target), data, 0, ProposalCategory.Parameter); + vm.prank(alice); ms.confirm(id); + vm.prank(bob); ms.confirm(id); + vm.prank(carol); + vm.expectRevert(MS_ExecutionFailed.selector); + ms.execute(id); + } + + // ─── cancel ─────────────────────────────────────────────────────────────── + + function test_Cancel_TransitionsToCancelled() public { + uint256 id = _createProposal(ProposalCategory.Parameter); + vm.prank(alice); ms.cancel(id); + (, , , , ProposalState state, , ) = ms.getProposal(id); + assertEq(uint8(state), uint8(ProposalState.Cancelled)); + } + + function test_Cancel_RevertsAfterExecution() public { + uint256 id = _createProposal(ProposalCategory.Parameter); + vm.prank(alice); ms.confirm(id); + vm.prank(bob); ms.confirm(id); + vm.prank(alice); ms.execute(id); + vm.prank(carol); + vm.expectRevert(MS_ProposalNotCancellable.selector); + ms.cancel(id); + } + + // ─── Category threshold enforcement ─────────────────────────────────────── + + function test_TreasuryRequiresThreeConfirms() public { + uint256 id = _createProposal(ProposalCategory.Treasury); // threshold=3 + vm.prank(alice); ms.confirm(id); + vm.prank(bob); ms.confirm(id); + (, , , , ProposalState state, , ) = ms.getProposal(id); + assertEq(uint8(state), uint8(ProposalState.Open)); // still open after 2 + vm.prank(carol); ms.confirm(id); + (, , , , state, , ) = ms.getProposal(id); + assertEq(uint8(state), uint8(ProposalState.Executable)); + } + + // ─── Helpers ────────────────────────────────────────────────────────────── + + function _createProposal(ProposalCategory cat) internal returns (uint256 id) { + bytes memory data = abi.encodeCall(CallTarget.setValue, (1)); + vm.prank(alice); + id = ms.propose(address(target), data, 0, cat); + } +} diff --git a/foundry/test/dao/DinTimelock.t.sol b/foundry/test/dao/DinTimelock.t.sol new file mode 100644 index 0000000..fcd8e9d --- /dev/null +++ b/foundry/test/dao/DinTimelock.t.sol @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.28; + +import "forge-std/Test.sol"; +import "../../src/dao/DinTimelock.sol"; + +/// @dev Minimal target whose state changes verify that scheduled calls execute. +contract TimelockTarget { + uint256 public value; + + function setValue(uint256 v) external { + value = v; + } +} + +contract DinTimelockTest is Test { + DinTimelock internal tlShort; // 24 h + DinTimelock internal tlLong; // 48 h + TimelockTarget internal target; + + address internal proposer = makeAddr("proposer"); + address internal executor = makeAddr("executor"); + + uint256 internal constant SHORT_DELAY = 1 days; + uint256 internal constant LONG_DELAY = 2 days; + + bytes32 internal constant PROPOSER_ROLE = keccak256("PROPOSER_ROLE"); + bytes32 internal constant EXECUTOR_ROLE = keccak256("EXECUTOR_ROLE"); + bytes32 internal constant CANCELLER_ROLE = keccak256("CANCELLER_ROLE"); + bytes32 internal constant ADMIN_ROLE = 0x00; + + function setUp() public { + address[] memory proposers_ = new address[](1); + proposers_[0] = proposer; + + address[] memory executors_ = new address[](1); + executors_[0] = address(0); // open execution + + tlShort = new DinTimelock(SHORT_DELAY, proposers_, executors_, address(0)); + tlLong = new DinTimelock(LONG_DELAY, proposers_, executors_, address(0)); + + target = new TimelockTarget(); + } + + // ─── Role assignments ───────────────────────────────────────────────────── + + function test_ShortDelay_ProposerHasRole() public view { + assertTrue(tlShort.hasRole(PROPOSER_ROLE, proposer)); + } + + function test_LongDelay_ProposerHasRole() public view { + assertTrue(tlLong.hasRole(PROPOSER_ROLE, proposer)); + } + + function test_OpenExecutorGranted() public view { + // address(0) as executor grants EXECUTOR_ROLE to everyone + assertTrue(tlShort.hasRole(EXECUTOR_ROLE, address(0))); + } + + function test_AdminRoleRenounced() public view { + // Passing address(0) as admin_ causes TimelockController to skip + // granting DEFAULT_ADMIN_ROLE to any external address. + assertFalse(tlShort.hasRole(ADMIN_ROLE, address(this))); + } + + function test_MinDelayShort() public view { + assertEq(tlShort.getMinDelay(), SHORT_DELAY); + } + + function test_MinDelayLong() public view { + assertEq(tlLong.getMinDelay(), LONG_DELAY); + } + + // ─── Propose → wait → execute (short timelock) ─────────────────────────── + + function test_ShortTimelock_ScheduleAndExecute() public { + bytes memory data = abi.encodeCall(TimelockTarget.setValue, (7)); + bytes32 salt = bytes32(0); + bytes32 id = tlShort.hashOperation(address(target), 0, data, bytes32(0), salt); + + vm.prank(proposer); + tlShort.schedule(address(target), 0, data, bytes32(0), salt, SHORT_DELAY); + + assertTrue(tlShort.isOperationPending(id)); + + vm.warp(block.timestamp + SHORT_DELAY); + + // Anyone can execute once the delay has elapsed (open EXECUTOR_ROLE) + tlShort.execute(address(target), 0, data, bytes32(0), salt); + + assertTrue(tlShort.isOperationDone(id)); + assertEq(target.value(), 7); + } + + function test_ShortTimelock_CannotExecuteBeforeDelay() public { + bytes memory data = abi.encodeCall(TimelockTarget.setValue, (1)); + bytes32 salt = bytes32(0); + + vm.prank(proposer); + tlShort.schedule(address(target), 0, data, bytes32(0), salt, SHORT_DELAY); + + vm.warp(block.timestamp + SHORT_DELAY - 1); + vm.expectRevert(); + tlShort.execute(address(target), 0, data, bytes32(0), salt); + } + + // ─── Cancel (canceller role) ────────────────────────────────────────────── + + function test_ProposerCanCancel() public { + bytes memory data = abi.encodeCall(TimelockTarget.setValue, (1)); + bytes32 salt = bytes32(0); + bytes32 id = tlShort.hashOperation(address(target), 0, data, bytes32(0), salt); + + vm.prank(proposer); + tlShort.schedule(address(target), 0, data, bytes32(0), salt, SHORT_DELAY); + + // Proposer also holds CANCELLER_ROLE by OZ default + vm.prank(proposer); + tlShort.cancel(id); + + assertFalse(tlShort.isOperationPending(id)); + } + + // ─── Long timelock delay enforcement ───────────────────────────────────── + + function test_LongTimelock_DelayEnforced() public { + bytes memory data = abi.encodeCall(TimelockTarget.setValue, (99)); + bytes32 salt = bytes32(0); + + vm.prank(proposer); + tlLong.schedule(address(target), 0, data, bytes32(0), salt, LONG_DELAY); + + // Warp only to short delay — should still be too early for the long lock + vm.warp(block.timestamp + SHORT_DELAY); + vm.expectRevert(); + tlLong.execute(address(target), 0, data, bytes32(0), salt); + } + + function test_LongTimelock_ExecutesAfterFullDelay() public { + bytes memory data = abi.encodeCall(TimelockTarget.setValue, (42)); + bytes32 salt = bytes32(0); + + vm.prank(proposer); + tlLong.schedule(address(target), 0, data, bytes32(0), salt, LONG_DELAY); + + vm.warp(block.timestamp + LONG_DELAY); + tlLong.execute(address(target), 0, data, bytes32(0), salt); + assertEq(target.value(), 42); + } +} diff --git a/hardhat/deployments/hardhat.json b/hardhat/deployments/hardhat.json new file mode 100644 index 0000000..197d093 --- /dev/null +++ b/hardhat/deployments/hardhat.json @@ -0,0 +1,7 @@ +{ + "dinToken": "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512", + "dinCoordinator": "0xCf7Ed3AccA5a467e9e704C703E8D87F634fB0Fc9", + "dinValidatorStake": "0x0165878A594ca255338adfa4d48449f69242Eb8F", + "dinModelRegistry": "0x8A791620dd6260079BF849Dc5567aDC3F2FdC318", + "proxyAdmin": "0xCafac3dD18aC6c6e92c921884f9E4176737C052c" +}