From 36a5677a2dad39f8c06cdc4ef023076a14150539 Mon Sep 17 00:00:00 2001 From: umermjd11 Date: Wed, 30 Sep 2026 18:01:32 +0500 Subject: [PATCH 1/5] fix(cli): remove broken non-proxy `dinrep deploy`, fix add-slasher crash (#203) The three `dinrep deploy` commands called constructors on contracts that are proxy-only: din-validator-stake and din-model-registry passed arguments to constructors that take none, and din-coordinator deployed an implementation that can never be initialised. Platform deployment goes through foundry/script/DeployPlatform.s.sol and `dincli system import-deployments`. `dinrep add-slasher` with none of --contract / --taskCoordinator / --taskAuditor raised UnboundLocalError. It now exits 1 with a usage message, and loads DinCoordinator only after the target is resolved. Also fixes the add-slasher and registry help texts. Co-Authored-By: Claude Opus 5.5 --- dincli/cli/dinrep.py | 160 +++---------------------------- tests/test_dinrep_add_slasher.py | 81 ++++++++++++++++ 2 files changed, 95 insertions(+), 146 deletions(-) create mode 100644 tests/test_dinrep_add_slasher.py diff --git a/dincli/cli/dinrep.py b/dincli/cli/dinrep.py index 291d3156..a1b458f5 100644 --- a/dincli/cli/dinrep.py +++ b/dincli/cli/dinrep.py @@ -1,20 +1,17 @@ import os -import time from importlib.resources import files import typer from dincli.cli.contract_utils import get_contract_instance -from dincli.cli.utils import (build_and_send_tx, get_env_key, load_din_info, - resolve_task_coordinator_address, save_din_info) +from dincli.cli.utils import (build_and_send_tx, get_env_key, + resolve_task_coordinator_address) app = typer.Typer(help="Commands for the DIN-Representative") -registry_app = typer.Typer(help="Registry sub-app (for 'dincli dinrep registry to interact with DINRegistry ...')") -deploy_app = typer.Typer(help="Deploy DIN smart contracts") +registry_app = typer.Typer(help="Registry sub-app ('dincli dinrep registry ...') to interact with DINModelRegistry") coordinator_app = typer.Typer(help="Coordinator sub-app ('dincli dinrep coordinator ...') to interact with DinCoordinator") -app.add_typer(deploy_app, name="deploy") app.add_typer(registry_app, name="registry") app.add_typer(coordinator_app, name="coordinator") @@ -54,145 +51,10 @@ def _print_manifest_request(console, w3, request_id: int, req): console.print(f" Fee Paid: {w3.from_wei(req[3], 'ether')} ETH") console.print(f" Status: {_request_status(req[4], req[5])}") -@deploy_app.command() -def din_coordinator( - ctx: typer.Context, - artifact_path: str = typer.Option(None, "--artifact", help="Path to contract artifact JSON (Hardhat format)") -): - - """ - Deploy the DIN Coordinator contract. - """ - effective_network, w3, account, console = ctx.obj.get_en_w3_account_console() - - DINCoordinator_contract = get_contract_instance(artifact_path, effective_network) - - tx_receipt = build_and_send_tx( - ctx, - DINCoordinator_contract.constructor(), - "Deploying DIN Coordinator Contract", - "DINCoordinator contract deployed successfully", - "Failed to deploy DIN Coordinator Contract" - ) - - dincoordinator_contract_address = tx_receipt.contractAddress - - console.print("DINCoordinator contract deployed at:", dincoordinator_contract_address) - - din_addresses = load_din_info() - din_addresses[effective_network]["coordinator"] = dincoordinator_contract_address - din_addresses[effective_network]["representative"] = account.address - save_din_info(din_addresses) - - taskCoordinator_contract = ctx.obj.get_deployed_din_coordinator_contract(verbose=False) - - dintoken_address = taskCoordinator_contract.functions.dinToken().call() - console.print("DINtoken contract deployed at:", dintoken_address) - din_addresses = load_din_info() - din_addresses[effective_network]["token"] = dintoken_address - save_din_info(din_addresses) - - - -@deploy_app.command("din-validator-stake") -def din_validator_stake( - ctx: typer.Context, - artifact_path: str = typer.Option(..., "--artifact", help="Path to contract artifact JSON (Hardhat/Brownie format)"), - dinCoordinator: str = typer.Option(None, "--dinCoordinator", help="the dinCoordinator asddress"), - dinToken: str = typer.Option(None, "--dinToken", help="the dinToken asddress"), - -): - - """ - Deploy the DIN Validator Stake contract. - """ - effective_network, w3, account, console = ctx.obj.get_en_w3_account_console() - - DINValidatorStake_contract = get_contract_instance(artifact_path, effective_network) - - din_addresses = load_din_info() - - if dinCoordinator: - dinCoordinator_address = dinCoordinator - else: - dinCoordinator_address = din_addresses[effective_network]["coordinator"] - - if dinToken: - dinToken_address = dinToken - else: - dinToken_address = din_addresses[effective_network]["token"] - - tx_receipt = build_and_send_tx( - ctx, - DINValidatorStake_contract.constructor(dinToken_address, dinCoordinator_address), - "Deploying DIN Validator Stake Contract", - "DINValidatorStake contract deployed successfully", - "Failed to deploy DIN Validator Stake Contract" - ) - - DINValidatorStake_contract_address = tx_receipt.contractAddress - - console.print("DINValidatorStake contract deployed at:", DINValidatorStake_contract_address) - - din_addresses[effective_network]["stake"] = DINValidatorStake_contract_address - - save_din_info(din_addresses) - - deployed_DINValidatorStake_Contract = ctx.obj.get_deployed_din_stake_contract() - - - DINCoordinator_Contract = ctx.obj.get_deployed_din_coordinator_contract() - - # add delay to allow the - time.sleep(10) - - - build_and_send_tx( - ctx, - DINCoordinator_Contract.functions.updateValidatorStakeContract(deployed_DINValidatorStake_Contract.address), - "Adding DinValidatorStake contract to DINCoordinator contract", - "DinValidatorStake contract added to DINCoordinator contract successfully", - "Failed to add DinValidatorStake contract to DINCoordinator contract" - ) - - -@deploy_app.command("din-model-registry") -def deploy_din_model_registry( - ctx: typer.Context, - artifact_path: str = typer.Option(..., "--artifact", help="Path to contract artifact JSON (Hardhat/Brownie format)"), - dinvalidatorstake: str = typer.Option(None, "--dinvalidatorstake", help="the dinvalidatorstake address"), -): - - effective_network, w3, account, console = ctx.obj.get_en_w3_account_console() - - DINModelRegistry_contract = get_contract_instance(artifact_path, effective_network) - - din_addresses = load_din_info() - - if dinvalidatorstake: - dinValidatorStake_address = dinvalidatorstake - else: - dinValidatorStake_address = din_addresses[effective_network]["stake"] - - tx_receipt = build_and_send_tx( - ctx, - DINModelRegistry_contract.constructor(dinValidatorStake_address), - "Deploying DIN Model Registry", - "DINModelRegistry contract deployed successfully", - "Failed to deploy DINModelRegistry contract" - ) - - DINModelRegistry_contract_address = tx_receipt.contractAddress - console.print("[bold green] ✅ DINModelRegistry contract deployed at:[/bold green]", DINModelRegistry_contract_address) - - din_addresses[effective_network]["registry"] = DINModelRegistry_contract_address - - save_din_info(din_addresses) - @app.command("add-slasher", - help="Add a slasher to the DIN SlasherRegistry contract." - "You must specify either the task coordinator or the task auditor (from config) to be registered as the slasher." - "The contract address can be provided explicitly or loaded from config." + help="Authorize a slasher contract via DinCoordinator.addSlasherContract. " + "Pass --contract with an explicit address, or --taskCoordinator / --taskAuditor " + "to load the task contract address from config." ) def add_slasher( ctx: typer.Context, @@ -211,8 +73,6 @@ def add_slasher( ): effective_network, w3, account, console = ctx.obj.get_en_w3_account_console() - - DINCoordinator_Contract = ctx.obj.get_deployed_din_coordinator_contract() if contract: contract_address = contract @@ -237,6 +97,14 @@ def add_slasher( f"[bold green] ✓ Using DINTaskAuditor Address: {contract_address} " f"(from {os.getcwd()}/.env)[/bold green]" ) + else: + console.print( + "[bold red]✗ No slasher contract given.[/bold red] " + "Pass --contract
, --taskCoordinator, or --taskAuditor." + ) + raise typer.Exit(1) + + DINCoordinator_Contract = ctx.obj.get_deployed_din_coordinator_contract() build_and_send_tx( ctx, diff --git a/tests/test_dinrep_add_slasher.py b/tests/test_dinrep_add_slasher.py new file mode 100644 index 00000000..a3deb36a --- /dev/null +++ b/tests/test_dinrep_add_slasher.py @@ -0,0 +1,81 @@ +from types import SimpleNamespace + +import pytest +import typer +from typer.testing import CliRunner + +from dincli.cli import dinrep +from dincli.main import app as main_app + +SLASHER = "0x00000000000000000000000000000000000000Aa" + + +class DummyConsole: + def __init__(self): + self.messages = [] + + def print(self, *args, **kwargs): + self.messages.append(" ".join(str(a) for a in args)) + + def text(self): + return "\n".join(self.messages) + + +class DummyCoordinatorFunctions: + def addSlasherContract(self, address): + return ("addSlasherContract", address) + + +class DummyContextObj: + def __init__(self): + self.console = DummyConsole() + self.coordinator_loads = 0 + + def get_en_w3_account_console(self): + return "local", None, SimpleNamespace(address=SLASHER), self.console + + def get_deployed_din_coordinator_contract(self): + self.coordinator_loads += 1 + return SimpleNamespace(functions=DummyCoordinatorFunctions()) + + +@pytest.fixture +def sent(monkeypatch): + calls = [] + + def fake_build_and_send_tx(*args, **kwargs): + calls.append(args) + + monkeypatch.setattr(dinrep, "build_and_send_tx", fake_build_and_send_tx) + return calls + + +def test_add_slasher_without_target_exits_before_loading_coordinator(sent): + ctx = SimpleNamespace(obj=DummyContextObj()) + + with pytest.raises(typer.Exit) as exc: + dinrep.add_slasher(ctx, contract=None, task_coordinator_flag=False, task_auditor_flag=False) + + assert exc.value.exit_code == 1 + assert sent == [] + assert ctx.obj.coordinator_loads == 0 + assert "No slasher contract given" in ctx.obj.console.text() + + +def test_add_slasher_with_explicit_contract_sends_add_slasher_contract(sent): + ctx = SimpleNamespace(obj=DummyContextObj()) + + dinrep.add_slasher(ctx, contract=SLASHER, task_coordinator_flag=False, task_auditor_flag=False) + + assert len(sent) == 1 + assert sent[0][1] == ("addSlasherContract", SLASHER) + assert ctx.obj.coordinator_loads == 1 + + +def test_dinrep_has_no_deploy_sub_app(): + # The constructor-based deploy commands could not deploy the proxied platform + # contracts; deployment goes through foundry/script/DeployPlatform.s.sol. + result = CliRunner().invoke(main_app, ["dinrep", "deploy", "--help"]) + + assert result.exit_code != 0 + assert "No such command" in result.output From 4e8179196a33ffd86b30a0256993ce9aa2605ff9 Mon Sep 17 00:00:00 2001 From: umermjd11 Date: Wed, 30 Sep 2026 18:01:32 +0500 Subject: [PATCH 2/5] chore(hardhat): drop unused daoAdmin/setDAOAdmin shims from DINModelRegistry (#203) The shims existed for dincli's `set-dao-admin`, which PR No. 183 removed. Nothing in dincli, the tests or the hardhat scripts calls them, and the foundry contract has no such functions. The hardhat docs now describe the contract without them and mark hardhat as the secondary toolchain. Co-Authored-By: Claude Opus 5.5 --- .../technical/contracts/hardhat/README.md | 2 ++ .../tests/DINModelRegistry.upgrade.test.md | 2 +- .../upgradable-contracts/hardhat/README.md | 11 +++-------- .../test/DINModelRegistry.upgrade.test.md | 2 +- hardhat/contracts/DINModelRegistry.sol | 18 ------------------ 5 files changed, 7 insertions(+), 28 deletions(-) diff --git a/Documentation/technical/contracts/hardhat/README.md b/Documentation/technical/contracts/hardhat/README.md index 3db232d5..98778d3c 100644 --- a/Documentation/technical/contracts/hardhat/README.md +++ b/Documentation/technical/contracts/hardhat/README.md @@ -1,5 +1,7 @@ # Hardhat Tooling — Technical Documentation +> **Secondary toolchain.** `hardhat/contracts/` lags the reference contracts in `foundry/src/` (no treasury, fee router or emission; older registry/coordinator/stake/task logic). For current behavior see the [Foundry docs](../foundry/README.md). + Per-file documentation for the Hardhat workspace's supporting code: test suites, test helpers, deployment/upgrade scripts, deploy utilities, and test-only Solidity (mocks and V2 upgrade fixtures). The platform contracts themselves are documented one level up in [`Documentation/technical/contracts/`](../). | Doc | Source file | What it is | diff --git a/Documentation/technical/contracts/hardhat/tests/DINModelRegistry.upgrade.test.md b/Documentation/technical/contracts/hardhat/tests/DINModelRegistry.upgrade.test.md index dd50e49d..61801cbe 100644 --- a/Documentation/technical/contracts/hardhat/tests/DINModelRegistry.upgrade.test.md +++ b/Documentation/technical/contracts/hardhat/tests/DINModelRegistry.upgrade.test.md @@ -72,5 +72,5 @@ so it can never have an owner or wired stake contract — the contract is only ## 5. Coverage Gaps - Manifest update requests, the kill switch (`disableModel`), and `withdrawFees` are not exercised across an upgrade. -- The `daoAdmin()` / `setDAOAdmin()` compatibility shims are untested here (and have no functional suite either). +- The former `daoAdmin()` / `setDAOAdmin()` compatibility shims have been removed from `hardhat/contracts/DINModelRegistry.sol` (the unused `DAOAdminUpdated` event declaration remains). - No functional (non-upgrade) suite exists for the registry at all — request/approve/reject edge cases are only covered incidentally by this file. diff --git a/Documentation/technical/upgradable-contracts/hardhat/README.md b/Documentation/technical/upgradable-contracts/hardhat/README.md index ac40bc05..c80f8f8c 100644 --- a/Documentation/technical/upgradable-contracts/hardhat/README.md +++ b/Documentation/technical/upgradable-contracts/hardhat/README.md @@ -4,6 +4,8 @@ **Toolchain:** Hardhat + `@openzeppelin/hardhat-upgrades`, solc 0.8.28, EVM `cancun` **Proxy pattern:** OpenZeppelin **Transparent Proxy** (`PROXY_KIND = "transparent"`) +> **Secondary toolchain.** `hardhat/contracts/` lags the reference contracts in `foundry/src/` (no treasury, fee router or emission; older registry/coordinator/stake logic). For the current seven-contract platform see [`../foundry/README.md`](../foundry/README.md). + This document explains how and why the four DIN platform contracts were converted from plain constructor-initialized contracts to an upgradeable proxy architecture, contract by contract. The companion test documentation lives in [`test/`](./test/README.md). --- @@ -106,14 +108,7 @@ Model registration requests, manifest updates, fee tiers, and per-model disable - **Registration is request/approve:** `requestModelRegistration` (fee-paying) validates that both task contracts are currently authorized slashers and owned by the requester; `approveModel` (owner-only) **re-validates all four conditions at approval time** — slasher status or task-contract ownership changing between request and approval causes a typed revert (`CoordinatorNoLongerSlasher`, `AuditorOwnershipChanged`, …). This closes the TOCTOU gap between submission and review. - **Manifest updates** follow the same request/approve pattern with their own fee tier, gated by `onlyModelOwner` + `notDisabled`. - **Fees:** individual setters plus an atomic `setFees(...)`; `withdrawFees` uses the `call{value:}` + `TransferFailed` pattern. -- **Access-model migration + compatibility shims.** The pre-upgrade contract used a bespoke `daoAdmin` field. The upgradeable version standardizes on `OwnableUpgradeable`, but preserves the old ABI surface with two read-through shims: - ```solidity - function daoAdmin() external view returns (address) { return owner(); } - function setDAOAdmin(address newAdmin) external onlyOwner { - transferOwnership(newAdmin); // + emits DAOAdminUpdated - } - ``` - `dincli` and off-chain indexers keep working unchanged; the `DAOAdminUpdated` event is still emitted for listeners. +- **Access model.** The pre-upgrade contract used a bespoke `daoAdmin` field; the upgradeable version standardizes on `OwnableUpgradeable` (`transferOwnership`). The `daoAdmin()` / `setDAOAdmin()` compatibility shims it originally kept have since been removed (the unused `DAOAdminUpdated` event declaration remains). ## 5. Deployment order and wiring diff --git a/Documentation/technical/upgradable-contracts/hardhat/test/DINModelRegistry.upgrade.test.md b/Documentation/technical/upgradable-contracts/hardhat/test/DINModelRegistry.upgrade.test.md index 12334f10..53178917 100644 --- a/Documentation/technical/upgradable-contracts/hardhat/test/DINModelRegistry.upgrade.test.md +++ b/Documentation/technical/upgradable-contracts/hardhat/test/DINModelRegistry.upgrade.test.md @@ -42,7 +42,7 @@ Raw implementation deploy; `initialize(signer.address)` must revert with `Invali ## Not covered here (by design) -The `daoAdmin()` / `setDAOAdmin()` backward-compat shims and the manifest-update request flow have no dedicated upgrade tests — they are thin wrappers over `OwnableUpgradeable` and the same request-array pattern proven in Test 3. Their functional correctness is exercised at the CLI integration layer (`tests/dincli/`). +The manifest-update request flow has no dedicated upgrade test — it uses the same request-array pattern proven in Test 3, and is exercised at the CLI integration layer (`tests/dincli/test_03_registration.py`). (The former `daoAdmin()` / `setDAOAdmin()` compatibility shims have been removed from the contract.) ## What V2 is diff --git a/hardhat/contracts/DINModelRegistry.sol b/hardhat/contracts/DINModelRegistry.sol index 31763337..5a0609e9 100644 --- a/hardhat/contracts/DINModelRegistry.sol +++ b/hardhat/contracts/DINModelRegistry.sol @@ -476,22 +476,4 @@ contract DINModelRegistry is Initializable, OwnableUpgradeable { emit FeesWithdrawn(to, balance); } - // Backward-compat shims — dincli calls daoAdmin() / setDAOAdmin(). - // Underlying auth model is OwnableUpgradeable; these are read-through facades. - - /// @notice Returns the current admin address. Delegates to OwnableUpgradeable.owner(). - /// @dev Compatibility shim preserving the pre-upgrade daoAdmin() ABI surface. - /// @return The current owner address. - function daoAdmin() external view returns (address) { - return owner(); - } - - /// @notice Transfers ownership and emits DAOAdminUpdated for off-chain indexers. - /// @dev Compatibility shim preserving the pre-upgrade setDAOAdmin() ABI surface. - /// @param newAdmin Address to transfer ownership to. - function setDAOAdmin(address newAdmin) external onlyOwner { - address old = owner(); - transferOwnership(newAdmin); - emit DAOAdminUpdated(old, newAdmin); - } } From dc8cb77740e4d47287c5a8093bf4c0f72e2dc789 Mon Sep 17 00:00:00 2001 From: umermjd11 Date: Wed, 30 Sep 2026 18:01:32 +0500 Subject: [PATCH 3/5] docs: bring contract, staking and DIN-Representative docs in line with foundry/src (#203) Contract docs (DinCoordinator, DinToken, DinValidatorStake, DINTaskCoordinator, DINTaskAuditor, DINShared) rewritten against the current source: - DinCoordinator: withdraw() is gone; documents sweepFeesToRouter, mintEmission, the mint cap and faucet retirement. - DinValidatorStake: partial slashing with S5 escalation, S6, jailing, 50% burn / 50% treasury, per-model floors and registration caps. - DINTaskCoordinator / DINTaskAuditor: reward pools and claims, dispute flows and the dispute seed, treasury forwarding via slashTreasury(), GIStateChanged, alongside the batch-assignment seed lock (PR No. 191) and T1/T2 commit-reveal (PR No. 197). - DINShared: slashTreasury() on IDinValidatorStake, the dispute-seed errors and the 12 T1/T2 commit-reveal errors. Also: - staking-mechanism.md and storage_layout.md (ERC-7201 namespaced bases). - dincli-testing-guide.md: Foundry is the default toolchain. - roles/dinrep.md: deployment via DeployPlatform.s.sol and import-deployments for local anvil and Optimism Sepolia, replacing the removed `dinrep deploy` commands. - Decision record, backlog and roadmap references updated. Open caveats recorded, not fixed: DINTaskCoordinator is 24,585 bytes at runtime, over the EIP-170 limit, and committed-but-unrevealed is slashed as a liveness miss (issue No. 201); the dincli auditor commit retry and aggregate-t2 batch id bugs (issue No. 202). Co-Authored-By: Claude Opus 5.5 --- Developer/BACK_LOG.md | 2 +- Developer/ROADMAP.md | 2 +- Developer/design/din-architecture.md | 10 +- .../issues/dincli-native-proxy-deployment.md | 11 +- Documentation/public/roles/dinrep.md | 132 +++- .../public/workflows/din-workflow.md | 2 +- .../technical/contracts/DINShared.md | 156 +++- .../technical/contracts/DINTaskAuditor.md | 419 ++++------- .../technical/contracts/DINTaskCoordinator.md | 606 ++++++--------- .../technical/contracts/DinCoordinator.md | 273 +++---- Documentation/technical/contracts/DinToken.md | 81 ++- .../technical/contracts/DinValidatorStake.md | 688 +++++------------- .../technical/mechanisms/staking-mechanism.md | 53 +- Documentation/technical/storage_layout.md | 65 +- .../technical/testing/dincli-testing-guide.md | 53 +- .../proxy-deployment-architecture.md | 7 + 16 files changed, 1079 insertions(+), 1481 deletions(-) diff --git a/Developer/BACK_LOG.md b/Developer/BACK_LOG.md index 582dad25..bb335081 100644 --- a/Developer/BACK_LOG.md +++ b/Developer/BACK_LOG.md @@ -32,4 +32,4 @@ | BL-23 | DP / Privacy (cache_model_0) | `clip_scope: global` default paired with an unreviewed `clipping_norm: 1.0` may over-clip real weights | [PR #109](https://github.com/InfiniteZeroFoundation/DevNet/pull/109) (`fix/dp-sensitivity`) correctly ties noise to the clip (`noise_scale = noise_multiplier * clipping_norm`) and defaults `clip_scope` to `global` so the clip actually bounds the combined L2 norm the noise is calibrated against — the sensitivity-calibration fix itself is sound (`per_layer` only bounds the combined norm at `clipping_norm * sqrt(n)`, not `clipping_norm`, so pairing it with the new noise formula would under-noise as tensor count grows). But `cache_model_0/manifest.json`'s `clipping_norm: 1.0` was never revisited: under the old `per_layer` scope it bounded each tensor independently to norm 1.0; under `global` it bounds the *entire model's combined* L2 norm to 1.0, a much tighter constraint for any model with more than one floating tensor (`sqrt(Σ‖tensor_i‖²)` grows with tensor count even if no individual tensor's norm changes). No test in PR #109 exercises this against `cache_model_0`'s real trained `state_dict` (the new tests use synthetic tensors), so it's unverified whether the new default clips — and thus degrades — the reference model's weights far more aggressively than before. Follow-up: run `apply_dp_mechanism`/`clip_state_dict` against `cache_model_0`'s actual trained weights under the new default and retune `clipping_norm` if the combined-norm bound turns out to be destructive. | P3 (verify before/at merge) | PR #109 review, Sep 8, 2026 | 🆕 New — unaddressed; `cache_model_0/manifest.json`'s `clipping_norm` still `1.0`, and `tests/test_cache_client_dp.py` still exercises synthetic tensors (`build_small_state_dict`), not real trained weights | | BL-24 | DP / Privacy (cache_model_0) | F4 — Laplace noise calibrated against an L2 clip, not the L1 sensitivity it's normally calibrated against | `post_training_laplace`'s `laplace_scale` is scaled by the same `clipping_norm` as the Gaussian mechanisms (`clip_state_dict`'s L2-norm clip), but the Laplace mechanism's textbook privacy guarantee is calibrated against L1 sensitivity, not L2. [PR #109](https://github.com/InfiniteZeroFoundation/DevNet/pull/109) flagged this explicitly rather than silently assuming it's fine (`Documentation/technical/services/clients.md:210-212`: "Laplace noise calibrated against an L2 clip is an open question tracked separately"), and [#99](https://github.com/InfiniteZeroFoundation/DevNet/issues/99)'s F4 (`Documentation/technical/audits/dp-mechanism-review.md` §F4) called the structural mismatch confirmed but left the exact magnitude/severity for a DP specialist's sign-off — explicitly excluded from #99's scope rather than fixed. [#119](https://github.com/InfiniteZeroFoundation/DevNet/pull/119) (which closed #99's remaining F8 items) left F4 untouched, so nothing currently tracks it now that #99 is closed. Needs either a DP specialist's opinion that L2-calibrated Laplace is acceptable as-is (and the open-question note downgraded to an explanation), or a fix (e.g. recalibrating against an actual L1 clip, or reclassifying `post_training_laplace` as experimental alongside F5's `per_layer` treatment) before any ε is claimed for it. | P3/P4 (needs DP specialist input first) | [#99](https://github.com/InfiniteZeroFoundation/DevNet/issues/99) F4 (excluded from scope), `Documentation/technical/audits/dp-mechanism-review.md` §F4, Sep 10, 2026 | 🆕 New — unaddressed; `Documentation/technical/services/clients.md`'s open-question note on L2-calibrated Laplace noise is unchanged since PR #109/#119 | | BL-25 | Tooling / reference model (cache_model_0) | `cache_model_0/abis/` task-contract artifacts are stale vs. `foundry/src` | `cache_model_0/abis/DINTaskCoordinator.json` and `DINTaskAuditor.json` are full hardhat artifacts (abi + bytecode) last regenerated 2026-06-09, missing 67 ABI entries present in `foundry/src` for the coordinator (104 vs 170) and 96 for the auditor (68 vs 158), with pre-foundry constructors. [PR #171](https://github.com/InfiniteZeroFoundation/DevNet/pull/171) regenerated the six bundled `dincli/abis/` files from `foundry/out` but deliberately left these alone: [cache_model_0/manifest.json](../cache_model_0/manifest.json)'s `task_contracts` block pins each artifact to an IPFS CID, records `"type": "hardhat-artifact"` and a hardhat/cancun/optimizer-off `compilation` block, and carries deployed addresses, and dincli resolves task artifacts by CID. Regenerating the files locally would leave the manifest describing artifacts it no longer points to. Needs one pass: regenerate both from `foundry/out` (with bytecode, e.g. `dincli system dump-abi --bytecode --output cache_model_0/abis`), update the manifest's artifact type and compilation metadata to foundry, re-pin to IPFS and update the CIDs. Addresses only change on a redeploy. | P3/P4 | PR #171 merge (2026-09-26): out of scope for the PR's ABI refresh | 🆕 New — unaddressed | -| BL-26 | Solidity/foundry (task contracts) | Batch-assignment seed lock (H-2) shares BL-11's re-roll residual, plus a new pool-reshaping gap | `DINTaskCoordinator.autoCreateTier1AndTier2`/`createAuditorsBatches` (task_240926_18 Part B, issue #156 H-2 aggregation side) reuse BL-11's future-block-seed + permissionless-lock pattern (`lockAggSeed`/`lockAuditSeed`), so they inherit its residual (2): the model owner can decline to lock a seed they dislike and wait out the ~256-block `blockhash` window for a fresh draw — repeatable, since nothing locks automatically today. A second, distinct gap not present in BL-11's `_assignFreshSubgroup` (which shuffles a fixed pool and only lets an exiting validator drop itself, not re-roll the rest): `_activeAggregatorPool`/`_activeAuditorPool` are evaluated at `autoCreateTier1AndTier2`/`createAuditorsBatches` call time, *after* the seed is already public, and Fisher-Yates re-rolls the whole assignment on any pool-size change — so a validator can unstake between lock and create to reshape everyone else's batch. Cost to the attacker is real but modest (their own Sybils sit out the GI and enter unbonding). Ready-made fix for the pool-reshaping gap: adopt `_assignFreshSubgroup`'s pattern — shuffle the fixed per-GI registration list (`dinAggregators[_GI]` / the auditor equivalent) with the locked seed, then skip inactive validators while filling seats, so an unstake only forfeits the leaver's own seat instead of re-rolling everyone else's. | P3/P4 | [PR #191](https://github.com/InfiniteZeroFoundation/DevNet/pull/191) review, Sep 29, 2026 | 🟡 Partially mitigated — the decline-to-lock re-roll is narrowed by a validator-side lock in dincli, added at the PR #191 merge (`dincli aggregator lock-seed` / `dincli auditor lock-seed`, and automatically in `show-t1-batches`/`show-t2-batches`/`lms-evaluation show-batch` before batches exist): any validator online after the seed block locks it, so the owner only gets a re-roll if nobody else is. A contract-side cap on re-anchors was considered and deferred: with no GI-abort path, a GI capped out would be stuck forever (`endGI` needs `AggregatorsSlashed`, `startGI` needs `GIended`), locking validators' registration slots and the reward pool — it needs a GI-abort design (who may abort, where the reward pool goes) first. Pool-reshaping gap unaddressed. Documented as residuals in the PR (`DINTaskCoordinator.md` §11.1). Sequencer-trust residual (shared with BL-11) tracked in [issue #178](https://github.com/InfiniteZeroFoundation/DevNet/issues/178); the re-roll-by-declining-to-lock and pool-reshaping gaps above have no dedicated issue yet, same as BL-11's own un-issued residual (2) | +| BL-26 | Solidity/foundry (task contracts) | Batch-assignment seed lock (H-2) shares BL-11's re-roll residual, plus a new pool-reshaping gap | `DINTaskCoordinator.autoCreateTier1AndTier2`/`createAuditorsBatches` (task_240926_18 Part B, issue #156 H-2 aggregation side) reuse BL-11's future-block-seed + permissionless-lock pattern (`lockAggSeed`/`lockAuditSeed`), so they inherit its residual (2): the model owner can decline to lock a seed they dislike and wait out the ~256-block `blockhash` window for a fresh draw — repeatable, since nothing locks automatically today. A second, distinct gap not present in BL-11's `_assignFreshSubgroup` (which shuffles a fixed pool and only lets an exiting validator drop itself, not re-roll the rest): `_activeAggregatorPool`/`_activeAuditorPool` are evaluated at `autoCreateTier1AndTier2`/`createAuditorsBatches` call time, *after* the seed is already public, and Fisher-Yates re-rolls the whole assignment on any pool-size change — so a validator can unstake between lock and create to reshape everyone else's batch. Cost to the attacker is real but modest (their own Sybils sit out the GI and enter unbonding). Ready-made fix for the pool-reshaping gap: adopt `_assignFreshSubgroup`'s pattern — shuffle the fixed per-GI registration list (`dinAggregators[_GI]` / the auditor equivalent) with the locked seed, then skip inactive validators while filling seats, so an unstake only forfeits the leaver's own seat instead of re-rolling everyone else's. | P3/P4 | [PR #191](https://github.com/InfiniteZeroFoundation/DevNet/pull/191) review, Sep 29, 2026 | 🟡 Partially mitigated — the decline-to-lock re-roll is narrowed by a validator-side lock in dincli, added at the PR #191 merge (`dincli aggregator lock-seed` / `dincli auditor lock-seed`, and automatically in `show-t1-batches`/`show-t2-batches`/`lms-evaluation show-batch` before batches exist): any validator online after the seed block locks it, so the owner only gets a re-roll if nobody else is. A contract-side cap on re-anchors was considered and deferred: with no GI-abort path, a GI capped out would be stuck forever (`endGI` needs `AggregatorsSlashed`, `startGI` needs `GIended`), locking validators' registration slots and the reward pool — it needs a GI-abort design (who may abort, where the reward pool goes) first. Pool-reshaping gap unaddressed. Documented as residuals in the PR (`DINTaskCoordinator.md` §6.3). Sequencer-trust residual (shared with BL-11) tracked in [issue #178](https://github.com/InfiniteZeroFoundation/DevNet/issues/178); the re-roll-by-declining-to-lock and pool-reshaping gaps above have no dedicated issue yet, same as BL-11's own un-issued residual (2) | diff --git a/Developer/ROADMAP.md b/Developer/ROADMAP.md index c4362a9e..35661a92 100644 --- a/Developer/ROADMAP.md +++ b/Developer/ROADMAP.md @@ -167,7 +167,7 @@ By the end of P4, DIN has an operational daemon capable of autonomous task disco | P4-9.2 | Daemon | Daemon Release (dind v1.0.0) | Package `dind` binary and Docker image alongside `din-node` · Installation and usage documentation · Release notes · Community onboarding materials | Public release. | P4-9.1 | Critical | Long-term (P4) | | 1 week | | Nov 23–27, 2026 | | Santiago | | 📋 Planned | P4 Week 13. | | **— P4: Core Protocol / On-chain Indexer (Robbert) —** | | | | | | | | | | | | | | | | | | P4-IDX1 | Core Protocol / Contracts | On-chain Indexer Design | Choose indexing approach: The Graph Protocol subgraph (preferred — Robbert has production experience) vs. lighter alternative (Ponder, custom event-poller) — document tradeoff · Design entity schema: queryable entities for all 4 platform contract event streams (`ModelRegistered`, `ValidatorSlashed`, `RewardClaimed`, `GIStarted`/`GIEnded`) · Write `subgraph.yaml` and `schema.graphql` · Set up local Graph node against Hardhat local chain for development testing · Design P4 daemon event schema: which events `dind` needs to subscribe to, what current coverage gaps exist (feeds into P4-7.1) | Foundation for replacing RPC-loop call sites in `dincli` with indexer-backed queries. Dynamic data sources for task-level contracts (`DINTaskCoordinator`/`DINTaskAuditor`) are deferred to P5+ (materially harder pattern). | P3-6.3b (stable contract ABIs), P3-PR13 (proxy contracts deployed) | High | Medium-term | | 1 week | | Oct 2026 | | Robbert | | ⚡ In Progress | Implemented in PR [#29](https://github.com/InfiniteZeroFoundation/DevNet/pull/29) (schema, 34 entities) + [#72](https://github.com/InfiniteZeroFoundation/DevNet/pull/72) (P3-staking event wiring, verified byte-for-byte against `DinValidatorStake.sol`), against `feat/din-indexer` — not yet merged to `develop` (no `subgraph/` there today). PR #29 has two small, real blockers: invalid GraphQL syntax at `schema.graphql:412` breaking `graph codegen`, and a broken (non-`CliRunner`) test file; full detail in [discussion #139](https://github.com/InfiniteZeroFoundation/DevNet/discussions/139). Dynamic task-contract indexing, originally deferred to P5+, was reclassified in-scope (Umer, Jul 13) but the underlying contract events it needs (`LocalModelSubmitted`, `T1AggregationSubmitted`, etc.) don't exist anywhere in `foundry/src` yet and have no owner — the one piece of #24 that's unstarted, not just unmerged. | -| P4-IDX2 | Core Protocol / Contracts | On-chain Indexer Implementation | Implement AssemblyScript (or TypeScript for Ponder) mapping handlers for all platform contract events · Deploy subgraph to local Graph node · Verify all event entities index correctly against test transactions · Replace `dincli/cli/dinrep.py` pending-request enumeration loop (~lines 443–467) with indexer-backed query (clearest candidate: `for idx in range(totalModelRequests)`) · Document: setup steps, local run instructions, ≥3 example queries covering validator registry, model registry, and reward history | Converts the most expensive RPC-polling loop to an indexed query. Other candidates for follow-up: `dincli/cli/modelownerd/lms.py` ~56–68 and `aggregation.py` ~76–144. | P4-IDX1 | High | Short-term (P4) | | 2 weeks | | Oct–Nov 2026 | | Robbert | | ⚡ In Progress | Handlers implemented in PR [#29](https://github.com/InfiniteZeroFoundation/DevNet/pull/29)/[#72](https://github.com/InfiniteZeroFoundation/DevNet/pull/72) (31 handlers across 4 data sources, local Graph node via `docker-compose`, 14+ example queries) — `dincli/cli/dinrep.py`'s RPC-loop replacement is done in the PR, just not merged to `develop` yet. Recommended merge order (#29 fixes first, then #72) in [discussion #139](https://github.com/InfiniteZeroFoundation/DevNet/discussions/139). 5 of the 31 handlers are commented out pending the unowned contract-event work noted in P4-IDX1. | +| P4-IDX2 | Core Protocol / Contracts | On-chain Indexer Implementation | Implement AssemblyScript (or TypeScript for Ponder) mapping handlers for all platform contract events · Deploy subgraph to local Graph node · Verify all event entities index correctly against test transactions · Replace `dincli/cli/dinrep.py` pending-request enumeration loop (~lines 378–406) with indexer-backed query (clearest candidate: `for idx in range(totalModelRequests)`) · Document: setup steps, local run instructions, ≥3 example queries covering validator registry, model registry, and reward history | Converts the most expensive RPC-polling loop to an indexed query. Other candidates for follow-up: `dincli/cli/modelownerd/lms.py` ~56–68 and `aggregation.py` ~76–144. | P4-IDX1 | High | Short-term (P4) | | 2 weeks | | Oct–Nov 2026 | | Robbert | | ⚡ In Progress | Handlers implemented in PR [#29](https://github.com/InfiniteZeroFoundation/DevNet/pull/29)/[#72](https://github.com/InfiniteZeroFoundation/DevNet/pull/72) (31 handlers across 4 data sources, local Graph node via `docker-compose`, 14+ example queries) — `dincli/cli/dinrep.py`'s RPC-loop replacement is done in the PR, just not merged to `develop` yet. Recommended merge order (#29 fixes first, then #72) in [discussion #139](https://github.com/InfiniteZeroFoundation/DevNet/discussions/139). 5 of the 31 handlers are commented out pending the unowned contract-event work noted in P4-IDX1. | | P4-IDX3 | Core Protocol / Contracts | Indexer Integration + P3 Docs Wrap-up | Wire indexer into `dincli` test suite · Verify replaced RPC-loop call site passes tests against indexed local node · Address any open audit findings requiring contract changes · Finalize any outstanding P3 public documentation · Handoff document: what P4 contract work follows (task-level contract indexing, event schema extensions for `dind`) | Completes contract-side P4 integration and closes all P3 documentation gaps. | P4-IDX2, P3-6.3b | High | Long-term (P4) | | 1 week | | Nov 2026 | | Robbert | | 📋 Planned | Blocked behind P4-IDX1/IDX2 actually merging to `develop` first — neither has, as of Sep 11, 2026. | | **— P4: Tokenomics / Fair Launch —** | | | | | | | | | | | | | | | | | | P4-FL1 | Cryptoeconomics / Tokenomics | Fair-Launch Validator Token Distributor (contract slice) | Merkle-drop claim contract (Transparent Proxy, treasury-Safe admin) · Funded once from treasury · Cliff + linear vesting on claim | Abraham's fair-launch vision: no ICO/pre-sale/VC allocation, DIN distributed to validators/active participants only. Resolves the ICO-vs-airdrop fork of [MECHANISM_DESIGN §9 item 14](design/MECHANISM_DESIGN.md#9-consolidated-open-decision-list) in favor of the airdrop path. Was not in this table at all — tracked only via [BL-18](BACK_LOG.md) and [issue #75](https://github.com/InfiniteZeroFoundation/DevNet/issues/75); added here to keep the roadmap matching reality. | — | High | — (ad hoc) | | — | | Aug–Sep 2026 | | Robbert (contract), Umer (eligibility scoping) | | ⚡ In Progress | Contract-only slice merged as [PR #77](https://github.com/InfiniteZeroFoundation/DevNet/pull/77) ([task_050826_9](tasks/task_050826_9.md)). Issue #75 stays open: eligibility computation is deliberately deferred until the on-chain indexer (P4-IDX1/IDX2) exists, since eligibility should be computed off indexed data rather than a raw RPC-log script. | diff --git a/Developer/design/din-architecture.md b/Developer/design/din-architecture.md index ae619ca8..67d2fc02 100644 --- a/Developer/design/din-architecture.md +++ b/Developer/design/din-architecture.md @@ -3,7 +3,7 @@ **Status:** Working design document — target architecture for the full DIN stack (DevNet → testnet) **Owner:** Umer **Scope:** How every DIN component — on-chain contracts, indexer, SDK, CLI, daemon, IPFS layer, and the on-device node/worker pair — fits together; which parts exist today and which are planned. -**Roadmap anchors:** P4 (SDK extraction, `dind` daemon, DIN Indexer); DIN DAO deferred to post-mainnet (2026-08-04 — see [DESIGN_DECISIONS.md DD-3](DESIGN_DECISIONS.md#dd-3--initial-dinmultisig-signer-composition-stage-a)), off-chain governance until then. +**Roadmap anchors:** P4 (SDK extraction, `dind` daemon, DIN Indexer); DIN-DAO deferred to post-mainnet (2026-08-04 — see [DESIGN_DECISIONS.md DD-3](DESIGN_DECISIONS.md#dd-3--initial-dinmultisig-signer-composition-stage-a)), off-chain governance until then. > The wiki pages under [DIN Components](https://github.com/InfiniteZeroFoundation/DevNet/wiki) describe each component individually; this document is the one place that shows the whole system and the dependency order between the parts. @@ -15,7 +15,7 @@ DIN is organized as six layers. Everything above the chain exists to make partic | Layer | Components | Status | |---|---|---| -| Governance | DIN DAO contracts (Multisig, Timelock, Governance staking, Governor, Guardian) | ⏳ Deferred (post-mainnet; off-chain team/forum governance until then) | +| Governance | DIN-DAO contracts (Multisig, Timelock, Governance staking, Governor, Guardian) | ⏳ Deferred (post-mainnet; off-chain team/forum governance until then) | | On-chain coordination | Platform contracts: `DinCoordinator`, `DinToken`, `DinValidatorStake`, `DinModelRegistry` | ✅ Deployed (Optimism Sepolia) | | | Task contracts (per model): `DINTaskCoordinator`, `DINTaskAuditor` | ✅ Deployed per model | | Read layer | DIN Indexer (subgraph or lighter equivalent) | 📋 Planned (P4) | @@ -34,7 +34,7 @@ DIN is organized as six layers. Everything above the chain exists to make partic ```mermaid flowchart TB subgraph chain["⛓ Blockchain — Optimism"] - DAO["DIN DAO ⏳
Multisig · Timelock · Governor · Guardian
(deferred, post-mainnet)"] + DAO["DIN-DAO ⏳
Multisig · Timelock · Governor · Guardian
(deferred, post-mainnet)"] subgraph platform["Platform contracts (deployed once)"] COORD["DinCoordinator"] TOKEN["DinToken"] @@ -81,9 +81,9 @@ flowchart TB ## 3. Layers, top to bottom -### 3.1 DIN DAO contracts (deferred, post-mainnet) +### 3.1 DIN-DAO contracts (deferred, post-mainnet) -Today the DIN-Representative admin key controls platform parameters (fees, slasher authorization, model registration approval, blacklisting, treasury withdrawal). The original plan was for the DIN DAO to replace that single key with governance contracts — Multisig, Timelock, governance staking (locked non-transferable DIN), Governor, and a Guardian emergency path — rolled out in stages (Multisig shadowing from devnet 2.0, Timelock ownership from devnet 3.0, full Governor voting on testnet). +Today the DIN-Representative admin key controls platform parameters (fees, slasher authorization, model registration approval, blacklisting, treasury withdrawal). The original plan was for the DIN-DAO to replace that single key with governance contracts — Multisig, Timelock, governance staking (locked non-transferable DIN), Governor, and a Guardian emergency path — rolled out in stages (Multisig shadowing from devnet 2.0, Timelock ownership from devnet 3.0, full Governor voting on testnet). **As of 2026-08-04, that staged rollout is deferred to post-mainnet.** Abraham's decision (see [DESIGN_DECISIONS.md DD-3](DESIGN_DECISIONS.md#dd-3--initial-dinmultisig-signer-composition-stage-a)): DIN follows Ethereum's off-chain governance model until well past testing — team coordination and public discussion (forums), no `DinMultisig` sitting between the community and protocol upgrades, no immutable signer set locked in before there's real demand or a legitimate selection process. Any near-term multisig is a plain Gnosis Safe scoped to treasury/fund management only, never the protocol-role authority (`PROPOSER_ROLE`/`CANCELLER_ROLE`) this section originally described. On-chain governance gets progressively revisited post-mainnet. Near-term contract work keeps using plain owner-controlled setters, as it already does — there is no Timelock to eventually govern them, so this is no longer a staging step toward one, just how the contracts stay. diff --git a/Developer/issues/dincli-native-proxy-deployment.md b/Developer/issues/dincli-native-proxy-deployment.md index 9855275a..301a9622 100644 --- a/Developer/issues/dincli-native-proxy-deployment.md +++ b/Developer/issues/dincli-native-proxy-deployment.md @@ -21,6 +21,12 @@ runtime dependency, hardhat env keys instead of the dincli wallet, coupling to the deployments file) and dies when `hardhat/` is deleted (Foundry-only decision, 2026-07-03). +Update (2026-09-25): the interim flow now defaults to Foundry — +`forge script foundry/script/DeployPlatform.s.sol` (seven platform contracts) +followed by `dincli system import-deployments` (reads +`foundry/deployments/.json`); the hardhat script remains the secondary +path via `--hardhat`. + ## Work Per contract (DinToken, DinCoordinator, DinValidatorStake, DINModelRegistry), @@ -38,8 +44,9 @@ Supporting pieces (from the record §6): `bytecode: {object: "0x…"}` in `get_contract_instance` / deploy path - ship/pin the OZ `TransparentUpgradeableProxy` artifact (v5.x) with dincli — do not recompile it ad hoc -- new `dinrep deploy din-token` command; existing deploy commands become - proxy-aware; write resulting addresses (incl. `proxy_admin`) to `din_info.json` +- a `dinrep deploy` sub-app with one proxy-aware command per platform contract + (the old constructor-based `dinrep deploy din-coordinator/din-validator-stake/din-model-registry` + commands were removed because they could not deploy the proxied contracts); write resulting addresses (incl. `proxy_admin`) to `din_info.json` - scope: **V1 bootstrap only** — upgrades stay behind the toolchain scripts ## Acceptance diff --git a/Documentation/public/roles/dinrep.md b/Documentation/public/roles/dinrep.md index 59440604..71785d5d 100644 --- a/Documentation/public/roles/dinrep.md +++ b/Documentation/public/roles/dinrep.md @@ -1,40 +1,123 @@ # DIN-Representative Documentation -The DIN-Representative administers the core infrastructure contracts of the DIN network (a DIN-DAO is planned to take over this role after mainnet). This includes deploying the fundamental contracts and authorizing participants (slashers) who can penalize misbehaving validators. +The DIN-Representative administers the core infrastructure contracts of the DIN network. This includes deploying the platform contracts, approving model registrations and manifest updates, setting registry fees, and authorizing participants (slashers) who can penalize misbehaving validators. + +Today the DIN-Representative is a single admin key: the `owner()` of the platform contracts. On-chain DIN-DAO governance is deferred to post-mainnet; until then, governance happens off-chain. + +The CLI commands for this role live under `dincli dinrep`. --- ## 1. Deployment -Deploy the core contracts in the order listed below. Each contract depends on the previous one being live. +The platform contracts (`DinTreasury`, `DinToken`, `DinCoordinator`, `DinValidatorStake`, `DINModelRegistry`, `DinFeeRouter`, `DinEmission`) are deployed behind OpenZeppelin Transparent Proxies by the Foundry script `foundry/script/DeployPlatform.s.sol`. The script also initializes and wires the contracts, then writes the proxy and ProxyAdmin addresses to `foundry/deployments/.json`. After it runs, import that file into `dincli`. + +The output file is named after the chain the script ran against: + +| Chain | Chain ID | Deployments file | +|-------|----------|------------------| +| Local anvil / hardhat node | 1337 / 31337 | `foundry/deployments/localhost.json` | +| Optimism Sepolia | 11155420 | `foundry/deployments/sepolia_op_devnet.json` | + +On any other chain the script reverts unless you set `DEPLOYMENTS_NETWORK=` to choose the file name. > [!NOTE] -> The `--artifact` flag must point to the compiled JSON output from Hardhat/ Foundry (contains the ABI and bytecode). +> Both flows run from the repo root and need `npm ci` in `foundry/` first. The OpenZeppelin upgrade-safety validation that runs during the deploy calls `npx @openzeppelin/upgrades-core` from `foundry/node_modules`. + +### 1a. Local network (anvil) + +**1. Start a local chain.** `anvil.sh` runs a private chain (chain ID 1337) on `http://127.0.0.1:8545` with pre-funded dev accounts: + +```bash +./foundry/anvil.sh +``` + +**2. Deploy.** `--unlocked` lets anvil sign for its dev account 0, so no private key is needed: + +```bash +cd foundry && npm ci +forge clean +forge script script/DeployPlatform.s.sol \ + --rpc-url http://127.0.0.1:8545 \ + --broadcast \ + --sender 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 \ + --unlocked +cd .. +``` + +This writes `foundry/deployments/localhost.json`. That file is gitignored, because it is regenerated on every local deploy. + +**3. Import into dincli:** + +```bash +dincli --network local system import-deployments +``` -### 1. DIN Coordinator +### 1b. Optimism Sepolia -The main coordinator contract that governs network-wide operations. +Optimism Sepolia is a public chain, so there is no anvil step. Instead you need an RPC endpoint and a funded signing key. + +**1. Prerequisites** + +- **RPC endpoint.** Use the same endpoint dincli uses: `SEPOLIA_OP_DEVNET_RPC_URL` in `.env.sepolia_op_devnet` at the repo root. The file is gitignored, so create it from [`.env.example`](../../../.env.example) if you don't have one yet. Load it into your shell: + + ```bash + set -a; source .env.sepolia_op_devnet; set +a + ``` + +- **Signing key.** Import the DIN-Representative key into Foundry's encrypted keystore once. This prompts for the private key and a password: + + ```bash + cast wallet import --interactive + cast wallet address --account # prints the DIN-Representative address + ``` + + The deploying address becomes the owner of every platform contract and every ProxyAdmin, so use the DIN-Representative key. + +- **Funds.** The address needs Optimism Sepolia ETH. A full deploy is roughly 30 transactions: seven implementations, seven proxies, and the wiring calls. + +**2. Deploy:** ```bash -dincli dinrep deploy din-coordinator --artifact +cd foundry && npm ci +forge clean +forge script script/DeployPlatform.s.sol \ + --rpc-url "$SEPOLIA_OP_DEVNET_RPC_URL" \ + --broadcast \ + --account \ + --sender +cd .. ``` -### 2. Validator Stake +To also verify the contracts on the block explorer, add `--verify --etherscan-api-key "$ETHERSCAN_API_KEY"`. + +Alternatively, `--rpc-url optimism-sepolia` uses the `foundry.toml` RPC alias. That alias builds an Infura URL from `INFURA_API_KEY`, which forge loads automatically from `foundry/.env` (gitignored), so it works without sourcing `.env.sepolia_op_devnet`. + +This writes `foundry/deployments/sepolia_op_devnet.json`. Unlike `localhost.json`, this file is **committed**: it is the network's public record of the platform addresses. -The staking contract used by validators (Auditors, Aggregators). +> [!TIP] +> Run once without `--broadcast` first. Forge then only simulates the deploy against the live chain state, so you can check the sender, the balance, and the upgrade-safety validation without spending gas. + +**3. Import into dincli:** ```bash -dincli dinrep deploy din-validator-stake --artifact +dincli --network sepolia_op_devnet system import-deployments ``` -### 3. Model Registry +### Import options -Records federated learning tasks, assigns a unique `model_id` to each task, and stores the initial global model reference and manifest for a task. +`import-deployments` reads `foundry/deployments/.json` for the active dincli network by default. The other sources are: ```bash -dincli dinrep deploy din-model-registry --artifact +dincli system import-deployments --file # any explicit file +dincli system import-deployments --hardhat # hardhat/deployments/.json ``` +`--hardhat` reads output from the secondary Hardhat toolchain (`cd hardhat && npx hardhat run scripts/deploy-platform.ts --network `). + +> [!NOTE] +> Native proxy deployment from `dincli` (`dincli dinrep deploy ...`) is planned but not implemented yet. See [dincli-native-proxy-deployment.md](../../../Developer/issues/dincli-native-proxy-deployment.md). + --- ## 2. Registry Management @@ -51,12 +134,18 @@ dincli dinrep registry total-models ### Model Registration Approval -Model registration follows a **request → approval** flow. Model Owners submit requests; the DIN-Representative reviews and approves or rejects them. +Model registration follows a **request → approval** flow. Model Owners submit requests, and the DIN-Representative approves or rejects each one. -**List pending registration requests:** +**List pending requests** (model registrations and manifest updates; `-t` narrows the list to one type): ```bash -dincli dinrep registry list-pending-requests [--type model|manifest] +dincli dinrep registry list-pending-requests [-t model|manifest] +``` + +**Inspect a single request:** + +```bash +dincli dinrep registry explore-request -t model ``` **Approve a model registration request:** @@ -80,7 +169,7 @@ The registration fee is retained by the contract in both cases. ### Manifest Update Approval -Manifest updates also follow a request → approval flow. +Manifest updates also follow a request → approval flow. Use `explore-request -t manifest ` to inspect a request before deciding. **Approve a manifest update:** @@ -101,7 +190,7 @@ dincli dinrep registry reject-manifest-update ### Kill Switch — Disable / Enable Models -Disable a model immediately. This blocks manifest update requests from the model owner and should be checked by downstream contracts (`TaskCoordinator`, `TaskAuditor`) before executing any model tasks. +Disable a model immediately. A disabled model's owner can't submit manifest update requests, and pending updates for it can't be approved. ```bash # Disable a model (emergency stop) @@ -112,7 +201,7 @@ dincli dinrep registry enable-model ``` > [!CAUTION] -> Disabling a model does not delete it. All on-chain history is preserved. Downstream contracts must actively check `modelDisabled(modelId)` for the kill switch to have operational effect. +> Disabling a model does not delete it. All on-chain history is preserved. Today the task contracts (`DINTaskCoordinator`, `DINTaskAuditor`) do **not** check `modelDisabled(modelId)`, so disabling only blocks manifest updates. It does not stop the model's running GIs, submissions or slashing. --- @@ -127,7 +216,7 @@ The registry charges fees for model registration and manifest update requests. A | `openSourceUpdateFee` | 0.0000001 ETH | Open-source manifest update requests | | `proprietaryUpdateFee` | 0.000001 ETH | Proprietary manifest update requests | -**Update a single fee:** +**Update a single fee** (amounts in ETH): ```bash dincli dinrep registry set-open-source-fee @@ -136,7 +225,7 @@ dincli dinrep registry set-open-source-update-fee dincli dinrep registry set-proprietary-update-fee ``` -**Update all fees atomically (preferred for governance proposals):** +**Update all fees atomically** (amounts in ETH): ```bash dincli dinrep registry set-fees \ @@ -207,10 +296,9 @@ dincli dinrep add-slasher --contract ## Workflow -1. **Deploy** — Coordinator → Validator Stake → Model Registry (in order). +1. **Deploy** — Run the Foundry `DeployPlatform.s.sol` script (§1a local, §1b Optimism Sepolia), then `dincli system import-deployments`. 2. **Configure Slashers** — After each new task is created, register its Task Coordinator and Task Auditor as slashers. 3. **Process Registration Requests** — Review pending `ModelRequest` entries; approve or reject each one. 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. - diff --git a/Documentation/public/workflows/din-workflow.md b/Documentation/public/workflows/din-workflow.md index a4737f21..375ee6ed 100644 --- a/Documentation/public/workflows/din-workflow.md +++ b/Documentation/public/workflows/din-workflow.md @@ -2,7 +2,7 @@ In this guide we will describe the workflow of the DIN Protocol. -DIN-Representative (later DIN-DAO) is a entity that is authorized to perform certain actions on behalf of the DIN Protocol. +DIN-Representative (later DIN-DAO) is an entity that is authorized to perform certain actions on behalf of the DIN Protocol. DIN-Representative has deployed the following DIN Protocol contracts on Sepolia-Optimism testnet as part of DIN-devnet: diff --git a/Documentation/technical/contracts/DINShared.md b/Documentation/technical/contracts/DINShared.md index 2ee927a4..4720a698 100644 --- a/Documentation/technical/contracts/DINShared.md +++ b/Documentation/technical/contracts/DINShared.md @@ -1,6 +1,6 @@ # DINShared — Technical Documentation -> **File:** `foundry/src/DINShared.sol` +> **File:** [`foundry/src/DINShared.sol`](../../../foundry/src/DINShared.sol) > **SPDX-License-Identifier:** UNLICENSED > **Solidity:** `^0.8.28` @@ -122,12 +122,17 @@ interface IDinValidatorStake { function getStake(address validator) external view returns (uint256); function minStake() external view returns (uint256); function isValidatorActive(address validator) external view returns (bool); - function slash( - address validator, - uint256 amount, - bytes32 reason - ) external returns (uint256); + function slash(address validator, uint256 amount, bytes32 reason) external returns (uint256); function isSlasherContract(address slasherContract) external view returns (bool); + function getEncryptionKey(address validator) external view returns (bytes memory); + function getModelStakeMin(uint256 modelId) external view returns (uint256); + function activeRegistrationCount(address validator) external view returns (uint256); + function incrementActiveRegistration(address validator) external; + function decrementActiveRegistration(address validator) external; + function maxConcurrentRegistrationsPerStakeUnit() external view returns (uint256); + function slashPartial(address validator, uint256 amount, bytes32 reason, uint256 giIndex) external returns (uint256); + function recordNoParticipation(address validator, bytes32 reason) external returns (uint256); + function slashTreasury() external view returns (address); } ``` @@ -135,11 +140,17 @@ Used by: `DINTaskCoordinator`, `DINTaskAuditor` | Method | Purpose | |--------|---------| -| `getStake` | Check if a registrant has sufficient stake before accepting registration | -| `minStake` | Minimum stake threshold a registrant/auditor/aggregator must be at or above | -| `isValidatorActive` | Checked on every `commitAuditScore`/`revealAuditScore` call (`TA_AuditorNotActive`) and aggregator registration (`TC_AggregatorNotActive`) | -| `slash` | Penalise a misbehaving auditor or aggregator, tagged with a `bytes32 reason`; returns the amount actually slashed | -| `isSlasherContract` | Verify a contract is registered as a slasher (used during model registration) | +| `getStake` / `minStake` | Per-model floor and concurrency-cap checks at registration; `minStake` also sizes every slash | +| `isValidatorActive` | Gate on registration, auditor and aggregator commit/reveal, `openDispute`, and batch formation (inactive registrants are filtered out) | +| `slash` | Full-severity slash (`AGG_T*_BAD_CONSENSUS`, `AUD_SCORE_DEVIATION`, `S4_*`) | +| `slashPartial` | S1/S2 liveness slash with S5 recidivism tracking (`AUD_NO_VOTE`, `AGG_T*_NO_SUBMISSION`) | +| `recordNoParticipation` | S6 counter — declared, but no task contract calls it | +| `isSlasherContract` | Setup-state checks (`setDINTaskCoordinatorAsSlasher` / `setDINTaskAuditorAsSlasher`) | +| `getEncryptionKey` | `DINTaskAuditor` requires each batch auditor to have an X25519 key before assigning encrypted test-data keys | +| `getModelStakeMin` | Per-model stake floor (`TC_/TA_StakeBelowModelFloor`) | +| `activeRegistrationCount` / `maxConcurrentRegistrationsPerStakeUnit` | Concurrent-registration cap (`TC_/TA_ConcurrentRegistrationCapReached`) | +| `incrementActiveRegistration` / `decrementActiveRegistration` | Counter maintenance at registration and in `releaseGIRegistrationSlots` | +| `slashTreasury` | Platform treasury address. Both task contracts forward the treasury reward share and the treasury half of forfeited bonds and penalties to it, and burn that part if it is unset | ### 3.2 `IDINTaskCoordinator` @@ -147,6 +158,7 @@ Used by: `DINTaskCoordinator`, `DINTaskAuditor` interface IDINTaskCoordinator { function GI() external view returns (uint256); function GIstate() external view returns (GIstates); + function aggregatorWeight(uint256 gi, address aggregator) external view returns (uint256); } ``` @@ -154,8 +166,9 @@ Used by: `DINTaskAuditor` | Method | Purpose | |--------|---------| -| `GI()` | Read the current Global Iteration counter for validation in modifiers | -| `GIstate()` | Check the current lifecycle state to gate operations | +| `GI()` | Current GI counter, for `onlyCurrentGI` and `depositRewards` validation | +| `GIstate()` | Lifecycle gate for every auditor-side phase | +| `aggregatorWeight` | Read at `claimReward` time to compute each aggregator's weighted share | ### 3.3 `IDINTaskAuditor` @@ -167,6 +180,9 @@ interface IDINTaskAuditor { function slashAuditors(uint _GI) external returns (bool); function approvedModelIndexes(uint _GI) external view returns (uint[] memory); function updatePassScore(uint256 newPassScore) external; + function giRewardPool(uint256 gi) external view returns (uint256); + function settleRewards(uint256 gi, uint256 aggregatorTotalWeight) external; + function decrementAuditorRegistrations(uint256 _GI) external; } ``` @@ -174,12 +190,15 @@ Used by: `DINTaskCoordinator` | Method | Purpose | |--------|---------| -| `createAuditorsBatches` | Called by coordinator to trigger batch formation in auditor contract; `seed` is the coordinator's locked `auditSeed[_GI]` (issue #156 H-2, task_240926_18 Part B) — reverts `TA_AuditSeedNotLocked` if zero | -| `setTestDataAssignedFlag` | Signals that test datasets have been distributed to batches | -| `finalizeEvaluation` | Computes final median scores and approval status for all submitted models; reverts unless `GIstate == LMSevaluationRevealStarted` | -| `slashAuditors` | Called by coordinator once `GIstate == T2AggregationDone`; slashes auditors who missed their vote, then coordinator transitions to `AuditorsSlashed` | -| `approvedModelIndexes` | Returns indexes of models that passed evaluation (used for T1/T2 batch formation) | -| `updatePassScore` | Sets the minimum median score required for model approval (called at start of each GI) | +| `createAuditorsBatches` | Batch formation, triggered by the coordinator in `LMSclosed`. `seed` is the coordinator's locked `auditSeed[_GI]`; a zero seed reverts `TA_AuditSeedNotLocked` | +| `setTestDataAssignedFlag` | Records that test datasets were distributed (informational — nothing gates on it) | +| `finalizeEvaluation` | Median scores + approval; requires `LMSevaluationRevealStarted` | +| `slashAuditors` | S1/S3 auditor slashing; requires `T2AggregationDone` | +| `approvedModelIndexes` | Approved models for T1/T2 batch formation | +| `updatePassScore` | Pass score update from the two-argument `startGI` | +| `giRewardPool` | `_startGI` refuses to start a GI whose pool is empty (`TC_GIRewardPoolNotFunded`) | +| `settleRewards` | Called from `endGI` with the coordinator's `totalAggregatorWeight` | +| `decrementAuditorRegistrations` | Called from `releaseGIRegistrationSlots` | --- @@ -208,13 +227,11 @@ Used by: `DINTaskCoordinator` | `TA_AuditorRegistrationNotOpen` | Registration attempted outside registration window | | `TA_WrongGI` | Global Iteration mismatch | | `TA_AuditorAlreadyRegistered` | Duplicate auditor registration for same GI | -| `TA_InsufficientStake` | Auditor's stake below minimum threshold | | `TA_LMSubmissionsNotOpen` | Local model submission attempted outside submission window | | `TA_AlreadySubmitted` | Client has already submitted a model this GI | | `TA_MaxLMSubmissionsReached` | Submission count reached `MAX_LM_SUBMISSIONS` (10,000) | | `TA_NotEnoughAuditors` | Too few auditors to form even one batch | | `TA_CannotCreateAuditorsBatches` | State is not `LMSclosed` | -| `TA_BatchNotFound` | Batch ID query out of bounds | | `TA_BatchDoesNotExist` | Batch ID >= batch array length | | `TA_BatchIDMismatch` | Internal sanity check failure on batch ID | | `TA_CannotSetTestDataAssignedFlag` | State is not `AuditorsBatchesCreated` | @@ -222,13 +239,21 @@ Used by: `DINTaskCoordinator` | `TA_FlagAlreadySet` | Flag was already set for this GI | | `TA_NotAssignedAuditor` | Score commit/reveal from auditor not assigned to the batch | | `TA_InvalidModelIndex` | Model index not assigned to this batch | -| `TA_CannotSetAuditScore` | Declared but currently unused — dead code left over from the pre-commit-reveal `setAuditScorenEligibility`, which this replaced with `commitAuditScore`/`revealAuditScore`. | +| `TA_CannotSetAuditScore` | Declared but unused — left over from the pre-commit-reveal `setAuditScorenEligibility`. | | `TA_ScoreOutOfRange` | Score > 100 (checked at reveal time) | | `TA_AlreadyVoted` | Auditor has already revealed a score for this model | | `TA_CannotFinalizeEvaluation` | State is not `LMSevaluationRevealStarted` | | `TA_AuditorNotActive` | Auditor's `DinValidatorStake.isValidatorActive()` is false — checked on both commit and reveal | | `TA_InvalidDeviationThreshold` | S3 deviation threshold set outside 0–100 range | | `TA_EmptyScoreSet` | `_medianOf` called with zero scores to compute a median over | +| `TA_RegistrationCapReached` | `dinAuditors[GI].length` reached `MAX_REGISTERED_AUDITORS` (300) | +| `TA_CannotSlashAuditors` | `slashAuditors` called while `GIstate != T2AggregationDone` | +| `TA_InvalidSlashFraction` | `setS1SlashFractionBps` with 0 or a value above 10 000 | +| `TA_StakeBelowModelFloor` | Auditor's stake below the model's `getModelStakeMin` floor | +| `TA_ConcurrentRegistrationCapReached` | Auditor already at `(stake / minStake) × maxConcurrentRegistrationsPerStakeUnit` open registrations | +| `TA_AuditorEncryptionKeyNotRegistered` | A batch auditor has no X25519 key on `DinValidatorStake` when test-data keys are assigned | +| `TA_InsufficientStake` | Declared but unused — registration gates on `isValidatorActive` instead | +| `TA_BatchNotFound` | Declared but unused (`TA_BatchDoesNotExist` is used) | ### 4.3 DINTaskCoordinator Errors (`TC_*`) @@ -244,7 +269,7 @@ Used by: `DINTaskCoordinator` | `TC_WrongGI` | GI index argument does not match current `GI` counter | | `TC_AggregatorsRegistrationCannotBeStarted` | Registration start called in wrong state | | `TC_AggregatorsRegistrationNotOpen` | Aggregator registration in wrong state | -| `TC_InsufficientStake` | Aggregator stake below threshold | +| `TC_InsufficientStake` | Declared but unused — registration gates on `isValidatorActive` instead | | `TC_AggregatorAlreadyRegistered` | Duplicate aggregator registration | | `TC_AggregatorsRegistrationCannotBeFinished` | Close called in wrong state | | `TC_AuditorsRegistrationCannotBeStarted` | Auditor registration start in wrong state | @@ -258,17 +283,17 @@ Used by: `DINTaskCoordinator` | `TC_EvalPhaseNotClosed` | T1/T2 batch creation before evaluation close | | `TC_NotEnoughValidators` | Too few aggregators for T1 batches | | `TC_NotEnoughApprovedModels` | Fewer than `T1_MODELS_PER_BATCH` models approved | -| `TC_BatchNotFound` | Tier-1 batch ID out of bounds | -| `TC_OnlyOneTier2Batch` | Tier-2 batch ID != 0 | +| `TC_BatchNotFound` | `getTier1Batch` with a batch ID out of bounds | +| `TC_OnlyOneTier2Batch` | Tier-2 batch ID != 0 (T2 commit/reveal, `getTier2Batch`) | | `TC_NotReadyForT1Aggregation` | T1 start in wrong state | -| `TC_T1AggregationNotStarted` | Submission or finalize called before T1 start | -| `TC_InvalidBatch` | Batch ID >= tier1Batches length | -| `TC_NotBatchAggregator` | Submitter not assigned to the batch | -| `TC_AlreadySubmitted` | Aggregator has already submitted for this batch | -| `TC_NoSubmissions` | No CIDs were submitted; cannot determine winner | +| `TC_T1AggregationNotStarted` | `commitT1Aggregation` called while the state is not `T1AggregationStarted` | +| `TC_InvalidBatch` | Batch ID out of range (T1 commit/reveal, `openDispute`) | +| `TC_NotBatchAggregator` | Commit or reveal from an address not assigned to the batch | +| `TC_AlreadySubmitted` | Aggregator has already revealed for this batch | +| `TC_NoSubmissions` | No CID was revealed for a batch; cannot determine a winner | | `TC_NotReadyToFinalizeT1` | T1 finalize called in wrong state | | `TC_NotReadyForT2Aggregation` | T2 start in wrong state | -| `TC_T2AggregationNotStarted` | T2 submission or finalize called before T2 start | +| `TC_T2AggregationNotStarted` | `commitT2Aggregation` called while the state is not `T2AggregationStarted` | | `TC_NotReadyToFinalizeT2` | T2 finalize called in wrong state | | `TC_NotReadyToSlashAuditors` | Auditor slash called before T2 done | | `TC_NotReadyToSlashAggregators` | Aggregator slash called before auditors slashed | @@ -277,10 +302,60 @@ Used by: `DINTaskCoordinator` | `TC_FailedToFinalizeEvaluation` | `finalizeEvaluation` returned false | | `TC_AggregatorNotActive` | Aggregator's `DinValidatorStake.isValidatorActive()` is false | | `TC_FailedToSlashAuditors` | `DINTaskAuditor.slashAuditors()` returned false | +| `TC_GIRewardPoolNotFunded` | `startGI` for a GI whose `giRewardPool` on the auditor is zero | +| `TC_RegistrationCapReached` | `dinAggregators[GI].length` reached `MAX_REGISTERED_AGGREGATORS` (300) | +| `TC_StakeBelowModelFloor` | Aggregator's stake below the model's `getModelStakeMin` floor | +| `TC_ConcurrentRegistrationCapReached` | Aggregator already at its concurrent-registration cap | +| `TC_ZeroCID` | T1/T2 reveal of `bytes32(0)` (reserved as the "no submission" sentinel) | +| `TC_InsufficientSubmissions` | A T1/T2 batch finalized with fewer than `T1_AGGREGATORS_PER_BATCH / 2 + 1` revealed CIDs | +| `TC_InvalidSlashFraction` | `setS2SlashFractionBps` with 0 or a value above 10 000 | -### 4.4 Batch-Assignment Seed Lock (issue #156 H-2, task_240926_18 Part B) +### 4.4 Rewards (`TA_*`, task_210726_6 §3) -Mirrors the dispute-seed errors' shape (`TC_DisputeSeedNotLocked`/`TC_DisputeSeedBlockNotMined`/`TC_DisputeSeedAlreadyLocked`, `DINTaskCoordinator.sol`'s `lockDisputeSeed` — dispute resolution isn't yet documented in `DINTaskCoordinator.md`, a pre-existing gap from PR #171/task_16 Part A, out of scope here), one set per seed pair. See `DINTaskCoordinator.md` §11.1 for the full lock mechanism this PR adds. +| Error | Description | +|-------|-------------| +| `TA_InvalidAddress` | Zero address passed to the constructor or `setDinToken` | +| `TA_InvalidRewardSplit` | `setRewardSplit` fields do not sum to 10 000 bps | +| `TA_NoRewardsToClaim` | `claimRewards` with a zero `claimable` balance | +| `TA_InvalidRewardGI` | `depositRewards` for GI 0 or a GI that has already passed | +| `TA_RewardsNotSettled` | `claimReward(gi)` before `endGI` settled that GI | +| `TA_RewardAlreadyClaimed` | Second `claimReward(gi)` by the same address | +| `TA_NoRewardEarned` | Caller has no client, auditor or aggregator share in that GI | + +### 4.5 Test-data disputes (`TA_*`, task_240826_10 §B) + +| Error | Description | +|-------|-------------| +| `TA_NoCommitmentStored` | `openTestDataDispute` on a batch with no test-data commitment | +| `TA_DisputeAlreadyActive` | A dispute is already open for the batch | +| `TA_NoActiveDispute` | Resolve/close on a batch with no open dispute; also `reassignAuditTestDataset` when no reassignment is pending | +| `TA_DisputeWindowClosed` | `resolveTestDataDispute` after the window, or `closeExpiredDispute` before it ends (the same error is reused for both) | +| `TA_InvalidDisputeBond` | Reused by `setDisputePenaltyBps` for a value above 10 000 | +| `TA_BatchPendingReassignment` | New assignment or dispute on a batch awaiting `reassignAuditTestDataset` | +| `TA_InsufficientDisputeBond` | Declared but unused | + +### 4.6 Aggregation disputes (`TC_*`, task_210726_6 §4c) + +| Error | Description | +|-------|-------------| +| `TC_InvalidAddress` | Zero address passed to the constructor, `setDINTaskAuditorContract` or `setDinToken` | +| `TC_InvalidDisputeParams` | `setDisputeParams` with a zero bond, window or resolution window, or a seed delay outside 1–256 | +| `TC_BatchNotFinalized` | `openDispute` on a batch that has not finalized | +| `TC_DisputeWindowClosed` | `openDispute` after `finalizedAt + disputeWindow` | +| `TC_DisputeAlreadyOpen` | A dispute already exists for the batch | +| `TC_DisputeNotOpen` | `resolveDispute` or `lockDisputeSeed` with no dispute | +| `TC_DisputeAlreadyResolved` | Second `resolveDispute`, or `lockDisputeSeed` after resolution | +| `TC_NoBondClaimable` | `claimDisputeBond` with nothing to claim | +| `TC_DisputeNotAwaitingRecomputation` | `settleRecomputation` / `expireDispute` on a dispute that was not upheld | +| `TC_ResolutionWindowOpen` | `expireDispute` before `resolutionDeadline` | +| `TC_DisputeAlreadyFinalized` | Second `settleRecomputation` / `expireDispute` | +| `TC_DisputeSeedBlockNotMined` | `lockDisputeSeed` before the anchor block is mined | +| `TC_DisputeSeedAlreadyLocked` | `lockDisputeSeed` when the seed is already set | +| `TC_DisputeSeedNotLocked` | `resolveDispute(…, upheld=true)` before the dispute seed is locked | + +### 4.7 Batch-assignment seed lock (issue #156 H-2) + +One set per seed, mirroring the dispute-seed errors in §4.6. See [DINTaskCoordinator §6.3](DINTaskCoordinator.md#63-batch-assignment-seed-lock) for the mechanism. | Error | Description | |-------|-------------| @@ -292,7 +367,20 @@ Mirrors the dispute-seed errors' shape (`TC_DisputeSeedNotLocked`/`TC_DisputeSee | `TC_AuditSeedBlockNotMined` | `lockAuditSeed` called before `block.number > auditSeedBlock[_GI]` | | `TC_AuditSeedAlreadyLocked` | `lockAuditSeed` called when `auditSeed[_GI]` is already non-zero | | `TC_AuditSeedNotLocked` | `createAuditorsBatches` called while `auditSeed[_GI] == bytes32(0)` | -| `TA_AuditSeedNotLocked` | `DINTaskAuditor.createAuditorsBatches(uint, bytes32)` independently rejects a zero seed — defense-in-depth on top of the coordinator's own `TC_AuditSeedNotLocked` check, same precedent as M-3 (no single point of trust) | +| `TA_AuditSeedNotLocked` | `DINTaskAuditor.createAuditorsBatches(uint, bytes32)` rejects a zero seed itself, as a second guard behind the coordinator's `TC_AuditSeedNotLocked` check | + +### 4.8 Commit-then-reveal T1/T2 aggregation (`TC_*`, issue #156 M-1) + +The T2 errors are the exact counterparts of the T1 errors. See [DINTaskCoordinator §6.5](DINTaskCoordinator.md#65-aggregation-t1--t2-commit-reveal-finalize). + +| Error | Description | +|-------|-------------| +| `TC_T1RevealCannotBeStarted` / `TC_T2RevealCannotBeStarted` | `startT1AggregationReveal` / `startT2AggregationReveal` called while the state is not `T1AggregationStarted` / `T2AggregationStarted` | +| `TC_T1RevealPhaseNotOpen` / `TC_T2RevealPhaseNotOpen` | `revealT1Aggregation` / `revealT2Aggregation` called outside the reveal state | +| `TC_T1AlreadyCommitted` / `TC_T2AlreadyCommitted` | Second commit by the same aggregator for the batch | +| `TC_T1EmptyCommitHash` / `TC_T2EmptyCommitHash` | `commitHash` is `bytes32(0)` | +| `TC_T1NoCommitFound` / `TC_T2NoCommitFound` | Reveal without a prior commit | +| `TC_T1RevealHashMismatch` / `TC_T2RevealHashMismatch` | `keccak256(abi.encode(cid, salt, msg.sender, GI, TierKind, batchId))` does not match the stored commit hash | --- diff --git a/Documentation/technical/contracts/DINTaskAuditor.md b/Documentation/technical/contracts/DINTaskAuditor.md index 1458fc11..f58f8ee4 100644 --- a/Documentation/technical/contracts/DINTaskAuditor.md +++ b/Documentation/technical/contracts/DINTaskAuditor.md @@ -1,361 +1,220 @@ # DINTaskAuditor — Technical Documentation -> **File:** `foundry/src/DINTaskAuditor.sol` +> **File:** [`foundry/src/DINTaskAuditor.sol`](../../../foundry/src/DINTaskAuditor.sol) > **SPDX-License-Identifier:** UNLICENSED > **Solidity:** `^0.8.28` +> **Deployment:** once per model by the model owner, paired with its `DINTaskCoordinator` (plain `Ownable`, **not** upgradeable) --- ## 1. Overview -`DINTaskAuditor` is the **evaluation and quality-control contract** for each Global Iteration (GI). Its responsibilities: +`DINTaskAuditor` is the evaluation, reward and test-data half of a model's task contracts. It handles: -1. **Auditor registration** — Accept active DIN validators as auditors for a GI. -2. **Local Model Submission (LMS)** — Accept hashed local model submissions from FL clients. -3. **Audit batch formation** — Randomly assign currently-active auditors to batches of submitted models. -4. **Test dataset distribution** — Record each batch's test-data CID and each assigned auditor's individually-encrypted decryption key (task_210726_6 §2b). -5. **Model evaluation** — Commit-then-reveal auditor scoring and eligibility voting (task_210726_6 §2a). -6. **Evaluation finalization** — Compute final median scores and determine which models are approved for aggregation. -7. **Auditor slashing** — Penalise auditors who missed a vote (S1), and, once enabled, auditors whose revealed score deviates too far from the model's median (S3). +- **auditor registration** (active validator, per-model stake floor, concurrent-registration cap, 300-per-GI cap); +- **local model submissions** from clients (one per client per GI, 10 000 per GI); +- **audit batch formation** from the coordinator's locked seed, and encrypted **test-data assignment** (per-auditor encrypted keys, content commitment); +- **commit-then-reveal scoring**: eligibility vote + 0–100 score, median per model, approval against `passScore`; +- **auditor slashing**: S1 (missed vote, partial) and S3 (score deviation, full, behind a switch that is off by default); +- the per-GI **reward pool**: funding, settlement at `endGI`, pull-based claims for clients, auditors and aggregators; +- **test-data disputes**: bonded challenges against the model owner's test data. -The contract is owned by the model owner (OpenZeppelin `Ownable`) and callable by `DINTaskCoordinator` for state-transition operations. +Phase transitions are driven by the paired [`DINTaskCoordinator`](DINTaskCoordinator.md); this contract reads its `GI()` / `GIstate()` and gates each function on them. --- ## 2. Inheritance & Dependencies -| Component | Source | Purpose | -|-----------|--------|---------| -| `Ownable` | OpenZeppelin | Owner-restricted functions (test data assignment, S3 threshold/toggle) | -| `DINShared.sol` | Local | `GIstates` enum, cross-contract interfaces, error declarations | +| Component | Purpose | +|-----------|---------| +| `Ownable` (OpenZeppelin, non-upgradeable) | Model owner: test-data assignment, parameters | +| `ReentrancyGuardTransient` | Guards `claimRewards` and the dispute functions | +| `SafeERC20` / `IERC20` | DIN reward pools, dispute bonds | +| `IBurnableDinToken` (local) | Burns the burn half of forfeited bonds and penalties, and anything that can't be forwarded because no treasury is set | +| `DINShared.sol` | `GIstates`, `IDinValidatorStake`, `IDINTaskCoordinator`, `TA_*` errors | --- -## 3. State Variables - -| Variable | Type | Visibility | Description | -|----------|------|-----------|-------------| -| `dinvalidatorStakeContract` | `IDinValidatorStake` | `public` | Stake contract for auditor activity checks and slashing; also the source of `minStake()` used as the slash amount | -| `dintaskcoordinatorContract` | `IDINTaskCoordinator` | `public` | Coordinator for GI/state reads | -| `totalDepositedRewards` | `uint` | `public` | Accumulated reward deposits (informational only) | -| `MAX_LM_SUBMISSIONS` | `uint` | *(default, internal)* | Hard cap per GI: `10,000` | -| `params` | `Params` | `public` | Per-round tunable parameters | -| `s3DeviationThreshold` | `uint256` | `public` | S3 auditor-deviation threshold, 0–100 scale; default `40` (deliberately wide, not a validated production value — see §6 below) | -| `s3SlashingEnabled` | `bool` | `public` | Whether S3 deviation actually triggers slashing in `slashAuditors`; default `false` (shadow mode) | -| `dinAuditors` | `mapping(uint => address[])` | `public` | Registered auditors per GI | -| `isRegisteredAuditor` | `mapping(uint => mapping(address => bool))` | `public` | Auditor registration membership | -| `lmSubmissions` | `mapping(uint => LMSubmission[])` | `public` | All submitted local models per GI | -| `clientHasSubmitted` | `mapping(uint => mapping(address => bool))` | `public` | One-submission-per-client guard | -| `clientSubmissionIndex` | `mapping(uint => mapping(address => uint))` | `public` | Client address → submission index | -| `auditBatches` | `mapping(uint256 => AuditBatch[])` | `public` | Formed audit batches per GI | -| `isBatchAuditor` | `mapping(uint => mapping(uint => mapping(address => bool)))` | `public` | GI → batchId → auditor → assigned | -| `isBatchModelIndex` | `mapping(uint => mapping(uint => mapping(uint => bool)))` | `public` | GI → batchId → modelIndex → assigned | -| `auditScores` | 4-level mapping | `public` | GI → batchId → auditor → modelIndex → revealed score | -| `LMeligibleVote` | 4-level mapping | `public` | GI → batchId → auditor → modelIndex → revealed eligibility vote | -| `hasAuditedLM` | 4-level mapping | `public` | GI → batchId → auditor → modelIndex → has revealed (single source of truth for quorum/median counting) | -| `auditScoreCommits` | 4-level mapping | `public` | GI → batchId → auditor → modelIndex → `keccak256(score, vote, salt)` commit hash | -| `hasCommittedLM` | 4-level mapping | `public` | GI → batchId → auditor → modelIndex → has committed (distinct from `hasAuditedLM` — see §9.1) | -| `encryptedTestDataKey` | `mapping(uint256 => mapping(uint => mapping(address => bytes)))` | `public` | GI → batchId → auditor → that auditor's individually-encrypted copy of the test-data decryption key | -| `Is_testdataCIDs_Assigned` | `mapping(uint256 => bool)` | `public` | Whether test datasets are assigned for a GI | - -> **Note:** there is no `minStake` state variable on this contract (auditor registration and slashing both go through `dinvalidatorStakeContract` — `isValidatorActive()` for registration/commit/reveal, `minStake()` for the slash amount — not a locally-cached threshold). +## 3. State ---- - -## 4. Data Structures - -### 4.1 `LMSubmission` - -```solidity -struct LMSubmission { - address client; // Submitting FL client - bytes32 modelCID; // IPFS CID hash of the local model - uint40 submittedAt; // Block timestamp - bool eligible; // Passed basic conformance check (majority vote) - bool evaluated; // Score quorum reached and finalMedianScore computed - bool approved; // eligible == true AND finalMedianScore >= passScore - uint256 finalMedianScore; // 0-100, canonical per-model score for both S3 slashing and the reward basis -} -``` - -### 4.2 `Params` (default values) - -| Parameter | Default | Spec Target | -|-----------|---------|-------------| -| `auditorsPerBatch` | 3 | 10 | -| `modelsPerBatch` | 3 | 100 | -| `minEligibilityQuorum` | 2 | 7 | -| `minScoreQuorum` | 2 | 7 | -| `passScore` | 50 | 50 | -| `MIN_MODELS_PER_BATCH` | 2 | — | - -### 4.3 `AuditBatch` - -```solidity -struct AuditBatch { - uint batchId; // Sequential batch index within a GI - address[] auditors; // Assigned auditors - uint[] modelIndexes; // Indexes into lmSubmissions[GI] - bytes32 testDataCID; // IPFS CID of test dataset for this batch -} -``` +### 3.1 Wiring ---- +| Variable | Description | +|----------|-------------| +| `dinvalidatorStakeContract` | `DinValidatorStake` proxy (constructor) | +| `dintaskcoordinatorContract` | Paired coordinator (constructor) | +| `modelId` (`immutable`) | Registry model ID for the stake floor (constructor) | +| `dinToken` | DIN token (`setDinToken`); required before `depositRewards`, claims or bonded disputes | -## 5. Access Control +There is no treasury address on this contract. The treasury share of rewards and the treasury half of forfeitures go to `dinvalidatorStakeContract.slashTreasury()`, the platform treasury configured on `DinValidatorStake`. -``` -Ownable (model owner) - ├── assignAuditTestDataset() - ├── setS3DeviationThreshold() - └── setS3SlashingEnabled() +### 3.2 `Params` (constructor defaults — no setter except `passScore`) -onlyTaskCoordinator (DINTaskCoordinator only) - ├── createAuditorsBatches() - ├── setTestDataAssignedFlag() - ├── finalizeEvaluation() - ├── slashAuditors() - └── updatePassScore() +| Field | Default | Meaning | +|-------|---------|---------| +| `auditorsPerBatch` | 3 | Auditors per batch | +| `modelsPerBatch` | 3 | Target models per batch | +| `MIN_MODELS_PER_BATCH` | 2 | Smallest allowed final batch | +| `minEligibilityQuorum` | 2 | Votes needed to decide eligibility, and "yes" votes needed to be eligible | +| `minScoreQuorum` | 2 | Revealed scores needed to compute a median | +| `passScore` | 50 | Minimum median for approval; updated via the coordinator's `startGI(gi, score)` | -onlyAssignedAuditor + onlyCurrentGI - ├── commitAuditScore() - └── revealAuditScore() +Constants: `MAX_REGISTERED_AUDITORS = 300`; `MAX_LM_SUBMISSIONS = 10000` (a plain storage variable, not `constant`). -Permissionless (within GI/state guards) - ├── registerDINAuditor() - └── submitLocalModel() -``` +### 3.3 Governable parameters (owner-settable) ---- +| Variable | Default | Setter | +|----------|---------|--------| +| `rewardSplit` | clients 60% / auditors 20% / aggregators 15% / treasury 5% | `setRewardSplit` (must sum to 10 000 bps) | +| `s1SlashFractionBps` | 3000 (30%) | `setS1SlashFractionBps` (≤ 10 000) | +| `s3DeviationThreshold` | 40 (on the 0–100 scale) | `setS3DeviationThreshold` (≤ 100) | +| `s3SlashingEnabled` | `false` (shadow mode) | `setS3SlashingEnabled` | +| `disputeBondAmount` | 0 | `setDisputeBondAmount` | +| `disputeWindowBlocks` | 7200 (~1 day on Optimism) | `setDisputeWindowBlocks` | +| `disputePenaltyBps` | 2500 (25% of the GI pool) | `setDisputePenaltyBps` (≤ 10 000) | -## 6. Auditor Registration +The reward split and the S1 fraction are explicitly provisional (MECHANISM_DESIGN §5; issue #155). -```solidity -function registerDINAuditor(uint _GI) public onlyCurrentGI(_GI) -``` +### 3.4 Per-GI data -1. Check `GIstate == DINauditorsRegistrationStarted`. -2. Check not already registered. -3. Check `dinvalidatorStakeContract.isValidatorActive(msg.sender)`. -4. Push to `dinAuditors[_GI]`, set membership flag. -5. Emit `DINAuditorRegistered`. +- **Registration:** `dinAuditors[gi]`, `isRegisteredAuditor[gi][addr]`. +- **Submissions:** `lmSubmissions[gi]` (`LMSubmission`: client, modelCID, submittedAt, eligible, evaluated, approved, finalMedianScore), `clientHasSubmitted`, `clientSubmissionIndex`. +- **Batches:** `auditBatches[gi]` (`AuditBatch`: batchId, auditors, modelIndexes, testDataCID), `isBatchAuditor`, `isBatchModelIndex`. +- **Scoring:** `auditScoreCommits`, `hasCommittedLM`, `auditScores`, `LMeligibleVote`, `hasAuditedLM` (all keyed `[gi][batchId][auditor][modelIndex]`). +- **Test data:** `encryptedTestDataKey[gi][batchId][auditor]`, `testDataCommitments[gi][batchId]`, `Is_testdataCIDs_Assigned[gi]`, `testDataDisputes[gi][batchId]`. +- **Rewards:** `giRewardPool[gi]`, `giRewardSnapshot[gi]`, `giTotalApprovedScore[gi]`, `giTotalAuditWeight[gi]`, `auditorGIWeight[gi][addr]`, `rewardClaimed[gi][addr]`, `claimable[addr]`, `treasuryAccrued`. --- -## 7. Local Model Submission +## 4. Access Control -```solidity -function submitLocalModel(bytes32 _clientModel, uint _GI) public onlyCurrentGI(_GI) -``` - -**Privacy:** Only the `bytes32` IPFS hash is stored on-chain. Actual weights remain off-chain. - -1. Check `GIstate == LMSstarted`. -2. One-per-client guard (`clientHasSubmitted`). -3. Enforce `MAX_LM_SUBMISSIONS` cap. -4. Push `LMSubmission` (all flags false, scores zero). -5. Record `clientSubmissionIndex`. +| Caller | Functions | +|--------|-----------| +| Paired coordinator (`onlyTaskCoordinator`) | `updatePassScore`, `createAuditorsBatches`, `setTestDataAssignedFlag`, `finalizeEvaluation`, `slashAuditors`, `settleRewards`, `decrementAuditorRegistrations` | +| `owner()` (model owner) | `assignAuditTestDataset`, `reassignAuditTestDataset`, all setters | +| Assigned auditor (`onlyAssignedAuditor`) | `commitAuditScore`, `revealAuditScore` | +| Any active validator | `registerDINAuditor` | +| Any address | `submitLocalModel`, `depositRewards`, `claimReward`, `claimRewards`, `openTestDataDispute`, `resolveTestDataDispute`, `closeExpiredDispute`, views | --- -## 8. Audit Batch Formation - -### `createAuditorsBatches(uint _GI, bytes32 seed)` - -Called by `DINTaskCoordinator` after LM submission closes (`GIstate == LMSclosed`). `seed` is the coordinator's locked, ungrindable `auditSeed[_GI]` (issue #156 H-2, task_240926_18 Part B) — see `DINTaskCoordinator.md`'s `lockAuditSeed` section for how it's produced. Reverts with `TA_AuditSeedNotLocked` if `seed == bytes32(0)`, independent defense-in-depth on top of the coordinator's own check before it calls in (same precedent as M-3). +## 5. Registration & Submissions -**Algorithm:** - -1. Filter the historical registration list `dinAuditors[_GI]` down to auditors still `isValidatorActive` at call time (`_activeAuditorPool`). Revert (`TA_NotEnoughAuditors`) if fewer than `params.auditorsPerBatch` remain active. -2. Shuffle the active pool (Fisher-Yates, `pure`) using `keccak256(seed, "AUD_ADDR")` as entropy. -3. Build `uint[]` of model indexes `[0..N-1]`. Shuffle (memory, `pure`) using `keccak256(seed, "AUD_IDX")`. -4. Greedy batch formation: - ``` - while vPtr + auditorsPerBatch <= aLen - AND (enough models remain for a full or partial-but-minimum batch): - Create AuditBatch: - auditors = auditorPool[vPtr .. vPtr+auditorsPerBatch-1] - modelsToAssign = min(modelsPerBatch, remaining models) - modelIndexes = modelIdx[mPtr .. mPtr+modelsToAssign-1] - vPtr += auditorsPerBatch - mPtr += modelsToAssign - ``` -5. Emit `AuditorsBatchesCreated`. - -> **Residual trust note:** the seed is derived from `blockhash(seedBlock)`, so on OP Stack this still trusts the sequencer not to grind — same caveat as the dispute seed (`DINTaskCoordinator.md`'s `lockDisputeSeed`). Two narrower gaps remain even with the seed locked: the model owner can decline to lock a seed they don't like and let it re-anchor (repeatable stalling, capped at roughly one re-roll per ~256-block window), and a validator can still reshape the active pool by unstaking *after* the seed is locked but *before* `createAuditorsBatches` is called, since `_activeAuditorPool` is evaluated at call time. Tracked as BL-26 in [`Developer/BACK_LOG.md`](../../../Developer/BACK_LOG.md) (residuals of issue #156, from the PR #191 review). VRF remains the mainnet-grade follow-up (issue #178). +- **`registerDINAuditor(gi)`** — requires the coordinator state `DINauditorsRegistrationStarted`. Checks: not already registered, fewer than 300 registered (`TA_RegistrationCapReached`), `isValidatorActive` (`TA_AuditorNotActive`), stake ≥ the model floor (`TA_StakeBelowModelFloor`), and the concurrent-registration cap (`TA_ConcurrentRegistrationCapReached`). Then calls `incrementActiveRegistration` and emits `DINAuditorRegistered`. +- **`submitLocalModel(cid, gi)`** — state `LMSstarted`; one submission per address per GI; up to 10 000 per GI. No stake or registration needed. --- -## 9. Evaluation Mechanism - -Auditor scoring is **commit-then-reveal** (task_210726_6 §2a), split across two GI states: `LMSevaluationStarted` (commit phase) and `LMSevaluationRevealStarted` (reveal phase, opened by `DINTaskCoordinator.startLMsubmissionsEvaluationReveal`). This hides each auditor's score/vote from the others until every commit is in, preventing later auditors from anchoring on earlier ones' revealed scores. - -### 9.1 Commit Phase — `commitAuditScore` - -```solidity -function commitAuditScore( - uint256 gi, uint batchId, uint modelIndex, bytes32 commitHash -) external onlyAssignedAuditor(gi, batchId, modelIndex) onlyCurrentGI(gi) -``` - -`commitHash` must equal `keccak256(abi.encodePacked(score, vote, salt))` for the values the auditor intends to reveal later; the contract cannot and does not validate this at commit time. - -1. Check `GIstate == LMSevaluationStarted`. -2. Check `dinvalidatorStakeContract.isValidatorActive(msg.sender)`. -3. Check `commitHash != bytes32(0)`. -4. One-commit-per-model guard (`hasCommittedLM`). -5. Record the commit hash, set `hasCommittedLM`, emit `AuditScoreCommitted`. +## 6. Batches & Test Data -### 9.2 Reveal Phase — `revealAuditScore` +- **`createAuditorsBatches(gi, seed)`** (from the coordinator, state `LMSclosed`) — `seed` is the coordinator's locked `auditSeed[gi]` (see [DINTaskCoordinator §6.3](DINTaskCoordinator.md#63-batch-assignment-seed-lock)). A zero seed reverts with `TA_AuditSeedNotLocked`; the coordinator checks this too, so this is a second guard. The function then: + 1. filters `dinAuditors[gi]` to auditors still `isValidatorActive` at call time (`TA_NotEnoughAuditors` if fewer than `auditorsPerBatch` remain); + 2. shuffles the active auditors with `keccak256(seed, "AUD_ADDR")` and the model indexes with `keccak256(seed, "AUD_IDX")` (Fisher-Yates, in memory); + 3. greedily forms batches of 3 auditors × 3 models (the last batch may take 2). Leftover auditors and models are unused. -```solidity -function revealAuditScore( - uint256 gi, uint batchId, uint modelIndex, uint256 score, bool vote, bytes32 salt -) external onlyAssignedAuditor(gi, batchId, modelIndex) onlyCurrentGI(gi) -``` + Emits `AuditorsBatchAuto` per batch and `AuditorsBatchesCreated`. The seed comes from a block hash, so the residual trust assumptions in the coordinator doc apply here as well; one of them is specific to this function: a validator can still change the active pool by unstaking after the seed is locked and before this call. +- **`assignAuditTestDataset(gi, batchId, testDataCID, encryptedKeys[], commitment)`** (owner) — stores the encrypted test-data CID (`AES-256-GCM(K, rawCID ‖ Sign(ownerSK, rawCID))`) and one encrypted copy of `K` per auditor, in the batch's auditor order. Every auditor must have an X25519 key on `DinValidatorStake`. Also stores `commitment = keccak256(gi, batchId, keccak256(K), keccak256(plaintext))`. Blocked while the batch awaits reassignment. +- **`setTestDataAssignedFlag(gi, true)`** (from the coordinator, state `AuditorsBatchesCreated`) — one-shot flag. Informational only: nothing checks it before scoring starts. -1. Check `GIstate == LMSevaluationRevealStarted`. -2. Check `dinvalidatorStakeContract.isValidatorActive(msg.sender)`. -3. Check `score <= 100`. -4. Check a commit exists (`hasCommittedLM`) and hasn't already been revealed (`hasAuditedLM`). -5. Recompute `keccak256(abi.encodePacked(score, vote, salt))` and check it matches the stored commit hash. -6. Record score and eligibility vote, set `hasAuditedLM`, emit `AuditScoreSubmitted` + `EligibilityVoted`. -7. Call `_tryFinalizeEligibility()` eagerly. - -`hasCommittedLM` and `hasAuditedLM` are deliberately distinct: an auditor who commits but never reveals ends up with `hasCommittedLM=true, hasAuditedLM=false`, so they're excluded from quorum/median counting exactly like a non-participant, and remain slashable via `slashAuditors`' "missed vote" check — no special-casing needed for the non-reveal case. - -### 9.3 Eligibility Finalization (`_tryFinalizeEligibility`) - -Internal; triggered after each reveal. - -``` -Count yesVotes and totalVotes for the model across batch auditors (via hasAuditedLM). -If totalVotes < minEligibilityQuorum → wait. -majorityEligible = (yesVotes >= minEligibilityQuorum) -Set submission.eligible = majorityEligible. -``` - -### 9.4 Evaluation Finalization - -```solidity -function finalizeEvaluation(uint _GI) public onlyTaskCoordinator returns (bool) -``` +--- -Called by the coordinator to close the reveal phase (`GIstate` must be `LMSevaluationRevealStarted`, else `TA_CannotFinalizeEvaluation`): +## 7. Commit-then-Reveal Scoring -``` -For each batch: - For each model in batch: - Re-attempt eligibility finalization if not yet eligible. - Collect scores from auditors who actually revealed (hasAuditedLM). - If revealed-vote count >= minScoreQuorum: - sub.finalMedianScore = _medianOf(votedScores) // MEDIAN, not mean — task_210726_6 §1c - sub.evaluated = true - sub.approved = (sub.eligible AND finalMedianScore >= params.passScore) - Emit AuditorScoreDeviation for every revealed voter (S3 shadow mode, §9.5) -Return true if finalizedCount > 0. -``` +1. **Commit** (`LMSevaluationStarted`): `commitAuditScore(gi, batchId, modelIndex, commitHash)` with `commitHash = keccak256(abi.encodePacked(score, vote, salt))`. The caller must be an assigned, active auditor; one commit each; a zero hash is rejected. +2. **Reveal** (`LMSevaluationRevealStarted`): `revealAuditScore(gi, batchId, modelIndex, score, vote, salt)`. Checks the auditor is active, `score ≤ 100`, a commit exists, no prior reveal, and the hash matches. Records the score and vote, sets `hasAuditedLM`, increments `auditorGIWeight` / `giTotalAuditWeight` (the auditor reward basis), and tries to finalize eligibility. +3. **Eligibility** (`_tryFinalizeEligibility`): once revealed votes ≥ `minEligibilityQuorum`, `eligible = (yesVotes ≥ minEligibilityQuorum)`. With the defaults that means 2 "yes" votes out of 3. +4. **`finalizeEvaluation(gi)`** (from the coordinator's `closeLMsubmissionsEvaluation`, still in the reveal state): for each batch model with ≥ `minScoreQuorum` revealed scores, `finalMedianScore = median`, `evaluated = true`, `approved = eligible && median ≥ passScore`. The first time a model is approved, its median is added to `giTotalApprovedScore` (the client reward basis). Emits `AuditorScoreDeviation` for every revealing auditor (S3 shadow data). Returns `true` if at least one model reached quorum. +5. **`approvedModelIndexes(gi)`** feeds the coordinator's T1/T2 batch formation. -The score is the **median** across the auditor batch, not a mean — a mean lets a single dishonest auditor drag the score arbitrarily far, while a median tolerates fewer than 50% dishonest auditors (BlockFlow Algorithm 1). `_medianOf` sorts in place; even-length batches median as the integer-divided average of the two middle elements. +An auditor who commits but never reveals is treated as a non-voter: excluded from quorum and median, and slashed as a missed vote (§8). -**Approval condition (both must hold):** -- `eligible == true` (majority voted conformant) -- `finalMedianScore >= passScore` +--- -### 9.5 S3 Deviation Tracking (shadow mode) +## 8. Auditor Slashing — `slashAuditors(gi)` -For every model that reaches score quorum, `finalizeEvaluation` also emits `AuditorScoreDeviation(gi, batchId, modelIndex, auditor, auditorScore, medianScore, deviation, exceedsThreshold)` for each auditor who revealed a score on it, where `deviation = |auditorScore - medianScore|` and `exceedsThreshold = deviation > s3DeviationThreshold`. This is purely observational unless `s3SlashingEnabled` is turned on (see §10) — per `MECHANISM_DESIGN.md` §6, the threshold (default `40`) must be empirically validated against real audit-score variance before it's allowed to cost auditors stake. The owner tunes it via `setS3DeviationThreshold`/`setS3SlashingEnabled`. +From the coordinator in `T2AggregationDone`. For each batch auditor: -### 9.6 `approvedModelIndexes` +- **S1 — missed vote** on any batch model: `slashPartial(auditor, minStake × s1SlashFractionBps / 10 000, "AUD_NO_VOTE", gi)` (partial, with S5 recidivism escalation). +- **Otherwise, S3 — deviation**, only when `s3SlashingEnabled`: if any of their scores deviates from that model's median by more than `s3DeviationThreshold`, `slash(auditor, minStake, "AUD_SCORE_DEVIATION")`. -Returns compact array of `lmSubmissions[_GI]` indexes where `approved == true`. Used by `DINTaskCoordinator` for T1/T2 batch formation. +At most one slash per auditor per batch; S1 takes priority. Emits `AuditorSlashed(gi, batchId, auditor, reason, requested, actual)`. --- -## 10. Auditor Slashing - -```solidity -function slashAuditors(uint _GI) external onlyTaskCoordinator onlyCurrentGI(_GI) returns (bool) -``` +## 9. Rewards -Called by the coordinator once `GIstate == T2AggregationDone`. For every auditor in every batch: +| Step | Function | Notes | +|------|----------|-------| +| Fund | `depositRewards(gi, amount)` — anyone | `gi` must be ≥ the current GI and non-zero; pulls DIN. The coordinator refuses to `startGI` an unfunded GI. | +| Settle | `settleRewards(gi, aggregatorTotalWeight)` — coordinator's `endGI` | Splits `giRewardPool[gi]` by `rewardSplit`. The treasury share (plus rounding dust) is forwarded in full to `slashTreasury()`, or burned if no treasury is set. Stores a snapshot. No participant loops. | +| Credit | `claimReward(gi)` — each participant, once per GI | Client: `clientPool × finalMedianScore / giTotalApprovedScore` (approved submissions only). Auditor: `auditorPool × auditorGIWeight / giTotalAuditWeight` (one unit per revealed vote). Aggregator: `aggregatorPool × aggregatorWeight / aggregatorTotalWeight` (read from the coordinator). Reverts `TA_NoRewardEarned` if all three are zero. | +| Withdraw | `claimRewards()` | Pull payment of the accumulated `claimable` balance. | -- **S1 — missed vote:** if the auditor never revealed (`hasAuditedLM == false`) for *any* model assigned to their batch, slash with reason `AUD_NO_VOTE`. Takes priority over S3 — an auditor who missed a vote is not also re-evaluated for S3 in the same batch. -- **S3 — score deviation:** only checked when `s3SlashingEnabled == true` and the auditor revealed on every model in the batch. If any revealed score deviated from that model's `finalMedianScore` by more than `s3DeviationThreshold`, slash with reason `AUD_SCORE_DEVIATION` (at most once per batch, even if multiple votes deviated). - -Slash amount is `dinvalidatorStakeContract.minStake()` at call time for both reasons (see `MECHANISM_DESIGN.md` §9 item 2 on the still-open flat-vs-partial slash-fraction question). Each slashed auditor emits `AuditorSlashed(gi, batchId, auditor, reason, requested, actual)`. Always returns `true`; individual slash failures do not halt the loop. +`DinEmission.fundGI` funds pools through the same `depositRewards` path, using freshly minted emission DIN. --- -## 11. Test Data Assignment +## 10. Test-Data Disputes + +Lets a batch auditor challenge the model owner's test data. -```solidity -function assignAuditTestDataset( - uint256 gi, uint256 batchId, bytes32 testDataCID, bytes[] calldata encryptedKeys -) external onlyOwner onlyCurrentGI(gi) -``` +| Step | Who | Effect | +|------|-----|--------| +| `isEncryptionKeyEmpty(gi, batchId, auditor)` | View | Free check: an auditor who received no key has grounds to dispute | +| `openTestDataDispute(gi, batchId)` | Anyone | Needs a stored commitment; pulls `disputeBondAmount` DIN (0 by default); window = `disputeWindowBlocks` | +| `resolveTestDataDispute(gi, batchId, K, plaintextHash)` | **Anyone**, within the window | Recomputes the commitment. **Match →** dispute false: bond forfeited. **Mismatch →** upheld: bond returned; `disputePenaltyBps` of `giRewardPool[gi]` removed as a penalty; batch marked `pendingReassignment` | +| `closeExpiredDispute(gi, batchId)` | Anyone, after the window | Bond forfeited as above | +| `reassignAuditTestDataset(…)` | Owner | New CID, keys and commitment for a batch pending reassignment | -Owner-only. Records the test dataset IPFS CID for a batch, and — per task_210726_6 §2b / whitepaper §5.2.3b — each assigned auditor's individually-encrypted copy of the test-data decryption key. `encryptedKeys[i]` must correspond to `auditBatches[gi][batchId].auditors[i]` (same order `createAuditorsBatches` populated them in); reverts with `TA_EncryptedKeyCountMismatch` if the lengths differ. The model owner is responsible for producing `encryptedKeys` off-chain (a symmetric test-data key encrypted to each auditor's public key) — the contract only stores what it's given and cannot validate the encryption itself. Emits `EncryptedTestDataKeysAssigned`. +Forfeited bonds and penalties are split 50% burned / 50% forwarded to `slashTreasury()`; both halves are burned if no treasury is set. `treasuryAccrued` is a running counter of everything routed out this way (including the burned part). No tokens are held against it. -**`setTestDataAssignedFlag`** (coordinator only): Sets `Is_testdataCIDs_Assigned[_GI] = true` once all datasets are assigned. One-time per GI. +The dispute is not bound to the caller (anyone can open one), and any caller can trigger the "upheld" branch with a wrong `K` (§13 No. 1). --- -## 12. Privacy Architecture +## 11. What Is On-Chain | Data | On-chain | Off-chain | |------|---------|-----------| | Model weights | ❌ | ✅ IPFS (`modelCID`) | -| Test dataset | ❌ | ✅ IPFS (`testDataCID`) | -| Test-data decryption key (per-auditor) | ✅ (encrypted `bytes`, `encryptedTestDataKey`) | key material itself only ever exists off-chain, decrypted client-side by the auditor | -| Submission record (client, CID) | ✅ | — | -| Audit score / eligibility vote before reveal | ❌ (only the commit hash, `auditScoreCommits`) | actual `(score, vote, salt)` known only to the committing auditor | -| Audit score / eligibility vote after reveal | ✅ | — | -| Final approval | ✅ | — | +| Test dataset | ❌ (only the encrypted CID) | ✅ IPFS | +| Test-data key `K` | Only per-auditor encrypted copies (`encryptedTestDataKey`) | Decrypted client-side by each auditor with their X25519 key | +| Test-data content | Only the commitment `keccak256(gi, batchId, keccak256(K), keccak256(plaintext))` | — | +| Score / vote before reveal | Only the commit hash | `(score, vote, salt)` known to the auditor | +| Score / vote after reveal, final approval, rewards | ✅ | — | --- -## 13. Events - -| Event | Emitted When | -|-------|--------------| -| `DINAuditorRegistered(GI, auditor)` | Auditor registers | -| `AuditScoreCommitted(gi, batchId, auditor, modelIndex, commitHash)` | Commit phase: score/vote hash committed | -| `AuditScoreSubmitted(gi, batchId, auditor, modelIndex, score)` | Reveal phase: score revealed | -| `EligibilityVoted(gi, batchId, modelIndex, auditor, vote)` | Reveal phase: eligibility vote revealed | -| `EligibilityFinalized(gi, batchId, modelIndex, eligible, totalVotes)` | Eligibility quorum reached | -| `EncryptedTestDataKeysAssigned(gi, batchId, testDataCID, auditorCount)` | Test dataset + per-auditor keys assigned to a batch | -| `AuditorsBatchAuto(GI, batchId)` | Individual batch created | -| `AuditorsBatchesCreated(GI, batchCount)` | All batches created | -| `PassScoreUpdated(oldScore, newScore)` | Pass score changed | -| `S3DeviationThresholdUpdated(oldThreshold, newThreshold)` | S3 threshold changed | -| `S3SlashingEnabledUpdated(oldValue, newValue)` | S3 shadow mode toggled | -| `AuditorScoreDeviation(gi, batchId, modelIndex, auditor, auditorScore, medianScore, deviation, exceedsThreshold)` | Emitted for every revealed voter on a finalized model (S3 shadow mode, always emitted regardless of `s3SlashingEnabled`) | -| `AuditorSlashed(gi, batchId, auditor, reason, requested, actual)` | Auditor slashed (S1 or S3) | +## 12. Events + +Registration & data: `DINAuditorRegistered`, `LocalModelSubmitted`, `AuditorsBatchAuto`, `AuditorsBatchesCreated`, `EncryptedTestDataKeysAssigned`, `TestDataCommitmentStored`. Scoring: `AuditScoreCommitted`, `AuditScoreSubmitted`, `EligibilityVoted`, `EligibilityFinalized`, `AuditorScoreDeviation`, `PassScoreUpdated`. Slashing: `AuditorSlashed`. Rewards: `RewardDeposited`, `RewardsSettled`, `RewardsClaimed`, `DinTokenSet`, `RewardSplitUpdated`. Parameters: `S1SlashFractionBpsUpdated`, `S3DeviationThresholdUpdated`, `S3SlashingEnabledUpdated`. Disputes: `TestDataDisputeOpened`, `TestDataDisputeResolvedFalse`, `TestDataDisputeUpheld`, `BatchPendingReassignment`, `DisputeExpired`. --- -## 14. Security Considerations +## 13. Review Notes & Open Caveats + +Read alongside the [foundry/src security review](../audits/foundry-src-security-review.md). -| Risk | Mitigation | -|------|-----------| -| Weak PRNG for shuffling | Acceptable for devnet; use Chainlink VRF in production | -| Colluding/copying auditors | Commit-then-reveal (§9.1–9.2) prevents an auditor from anchoring their score on others' already-revealed votes | -| Sybil auditor registration | Gated by `isValidatorActive` (DinValidatorStake), not a raw stake threshold on this contract | -| Double voting / double committing | `hasCommittedLM` and `hasAuditedLM` each prevent a repeat | -| Reveal without a matching commit | `TA_NoCommitFound` / `TA_RevealHashMismatch` reject reveals that don't match a prior commit | -| Batch flooding | `MAX_LM_SUBMISSIONS` cap at 10,000 | -| Gas exhaustion in `finalizeEvaluation` / `slashAuditors` | O(batches × models × auditors) — could hit limits at scale | -| Untuned S3 threshold slashing honest variance | Defaults to shadow mode (`s3SlashingEnabled = false`); must be empirically validated before enabling (`MECHANISM_DESIGN.md` §6) | -| Model owner supplies invalid/wrong-recipient encrypted test-data keys | Not detectable on-chain — `assignAuditTestDataset` stores whatever `encryptedKeys` it's given; no on-chain dispute mechanism exists yet for this (see `Developer/tasks/task_240826_10.md`) | +- **No. 1 — Test-data disputes can be won by the challenger alone.** `resolveTestDataDispute` is callable by anyone, and any commitment *mismatch* upholds the dispute. A challenger can call it with an arbitrary `K` and win: bond back, the model owner's GI pool cut by `disputePenaltyBps`, and the batch blocked until reassignment. Only the model owner revealing the real `K` should be able to reach the "match" branch, and a mismatch from a non-owner caller should not count as evidence. +- **No. 2 — Commit hashes are not bound to the auditor.** `keccak256(score, vote, salt)` carries no address, GI, batch or model. An auditor in the same batch can copy another's commit hash, wait for their reveal, and replay it. Tracked in issue No. 192. (The aggregation commits on the coordinator do bind `msg.sender`.) +- **No. 3 — Committed-but-unrevealed is slashed as a liveness miss.** An auditor who commits and then withholds the reveal pays the S1 fraction (`AUD_NO_VOTE`, 30% of `minStake` by default). That is less than the full-`minStake` S3 slash a revealed outlier would pay once `s3SlashingEnabled` is on, so an auditor who sees they will be in the minority can choose not to reveal. Whether this case gets its own reason code and fraction is open in issue No. 201 (Part B). +- **No. 4 — Unclaimable remainders.** If no model is approved (`giTotalApprovedScore == 0`), or nobody reveals, or no aggregator weight exists, that role's pool share stays in the contract with no reclaim path. +- **No. 5 — `setTestDataAssignedFlag` gates nothing:** scoring can open before any test data is assigned. +- **No. 6 — Stale NatSpec and reused errors:** `slashAuditors` says S1 and S3 are both `minStake()` (S1 is now a fraction). Several comments call parameters "DAO-settable"; they are `onlyOwner`, i.e. set by the model owner. `setDisputePenaltyBps` reuses `TA_InvalidDisputeBond`, and `closeExpiredDispute` reuses `TA_DisputeWindowClosed` for "window still open". +- **No. 7 — `modelId` is fixed at construction**, before the registry assigns it (see [DINTaskCoordinator §10 No. 4](DINTaskCoordinator.md#10-review-notes--open-caveats)). +- **No. 8 — dincli lags this contract:** `dincli model-owner deploy task-auditor` still calls the older two-argument constructor (no `modelId`). +- **No. 9 — dincli auditor commit retry can lose the committed salt.** Rerunning `dincli auditor lms-evaluation evaluate --submit` generates a new salt and overwrites the local commit cache even when the on-chain commit already exists. The later reveal then fails the hash check and the auditor is slashed for a missed vote. Tracked in issue No. 202 (Part 1); until it is fixed, don't rerun the command for a GI that already has commits. --- -## 15. Known Limitations & Future Work +## 14. Change Log + +### P3 (foundry) -- Reward distribution to auditors not implemented (`totalDepositedRewards` is tracked but unused). -- `params` struct is immutable post-deployment except `passScore` (updatable via `updatePassScore`). -- Models not reaching score quorum are silently left unapproved with no alerting. -- No mechanism to re-open evaluation if quorum is not reached before `finalizeEvaluation` is called. -- No on-chain dispute resolution if a model owner distributes an incorrect or wrong-recipient `encryptedTestDataKey` — an auditor who can't decrypt the test data currently has no on-chain recourse. Tracked in `Developer/tasks/task_240826_10.md`. -- `s3DeviationThreshold`'s default (`40`) is a deliberately wide placeholder, not a tuned production value. +- Commit-then-reveal scoring (`commitAuditScore` / `revealAuditScore`), replacing `setAuditScorenEligibility`; median-based scores. +- Per-GI reward pools with O(1) settlement snapshots and pull-based claims (BL-10), replacing `totalDepositedRewards`. +- Encrypted per-auditor test-data keys, content commitments, test-data disputes and reassignment. +- S1 partial slashing via `slashPartial`; S3 deviation slashing behind `s3SlashingEnabled`. +- Registration caps and floors, the active-registration counter, and the `modelId` constructor argument. +- Treasury shares and forfeitures forwarded to the platform treasury (`slashTreasury()`), replacing the per-contract treasury address (issue No. 152). +- `createAuditorsBatches` takes the coordinator's locked audit seed (issue No. 156 H-2, PR No. 191). diff --git a/Documentation/technical/contracts/DINTaskCoordinator.md b/Documentation/technical/contracts/DINTaskCoordinator.md index f70f32ee..ec1efdb5 100644 --- a/Documentation/technical/contracts/DINTaskCoordinator.md +++ b/Documentation/technical/contracts/DINTaskCoordinator.md @@ -1,464 +1,298 @@ # DINTaskCoordinator — Technical Documentation -> **File:** `foundry/src/DINTaskCoordinator.sol` +> **File:** [`foundry/src/DINTaskCoordinator.sol`](../../../foundry/src/DINTaskCoordinator.sol) > **SPDX-License-Identifier:** UNLICENSED > **Solidity:** `^0.8.28` +> **Deployment:** once per model by the model owner (plain `Ownable`, **not** upgradeable) --- ## 1. Overview -`DINTaskCoordinator` is the **central orchestration contract** for the DIN Protocol's federated learning workflow. It is the single entity that drives the 24-state Global Iteration (GI) lifecycle, coordinates between aggregators and the auditor contract, and executes slashing of misbehaving participants. +`DINTaskCoordinator` orchestrates the **Global Iteration (GI)** lifecycle of one federated-learning model. The model owner drives every phase transition; validators act only inside the phase that is open. It owns: -Responsibilities: -- **State machine management** — Advance `GIstate` through the full GI lifecycle (see `DINShared.md` §2 for the full `GIstates` table, including the commit-then-reveal evaluation phases). -- **Aggregator registration** — Accept active validators as aggregators for a GI. -- **Tier-1 and Tier-2 batch creation** — Form aggregation batches from approved local models. -- **Aggregation result collection** — Accept and vote on aggregated model CIDs. -- **Slashing** — Trigger auditor slashing on the paired `DINTaskAuditor` (see `DINTaskAuditor.md` §10 for the actual S1/S3 logic), and directly slash aggregators that failed to participate or submitted a non-consensus CID. +- the one-time **setup sequence** (pair with `DINTaskAuditor`, confirm both are slashers, record the genesis model); +- **aggregator registration** (active-validator, per-model stake floor, concurrent-registration cap, 300-per-GI cap); +- the **batch-assignment seeds** for auditor batches and T1/T2 batches (future-block hash, locked by anyone); +- **Tier-1 / Tier-2 batch formation** and commit-then-reveal, majority-CID **aggregation** with a submission quorum; +- **aggregator slashing** (S2 partial for no submission, full for bad consensus); +- per-aggregator **reward weights**, handed to `DINTaskAuditor.settleRewards` at `endGI`; +- the **aggregation dispute** scaffold (S4): bonded challenges, fresh-subgroup reassignment, owner adjudication. -The contract is owned by the model owner (via `Ownable`) and delegates auditor operations to `DINTaskAuditor`. +Auditor registration, client submissions, scoring and rewards live in the paired [`DINTaskAuditor`](DINTaskAuditor.md); the shared state enum, interfaces and errors are in [`DINShared`](DINShared.md). --- ## 2. Inheritance & Dependencies -| Component | Source | Purpose | -|-----------|--------|---------| -| `Ownable` | OpenZeppelin | Owner-restricted lifecycle transitions | -| `DINShared.sol` | Local | `GIstates` enum, cross-contract interfaces, error declarations | +| Component | Purpose | +|-----------|---------| +| `Ownable` (OpenZeppelin, non-upgradeable) | Model owner = deployer; drives all phase transitions | +| `ReentrancyGuardTransient` | Guards `openDispute` / `claimDisputeBond` | +| `SafeERC20` / `IERC20` | DIN dispute bonds | +| `IBurnableDinToken` (local) | Burns the burn half of a forfeited dispute bond (all of it if no treasury is set) | +| `DINShared.sol` | `GIstates`, `IDinValidatorStake`, `IDINTaskAuditor`, `TC_*` errors | --- -## 3. State Variables - -### 3.1 Core State - -| Variable | Type | Visibility | Description | -|----------|------|-----------|-------------| -| `dinvalidatorStakeContract` | `IDinValidatorStake` | `public` | Validator stake contract — source of `isValidatorActive()` (registration/submission gating) and `minStake()` (aggregator slash amount, fetched dynamically, not cached) | -| `dinTaskAuditorContract` | `IDINTaskAuditor` | `public` | Auditor contract for delegation | -| `GI` | `uint` | `public` | Current Global Iteration counter (starts at 0, first active GI = 1) | -| `GIstate` | `GIstates` | `public` | Current lifecycle state | -| `genesisModelIpfsHash` | `bytes32` | `public` | IPFS hash of the genesis model, set once before GI 1 | - -> **Note:** there is no `minStake` state variable on this contract. Aggregator registration and submissions are gated by `dinvalidatorStakeContract.isValidatorActive()`, and the aggregator slash amount is read live from `dinvalidatorStakeContract.minStake()` at slash time — neither is a value cached or configurable on `DINTaskCoordinator` itself. - -### 3.2 Aggregator Registry - -| Variable | Type | Description | -|----------|------|-------------| -| `dinAggregators` | `mapping(uint => address[])` | Registered aggregators per GI | -| `isDINAggregator` | `mapping(uint => mapping(address => bool))` | Membership check | - -### 3.3 Tier-1 Batch State - -| Variable | Type | Description | -|----------|------|-------------| -| `tier1Batches` | `mapping(uint => Tier1Batch[])` | T1 batches per GI | -| `isTier1Aggregator` | `mapping(uint => mapping(uint => mapping(address => bool)))` | GI → batchId → address → assigned | -| `t1SubmissionCID` | `mapping(uint => mapping(uint => mapping(address => bytes32)))` | Revealed CID per aggregator (written only by `revealT1Aggregation`) | -| `t1Submitted` | `mapping(uint => mapping(uint => mapping(address => bool)))` | Revealed flag; `false` for a committed-but-unrevealed aggregator | -| `t1Votes` | `mapping(uint => mapping(uint => mapping(bytes32 => uint)))` | Vote count per revealed CID | -| `t1CommitHash` | `mapping(uint => mapping(uint => mapping(address => bytes32)))` | Commit hash stored by `commitT1Aggregation` (§7.4) | -| `t1Committed` | `mapping(uint => mapping(uint => mapping(address => bool)))` | Commit flag | - -### 3.4 Tier-2 Batch State - -| Variable | Type | Description | -|----------|------|-------------| -| `tier2Batches` | `mapping(uint => Tier2Batch[])` | T2 batches per GI (always exactly 1) | -| `isTier2Aggregator` | `mapping(uint => mapping(uint => mapping(address => bool)))` | Assignment check | -| `t2SubmissionCID` | `mapping(uint => mapping(uint => mapping(address => bytes32)))` | Revealed CID (written only by `revealT2Aggregation`) | -| `t2Submitted` | `mapping(uint => mapping(uint => mapping(address => bool)))` | Revealed flag | -| `t2Votes` | `mapping(uint => mapping(uint => mapping(bytes32 => uint)))` | Vote count per revealed CID | -| `t2CommitHash` | `mapping(uint => mapping(uint => mapping(address => bytes32)))` | Commit hash stored by `commitT2Aggregation` (§7.5) | -| `t2Committed` | `mapping(uint => mapping(uint => mapping(address => bool)))` | Commit flag | -| `tier2Score` | `mapping(uint => uint)` | Final score recorded for a GI's T2 output | - ---- - -## 4. Data Structures - -### 4.1 `Tier1Batch` - -```solidity -struct Tier1Batch { - uint batchId; // Unique within GI (sequential) - address[] aggregators; // Aggregators assigned to this batch - uint[] modelIndexes; // Indexes into approvedModels for this GI - bool finalized; // True after majority winner determined - bytes32 finalCID; // Winning aggregated model CID -} -``` - -### 4.2 `Tier2Batch` - -```solidity -struct Tier2Batch { - uint batchId; // Always 0 (only one T2 batch) - address[] aggregators; // T2 aggregators - bool finalized; - bytes32 finalCID; // Global winning aggregated CID -} -``` - -### 4.3 Aggregation Constants - -```solidity -uint256 public constant T1_AGGREGATORS_PER_BATCH = 3; -uint256 public constant T1_MODELS_PER_BATCH = 3; -uint256 public constant MIN_T1_MODELS_PER_BATCH = 2; -``` +## 3. State + +### 3.1 Wiring & lifecycle + +| Variable | Description | +|----------|-------------| +| `dinvalidatorStakeContract` | `DinValidatorStake` proxy (constructor) | +| `dinTaskAuditorContract` | Paired auditor (`setDINTaskAuditorContract`) | +| `modelId` (`immutable`) | Registry model ID, used for the per-model stake floor (constructor) | +| `GI` | Current GI counter (starts at 0; the first GI is 1) | +| `GIstate` | Current `GIstates` value | +| `genesisModelIpfsHash` | Genesis model CID (bytes32) | +| `dinToken` | DIN token for dispute bonds (`setDinToken`) | + +### 3.2 Aggregators & batches + +| Variable | Description | +|----------|-------------| +| `dinAggregators[gi]`, `isDINAggregator[gi][addr]` | Registration list and flag | +| `registrationSlotsReleased[gi]` | Guard for `releaseGIRegistrationSlots` | +| `tier1Batches[gi]`, `tier2Batches[gi]` | Batch arrays (`Tier1Batch` / `Tier2Batch`); T2 always has at most one batch, id 0 | +| `isTier1Aggregator`, `isTier2Aggregator` | Batch assignment flags (not public) | +| `t1CommitHash`, `t1Committed` (and `t2*`) | Commit hash and commit flag per `[gi][batchId][aggregator]` | +| `t1SubmissionCID`, `t1Submitted`, `t1Votes` (and `t2*`) | Revealed CID, revealed flag and per-CID vote count. Written only by the reveal functions; `t*Submitted` stays `false` for an aggregator who committed but never revealed | +| `tier1FinalizedAt`, `tier2FinalizedAt` | Finalization timestamps (dispute window start) | +| `tier2Score[gi]` | Owner-recorded quality score for the T2 model | +| `aggregatorWeight[gi][addr]`, `totalAggregatorWeight[gi]` | Reward weights (one unit per aggregator per finalized batch) | +| `auditSeedBlock[gi]`, `auditSeed[gi]` | Anchor block and locked seed for auditor batch assignment (§6.3) | +| `aggSeedBlock[gi]`, `aggSeed[gi]` | Anchor block and locked seed for T1/T2 batch assignment (§6.3) | + +### 3.3 Constants + +| Constant | Value | Meaning | +|----------|-------|---------| +| `T1_AGGREGATORS_PER_BATCH` | 3 | Aggregators per T1 batch — and per T2 batch and fresh dispute subgroup | +| `T1_MODELS_PER_BATCH` | 3 | Target models per T1 batch; also the minimum number of approved models to form batches | +| `MIN_T1_MODELS_PER_BATCH` | 2 | Smallest allowed final T1 batch | +| `MAX_REGISTERED_AGGREGATORS` | 300 | Registration cap per GI | + +### 3.4 Governable parameters (owner-settable) + +| Variable | Default | Setter | +|----------|---------|--------| +| `s2SlashFractionBps` | 3000 (30%) | `setS2SlashFractionBps` (1 – 10 000) | +| `disputeBond` | 100 DIN | `setDisputeParams` (non-zero) | +| `disputeWindow` | 1 day | `setDisputeParams` (non-zero) | +| `resolutionWindow` | 2 days | `setDisputeParams` (non-zero) | +| `disputeSeedDelay` | 7 blocks | `setDisputeParams` (1 – 256). Also the delay for the two batch-assignment seeds | +| `networkFeeFloor` | 0 | `setNetworkFeeFloor` (stored only, not enforced) | + +The S1/S2 slash-fraction default is one of the testnet values still to be decided (issue #155). There is no treasury address on this contract: forfeited bonds go to `dinvalidatorStakeContract.slashTreasury()` (§7). + +### 3.5 Disputes + +`disputes[gi][tierKind][batchId]` (`Dispute`: challenger, bond, openedAt, resolutionDeadline, seedBlock, resolved, upheld, finalized, seed), `reEvaluationAssignees[gi][tierKind][batchId]`, `disputeBondClaimable[addr]`, `treasuryAccrued` (a running counter of forfeited bonds routed out; no tokens are held against it). --- -## 5. Access Control +## 4. Access Control -``` -Ownable (model owner) - ├── setDINTaskAuditorContract() - ├── setDINTaskCoordinatorAsSlasher() - ├── setDINTaskAuditorAsSlasher() - ├── setGenesisModelIpfsHash() - ├── startGI(uint, uint) / startGI(uint) - ├── startDINaggregatorsRegistration() - ├── closeDINaggregatorsRegistration() - ├── startDINauditorsRegistration() - ├── closeDINauditorsRegistration() - ├── startLMsubmissions() - ├── closeLMsubmissions() - ├── createAuditorsBatches() - ├── setTestDataAssignedFlag() - ├── startLMsubmissionsEvaluation() // opens commit phase - ├── startLMsubmissionsEvaluationReveal() // closes commit, opens reveal phase - ├── closeLMsubmissionsEvaluation() - ├── autoCreateTier1AndTier2() - ├── startT1Aggregation() // opens T1 commit phase - ├── startT1AggregationReveal() // closes T1 commit, opens reveal phase - ├── finalizeT1Aggregation() - ├── startT2Aggregation() // opens T2 commit phase - ├── startT2AggregationReveal() // closes T2 commit, opens reveal phase - ├── finalizeT2Aggregation() - ├── slashAuditors() - ├── slashAggregators() - ├── setTier2Score() - └── endGI() - -Permissionless (with batch/GI guards) - ├── registerDINaggregator() - ├── commitT1Aggregation() - ├── revealT1Aggregation() - ├── commitT2Aggregation() - └── revealT2Aggregation() -``` +| Caller | Functions | +|--------|-----------| +| `owner()` (model owner) | Setup, every phase transition, `createAuditorsBatches`, `autoCreateTier1AndTier2`, `finalizeT1/T2Aggregation`, `slashAuditors`, `slashAggregators`, `setTier2Score`, `endGI`, `releaseGIRegistrationSlots`, `resolveDispute`, `settleRecomputation`, all setters | +| Any active validator | `registerDINaggregator`, `openDispute` | +| Assigned active aggregator | `commitT1Aggregation`, `revealT1Aggregation`, `commitT2Aggregation`, `revealT2Aggregation` | +| Anyone | `lockAuditSeed`, `lockAggSeed`, `lockDisputeSeed`, `expireDispute` (after the deadline), `claimDisputeBond` (own balance), views | --- -## 6. Initialization Sequence (Pre-GI Setup) - -Before any GI can begin, a one-time setup must be completed: +## 5. Setup Sequence (before GI 1) ``` -State: [0] AwaitingDINTaskAuditorToBeSet - → setDINTaskAuditorContract(auditorAddress) -State: [1] AwaitingDINTaskCoordinatorAsSlasher - → (DAO calls DinCoordinator.addSlasherContract(taskCoordinatorAddress)) - → setDINTaskCoordinatorAsSlasher() // verifies isSlasherContract(this) == true -State: [2] AwaitingDINTaskAuditorAsSlasher - → (DAO calls DinCoordinator.addSlasherContract(taskAuditorAddress)) - → setDINTaskAuditorAsSlasher() // verifies isSlasherContract(auditor) == true -State: [3] AwaitingGenesisModel - → setGenesisModelIpfsHash(cid) -State: [4] GenesisModelCreated +constructor(dinValidatorStake, modelId) → AwaitingDINTaskAuditorToBeSet +setDINTaskAuditorContract(auditor) → AwaitingDINTaskCoordinatorAsSlasher + [DIN-Representative: DinCoordinator.addSlasherContract(this)] +setDINTaskCoordinatorAsSlasher() → AwaitingDINTaskAuditorAsSlasher (checks isSlasherContract) + [DIN-Representative: DinCoordinator.addSlasherContract(auditor)] +setDINTaskAuditorAsSlasher() → AwaitingGenesisModel +setGenesisModelIpfsHash(cid) → GenesisModelCreated +setDinToken(dinToken) (needed for dispute bonds) ``` --- -## 7. GI Lifecycle Functions +## 6. GI Lifecycle -### 7.1 `startGI` +The numbers are `GIstates` ordinals (see [DINShared §2.1](DINShared.md)). -```solidity -function startGI(uint _GI, uint score) public onlyOwner // updates the pass score -function startGI(uint _GI) public onlyOwner // keeps the existing pass score -``` +| Step | Function (owner unless noted) | From → To state | +|------|-------------------------------|-----------------| +| Start | `startGI(gi)` / `startGI(gi, passScore)` | `GenesisModelCreated` (4) or `GIended` (25) → `GIstarted` (5) | +| Aggregator registration | `startDINaggregatorsRegistration` → *validators:* `registerDINaggregator` → `closeDINaggregatorsRegistration` | → 6 → 7 | +| Auditor registration | `startDINauditorsRegistration` → *validators on the auditor contract* → `closeDINauditorsRegistration` | → 8 → 9 | +| Local model submissions | `startLMsubmissions` → *clients on the auditor contract* → `closeLMsubmissions` (anchors the audit seed) | → 10 → 11 | +| Audit seed | *anyone:* `lockAuditSeed` | stays 11 | +| Audit batches | `createAuditorsBatches` (delegates to the auditor, passing the seed) | → 12 | +| Test data | `DINTaskAuditor.assignAuditTestDataset` per batch; `setTestDataAssignedFlag` | stays 12 | +| Score commit | `startLMsubmissionsEvaluation` → *auditors commit on the auditor contract* | → 13 | +| Score reveal | `startLMsubmissionsEvaluationReveal` → *auditors reveal* | → 14 | +| Finalize evaluation | `closeLMsubmissionsEvaluation` (calls `finalizeEvaluation`; anchors the aggregation seed) | → 15 | +| Aggregation seed | *anyone:* `lockAggSeed` | stays 15 | +| Batches | `autoCreateTier1AndTier2` | → 16 | +| T1 commit | `startT1Aggregation` → *aggregators:* `commitT1Aggregation` | → 17 | +| T1 reveal | `startT1AggregationReveal` → *aggregators:* `revealT1Aggregation` → `finalizeT1Aggregation` | → 18 → 19 | +| T2 commit | `startT2Aggregation` → *aggregators:* `commitT2Aggregation` | → 20 | +| T2 reveal | `startT2AggregationReveal` → *aggregators:* `revealT2Aggregation` → `finalizeT2Aggregation` | → 21 → 22 | +| Slash | `slashAuditors` → `slashAggregators` | → 23 → 24 | +| End | `endGI` (settles rewards) | → 25 | +| Housekeeping | `releaseGIRegistrationSlots(gi)` | no state change | -Both overloads delegate to an internal `_startGI(_GI, score, updatePassScore)`: +Every owner transition after `startGI` takes the expected `_GI` and reverts `TC_WrongGI` if it is not the current one. Every state change goes through `_setGIstate`, which emits `GIStateChanged(GI, newState)`. -1. Check `GIstate == GenesisModelCreated` or `GIstate == GIended` (allows repeat GIs). -2. Check `_GI == GI + 1` (must increment by exactly 1). -3. If called via the two-argument overload, call `dinTaskAuditorContract.updatePassScore(score)` — sets minimum median-score approval threshold for this GI. The one-argument overload skips this and simply reuses whatever pass score is already set on `DINTaskAuditor`. -4. Set `GIstate = GIstarted`, increment `GI`. +The three reveal-opening calls (`startLMsubmissionsEvaluationReveal`, `startT1AggregationReveal`, `startT2AggregationReveal`) are manual owner steps. Forgetting one stalls the GI in the commit window (finalize reverts) without corrupting state. ---- +### 6.1 `startGI` +Requires `_GI == GI + 1` and a funded reward pool: `dinTaskAuditorContract.giRewardPool(_GI) > 0`, otherwise `TC_GIRewardPoolNotFunded`. The two-argument overload also calls `updatePassScore`. Then `GI++`. -### 7.2 Aggregator Registration +### 6.2 `registerDINaggregator` +In `DINaggregatorsRegistrationStarted` only. There is no `onlyCurrentGI` modifier: the checks and the registration list use the `_GI` the caller passes. Checks in order: `isValidatorActive` (`TC_AggregatorNotActive`), not already registered, fewer than 300 registered (`TC_RegistrationCapReached`), stake ≥ `getModelStakeMin(modelId)` when non-zero (`TC_StakeBelowModelFloor`), and — when `maxConcurrentRegistrationsPerStakeUnit > 0` — `activeRegistrationCount < (stake / minStake) × cap` (`TC_ConcurrentRegistrationCapReached`). Then records the aggregator, calls `incrementActiveRegistration`, emits `DINValidatorRegistered`. -```solidity -function registerDINaggregator(uint _GI) public -``` +### 6.3 Batch-Assignment Seed Lock -Permissionless (no `onlyCurrentGI` modifier — note: `isDINAggregator[_GI]` check uses the passed `_GI`). +Both batch-creation steps shuffle with a seed that must be locked first (issue #156 H-2, PR #191). It replaces `blockhash(block.number - 1)` / `block.timestamp` entropy, which the caller could grind. The pattern is the same as the dispute seed (§7): a future block is anchored, and anyone may lock its hash once it is mined. -1. Check `GIstate == DINaggregatorsRegistrationStarted`. -2. Check `dinvalidatorStakeContract.isValidatorActive(msg.sender)` (`TC_AggregatorNotActive` otherwise). -3. Check not already registered (`TC_AggregatorAlreadyRegistered`). -4. Push to `dinAggregators[_GI]`, set membership. -5. Emit `DINValidatorRegistered`. +| | Auditor batches | T1/T2 batches | +|---|---|---| +| Anchor | `closeLMsubmissions` sets `auditSeedBlock[gi] = block.number + disputeSeedDelay` | `closeLMsubmissionsEvaluation` sets `aggSeedBlock[gi]` the same way | +| Lock (anyone, current GI) | `lockAuditSeed(gi)` | `lockAggSeed(gi)` | +| Seed | `keccak256(blockhash(seedBlock), gi, "AUD")` | `keccak256(blockhash(seedBlock), gi, "AGG")` | +| Consumer | `createAuditorsBatches` (`TC_AuditSeedNotLocked` if unset; the auditor contract also rejects a zero seed with `TA_AuditSeedNotLocked`) | `autoCreateTier1AndTier2` (`TC_AggSeedNotLocked` if unset) | +| Events | `AuditSeedLocked`, `AuditSeedReanchored` | `AggSeedLocked`, `AggSeedReanchored` | ---- - -### 7.3 Tier-1 and Tier-2 Batch Formation (`autoCreateTier1AndTier2`) +A lock call reverts if the seed isn't anchored yet (`TC_*SeedNotAnchored`), is already locked (`TC_*SeedAlreadyLocked`), or the anchor block isn't mined yet (`TC_*SeedBlockNotMined`). If more than 256 blocks have passed and `blockhash(seedBlock)` is zero, the call re-anchors to `block.number + disputeSeedDelay` and emits the `…Reanchored` event; it never stores a zero-derived seed. -```solidity -function autoCreateTier1AndTier2(uint _GI) external onlyOwner onlyCurrentGI(_GI) -``` +**Residual trust and gaps** (from the PR #191 review; tracked as BL-26 in [`Developer/BACK_LOG.md`](../../../Developer/BACK_LOG.md)): +- **Sequencer trust.** `blockhash` is sequencer-produced on OP Stack, so this design trusts the sequencer not to grind. VRF is the mainnet-grade follow-up (issue #178). +- **Re-roll by declining to lock.** Once `seedBlock` is mined, anyone can compute the resulting shuffle off-chain. A model owner who dislikes it can simply not lock, wait out the 256-block window, and get a fresh anchor on the next lock call. The permissionless lock only prevents this if another party locks first. +- **Post-lock pool reshaping.** The active pools are read when `createAuditorsBatches` / `autoCreateTier1AndTier2` run, after the seed is public. A validator can unstake between the lock and the create call, which changes the pool size and so reshuffles everyone. An attacker with several registered addresses can compute each outcome off-chain and unstake whichever address gives the batch they want. -Called after LM evaluation closes (`LMSevaluationClosed`). Reverts with `TC_AggSeedNotLocked` unless `aggSeed[_GI]` is already locked via `lockAggSeed` (issue #156 H-2, task_240926_18 Part B — see the seed-lock subsection below). +### 6.4 `autoCreateTier1AndTier2` +Requires `LMSevaluationClosed` and a locked `aggSeed[gi]`. +1. Filters the registered aggregators to those still `Active` at call time (`_activeAggregatorPool`); needs ≥ 3 (`TC_NotEnoughValidators`). +2. Shuffles the aggregators with `keccak256(seed, "AGG_ADDR")` and the approved model indexes with `keccak256(seed, "AGG_IDX")` (Fisher-Yates, in memory). Needs ≥ `T1_MODELS_PER_BATCH` approved models (`TC_NotEnoughApprovedModels`). +3. Greedily fills T1 batches with 3 aggregators and 3 models each (the last batch may take 2 models). +4. If ≥ 3 aggregators remain, forms the single T2 batch (id 0) from the next 3. -**Algorithm:** +Leftover aggregators and models are simply not used this GI. If fewer than 3 aggregators remain for T2, no T2 batch exists and `finalizeT2Aggregation` has nothing to finalize. -1. **Load aggregator pool:** filter the historical registration list `dinAggregators[_GI]` down to aggregators still `isValidatorActive` at call time (`_activeAggregatorPool`). Revert `TC_NotEnoughValidators` if the active count is below `T1_AGGREGATORS_PER_BATCH`. +### 6.5 Aggregation (T1 / T2): commit, reveal, finalize -2. **Shuffle aggregators (Fisher-Yates, `pure`):** - ``` - j = keccak256(keccak256(aggSeed[_GI], "AGG_ADDR"), i, arr.length) % (i+1) - ``` +Submissions are commit-then-reveal (issue #156 M-1, PR #197). With a single-shot submit, a late aggregator could read an earlier aggregator's CID from public state and copy it. Now no CID is visible while commits are open. -3. **Collect approved model indexes:** Calls `dinTaskAuditorContract.approvedModelIndexes(_GI)`. Revert `TC_NotEnoughApprovedModels` if fewer than `T1_MODELS_PER_BATCH`. +- **Commit** (`T1AggregationStarted` / `T2AggregationStarted`): `commitT1Aggregation(gi, batchId, commitHash)` / `commitT2Aggregation(…)`. The caller must be an assigned (`TC_NotBatchAggregator`), active (`TC_AggregatorNotActive`) aggregator of an existing batch (`TC_InvalidBatch`; for T2 any `batchId != 0` reverts `TC_OnlyOneTier2Batch`). A zero hash (`TC_T*EmptyCommitHash`) and a second commit (`TC_T*AlreadyCommitted`) are rejected. Emits `T1AggregationCommitted` / `T2AggregationCommitted`. +- **Commit hash:** `keccak256(abi.encode(cid, salt, msg.sender, gi, TierKind, batchId))`. Because the sender, GI, tier and batch are bound in, an aggregator who copies a peer's commit hash cannot later reveal the peer's `(cid, salt)` under their own address. +- **Open reveals** (owner): `startT1AggregationReveal` / `startT2AggregationReveal`. No further commits are accepted. +- **Reveal** (`T1AggregationRevealStarted` / `T2AggregationRevealStarted`): `revealT1Aggregation(gi, batchId, cid, salt)` / `revealT2Aggregation(…)`. Same batch, assignment and active checks; requires a prior commit (`TC_T*NoCommitFound`), no prior reveal (`TC_AlreadySubmitted`), a non-zero CID (`TC_ZeroCID`) and a matching hash (`TC_T*RevealHashMismatch`). Records the CID, increments `t*Votes[cid]`, emits `T1AggregationSubmitted` / `T2AggregationSubmitted`. +- **Finalize** (owner, from the reveal state): per batch, the CID with the most votes wins (on a tie, the first tied CID in batch order). Reverts `TC_NoSubmissions` if nobody revealed, and `TC_InsufficientSubmissions` if fewer than `3 / 2 + 1 = 2` revealed. Records `finalCID` and the finalization time, emits `T1BatchFinalized` / `T2Finalized`, and adds one `aggregatorWeight` unit for **every assigned aggregator** of the batch, whether or not they revealed or matched consensus. A failing batch reverts the whole call, so no weight persists for unfinalized batches. -4. **Shuffle model indexes (Fisher-Yates, memory, `pure`):** - ``` - j = keccak256(keccak256(aggSeed[_GI], "AGG_IDX"), i, arr.length) % (i+1) - ``` +An aggregator who commits but never reveals is treated exactly like one who never committed: excluded from the vote and slashed as a non-submitter (§6.6). -5. **Greedy T1 batch creation:** - ``` - while vPtr + T1_AGGREGATORS_PER_BATCH <= vLen - AND (enough models for a full or minimum-size batch): - T1 batch: - aggregators = valPool[vPtr .. vPtr+2] - modelsToAssign = min(T1_MODELS_PER_BATCH, remaining) - modelIndexes = modelIdx[mPtr .. mPtr+modelsToAssign-1] - vPtr += 3, mPtr += modelsToAssign - ``` +### 6.6 `slashAggregators` +For every T1 and T2 batch member: +- **No reveal (S2):** `slashPartial(agg, minStake × s2SlashFractionBps / 10 000, "AGG_T1_NO_SUBMISSION" | "AGG_T2_NO_SUBMISSION", GI)` — liveness fault with S5 recidivism escalation. Covers both never committing and committing without revealing. +- **Revealed a non-consensus CID:** `slash(agg, minStake, "AGG_T1_BAD_CONSENSUS" | "AGG_T2_BAD_CONSENSUS")` — full severity. -6. **T2 batch creation:** If `vLen - vPtr >= T1_AGGREGATORS_PER_BATCH`, create exactly one T2 batch with `valPool[vPtr .. vPtr+2]`. T2 batch always has `batchId = 0`. +`minStake` is read from `DinValidatorStake` at call time. Emits `AggregatorSlashed(gi, batchId, aggregator, reason, requested, actual)`; `actual` can be lower than `requested` if the aggregator's remaining stake is smaller. The S6 counter is deliberately not fired here (it would stack with S2/S5). -7. Set `GIstate = T1nT2Bcreated`. +### 6.7 `slashAuditors`, `setTier2Score`, `endGI`, `releaseGIRegistrationSlots` +- `slashAuditors(gi)` requires `T2AggregationDone` and only delegates to `DINTaskAuditor.slashAuditors` (see [DINTaskAuditor §8](DINTaskAuditor.md#8-auditor-slashing--slashauditorsgi)), then moves to `AuditorsSlashed`. +- `setTier2Score(gi, score)` is allowed in `T2AggregationDone` or `GenesisModelCreated`; stored only, it does not affect slashing or rewards. +- `endGI` requires `AggregatorsSlashed`, calls `dinTaskAuditorContract.settleRewards(GI, totalAggregatorWeight[GI])` and moves to `GIended`. It runs no participant loops (BL-10). +- `releaseGIRegistrationSlots(gi)` (owner, once per ended GI) decrements the active-registration counters of that GI's aggregators and, via `decrementAuditorRegistrations`, its auditors. Until it is called, those validators keep occupying concurrent-registration slots. --- -### 7.4 T1 Aggregation +## 7. Aggregation Disputes (S4 scaffold) -T1 submissions are commit-then-reveal (issue #156 M-1): a single-shot submit let a late aggregator read an earlier aggregator's `t1SubmissionCID` from public state and copy it. Nothing is counted until the model owner closes the commit window, so no CID is visible while commits are open. +Bond custody, windows and fresh-subgroup assignment are on-chain; **adjudication is not** (the owner decides — verifying an aggregation CID would mean re-running aggregation). -**Phase 1: commit** (state `T1AggregationStarted`) -```solidity -function commitT1Aggregation(uint _GI, uint _batchId, bytes32 commitHash) external -``` -- Validates the batch exists (`TC_InvalidBatch`), sender is its assigned T1 aggregator (`TC_NotBatchAggregator`) and still `isValidatorActive` (`TC_AggregatorNotActive`). -- Rejects a zero hash (`TC_T1EmptyCommitHash`) and a second commit (`TC_T1AlreadyCommitted`). -- Stores `t1CommitHash`/`t1Committed`; emits `T1AggregationCommitted`. -- `commitHash = keccak256(abi.encode(cid, salt, msg.sender, GI, TierKind.Tier1, batchId))`. Binding `msg.sender` (and GI/tier/batch) means an aggregator who copies a peer's commit hash cannot later reveal the peer's `(cid, salt)` under their own address; the recomputed hash won't match. - -**Open reveals** (owner): -```solidity -function startT1AggregationReveal(uint _GI) external onlyOwner -``` -Requires `T1AggregationStarted` (`TC_T1RevealCannotBeStarted`); sets `GIstate = T1AggregationRevealStarted`. No further commits are accepted. - -**Phase 2: reveal** (state `T1AggregationRevealStarted`) -```solidity -function revealT1Aggregation(uint _GI, uint _batchId, bytes32 _aggregationCID, bytes32 salt) external -``` -- Wrong state reverts `TC_T1RevealPhaseNotOpen`; same batch/assignment/active checks as commit. -- Requires a prior commit (`TC_T1NoCommitFound`), no prior reveal (`TC_AlreadySubmitted`), a non-zero CID (`TC_ZeroCID`), and a matching hash (`TC_T1RevealHashMismatch`). -- Writes `t1Submitted`/`t1SubmissionCID`, tallies `t1Votes[_GI][_batchId][_aggregationCID]++`, emits `T1AggregationSubmitted`. - -An aggregator who commits but never reveals never sets `t1Submitted`, so they are excluded from finalization and slashed as a non-submitter in §8.2. - -**Finalize:** -```solidity -function finalizeT1Aggregation(uint _GI) external onlyOwner -``` -Requires `T1AggregationRevealStarted` (`TC_NotReadyToFinalizeT1`). For each T1 batch, determines the winning CID by plurality (most votes): -``` -For each aggregator in batch: - if submitted: - cid = t1SubmissionCID[...][aggregator] - if t1Votes[...][cid] > maxVotes: - maxVotes = t1Votes[...][cid] - winningCID = cid -b.finalized = true -b.finalCID = winningCID -``` -Reverts `TC_NoSubmissions` if no CID was submitted for a batch. - -Sets `GIstate = T1AggregationDone`. - ---- +| Step | Who | Effect | +|------|-----|--------| +| `openDispute(gi, tierKind, batchId)` | Any active validator | Within `disputeWindow` of the batch's finalization; pulls `disputeBond` DIN; anchors the dispute seed at `block.number + disputeSeedDelay` | +| `lockDisputeSeed(gi, tierKind, batchId)` | Anyone, once the anchor block is mined and before resolution | Stores `seed = keccak256(blockhash(seedBlock), gi, tierKind, batchId)`; re-anchors if the block hash is no longer available (emits `DisputeSeedReanchored`) | +| `resolveDispute(…, upheld=false)` | Owner | Dispute closed; bond forfeited | +| `resolveDispute(…, upheld=true)` | Owner | Requires a locked seed (`TC_DisputeSeedNotLocked`). Shuffles the GI's registered aggregators with the seed and takes the first 3 that are active and were not in the disputed batch (`TC_NotEnoughValidators` if fewer than 3). Starts `resolutionWindow` | +| `settleRecomputation(…, confirmed=true)` | Owner | Bond credited back to the challenger; every original aggregator whose revealed CID matched the disputed `finalCID` is slashed `minStake` (`S4_INVALID_AGGREGATION`) | +| `settleRecomputation(…, confirmed=false)` | Owner | Bond forfeited | +| `expireDispute(…)` | Anyone, after `resolutionDeadline` | Bond credited back; every fresh-subgroup member slashed `minStake` (`S4_FRESH_SUBGROUP_TIMEOUT`) | +| `claimDisputeBond()` | Challenger | Pull payment of credited bonds | -### 7.5 T2 Aggregation +A forfeited bond is split 50% burned / 50% forwarded to `dinvalidatorStakeContract.slashTreasury()`; both halves are burned if no treasury is set. `treasuryAccrued` counts the full forfeited amount. -Same commit-then-reveal pattern as T1 (§7.4) on `tier2Batches`: `commitT2Aggregation` in `T2AggregationStarted`, owner `startT2AggregationReveal` → `T2AggregationRevealStarted`, then `revealT2Aggregation`, with the `TC_T2*` counterparts of the T1 errors and events. The commit hash uses `TierKind.Tier2`. Only one batch exists: any `_batchId != 0` reverts `TC_OnlyOneTier2Batch`. `finalizeT2Aggregation` requires `T2AggregationRevealStarted` (`TC_NotReadyToFinalizeT2`) and sets `GIstate = T2AggregationDone`. +The fresh subgroup is bookkeeping only: nothing re-opens the state machine for it to submit a recomputation on-chain (§10 No. 3). --- -## 8. Slashing Mechanism +## 8. Events -### 8.1 `slashAuditors` - -```solidity -function slashAuditors(uint _GI) external onlyOwner onlyCurrentGI(_GI) -``` - -State requirement: `T2AggregationDone`. - -This is a **thin delegation wrapper**: it calls `dinTaskAuditorContract.slashAuditors(_GI)` (reverting with `TC_FailedToSlashAuditors` if that returns `false`) and then advances `GIstate = AuditorsSlashed`. The actual slashing logic — S1 (missed vote) always active, S3 (score-deviation) gated behind `s3SlashingEnabled` — lives entirely on `DINTaskAuditor`; see `DINTaskAuditor.md` §10 for the full algorithm. - -### 8.2 `slashAggregators` - -```solidity -function slashAggregators(uint _GI) external onlyOwner onlyCurrentGI(_GI) -``` - -State requirement: `AuditorsSlashed`. - -**Algorithm:** - -``` -slashAmount = dinvalidatorStakeContract.minStake() // read live, not cached - -For each T1 batch: - For each aggregator in batch: - submitted = t1Submitted[GI][batchId][aggregator] - submittedMatching = (submitted AND t1SubmissionCID[...] == b.finalCID) - reason = submitted ? (submittedMatching ? — : "AGG_T1_BAD_CONSENSUS") : "AGG_T1_NO_SUBMISSION" - if NOT submitted OR NOT submittedMatching: - actual = dinvalidatorStakeContract.slash(aggregator, slashAmount, reason) - emit AggregatorSlashed(GI, batchId, aggregator, reason, slashAmount, actual) - -For each T2 batch: - Same logic using t2Submitted, t2SubmissionCID, b.finalCID, reasons "AGG_T2_NO_SUBMISSION" / "AGG_T2_BAD_CONSENSUS" -``` - -**Slash condition:** An aggregator is slashed if they either: -- Did not reveal any CID (`AGG_T1_NO_SUBMISSION` / `AGG_T2_NO_SUBMISSION`). This covers both never committing and committing without revealing; the two aren't distinguished (see §15), OR -- Submitted a CID that did not match the winning (plurality) CID (`AGG_T1_BAD_CONSENSUS` / `AGG_T2_BAD_CONSENSUS`). - -**Slash amount:** `dinvalidatorStakeContract.minStake()` at call time — fetched live from the stake contract, not a value stored on `DINTaskCoordinator`. `IDinValidatorStake.slash()` returns the amount actually deducted, which is recorded in the `AggregatorSlashed` event alongside the requested amount (they can differ, e.g. if the aggregator's remaining stake is below `slashAmount`). - -Sets `GIstate = AggregatorsSlashed`. +| Group | Events | +|-------|--------| +| Lifecycle | `GIStateChanged(GI, newState)` on every state change (`GI` is 0 during setup) | +| Registration & batches | `DINValidatorRegistered`, `Tier1BatchAuto`, `Tier2BatchAuto` | +| Seeds | `AuditSeedLocked`, `AuditSeedReanchored`, `AggSeedLocked`, `AggSeedReanchored`, `DisputeSeedLocked`, `DisputeSeedReanchored` | +| Aggregation | `T1AggregationCommitted`, `T1AggregationSubmitted` (on reveal), `T1BatchFinalized`, `T2AggregationCommitted`, `T2AggregationSubmitted` (on reveal), `T2Finalized` | +| Slashing | `AggregatorSlashed`, `S2SlashFractionBpsUpdated` | +| Disputes | `DisputeOpened`, `DisputeResolved`, `ReEvaluationAssigned`, `RecomputationSettled`, `DisputeExpired`, `DisputeBondClaimed` | --- -## 9. Tier-2 Score +## 9. Interactions -```solidity -function setTier2Score(uint _GI, uint _score) external onlyOwner onlyCurrentGI(_GI) -function getTier2Score(uint _GI) external view returns (uint) ``` - -Records an off-chain computed performance score for the T2 aggregation result. Can be set during `T2AggregationDone` or `GenesisModelCreated` states. This is a metadata field — it does not affect slashing. - ---- - -## 10. GI Termination - -```solidity -function endGI(uint _GI) external onlyOwner onlyCurrentGI(_GI) +DINTaskCoordinator + ├── DinValidatorStake: isSlasherContract, isValidatorActive, getStake, minStake, + │ getModelStakeMin, maxConcurrentRegistrationsPerStakeUnit, + │ activeRegistrationCount, increment/decrementActiveRegistration, + │ slashPartial (S2), slash (bad consensus, S4), slashTreasury + ├── DINTaskAuditor: giRewardPool, updatePassScore, createAuditorsBatches (with the audit seed), + │ setTestDataAssignedFlag, finalizeEvaluation, approvedModelIndexes, + │ slashAuditors, settleRewards, decrementAuditorRegistrations + └── DinToken: safeTransferFrom / safeTransfer / burn (dispute bonds) + +DINTaskAuditor → DINTaskCoordinator: GI, GIstate, aggregatorWeight (at claim time) ``` -State requirement: `AggregatorsSlashed`. Sets `GIstate = GIended`. - -After `endGI`, a new GI can be started via `startGI(_GI+1, newPassScore)` or `startGI(_GI+1)`. +`DINModelRegistry` is never read: disabling a model has no effect here. --- -## 11. Shuffling (PRNG Details) - -Two internal shuffle helpers (`pure`, taking an explicit `bytes32 seed`) mirror those in `DINTaskAuditor`, and are applied to the *active-filtered* aggregator pool (`_activeAggregatorPool`), not the raw historical registration list: - -| Function | Target | Entropy | -|----------|--------|---------| -| `_shuffleAddressArray` | Active aggregator pool | `keccak256(seed, "AGG_ADDR")` (`autoCreateTier1AndTier2`) or the dispute seed's own domain tag (`_assignFreshSubgroup`) | -| `_shuffleUintArray` (memory) | Model index pool | `keccak256(seed, "AGG_IDX")` | - -Both use Fisher-Yates algorithm. `aggSeed[_GI]` / `auditSeed[_GI]` (§11.1) replaced `blockhash(block.number - 1)` / `block.timestamp + msg.sender` as of issue #156 H-2 (task_240926_18 Part B) — see Security Considerations below for what the fix does and doesn't close. - -### 11.1 Batch-Assignment Seed Lock - -`autoCreateTier1AndTier2` and `createAuditorsBatches` (delegated to `DINTaskAuditor`, passing the locked seed across the interface) each require their own ungrindable seed to already be locked, using the same future-block-seed + permissionless-lock pattern as the dispute seed (`Dispute.seed`/`Dispute.seedBlock`, `lockDisputeSeed`): +## 10. Review Notes & Open Caveats -- **Anchor:** `closeLMsubmissionsEvaluation` sets `aggSeedBlock[_GI] = block.number + disputeSeedDelay` (T1/T2); `closeLMsubmissions` sets `auditSeedBlock[_GI]` the same way (auditor batches). `disputeSeedDelay` is reused rather than adding a second delay parameter — same owner-controlled-transition trust assumption as the dispute seed. -- **Lock:** `lockAggSeed(uint _GI)` / `lockAuditSeed(uint _GI)`, callable by **anyone**, once `block.number > seedBlock`. Stores `seed = keccak256(blockhash(seedBlock), _GI, "AGG"/"AUD")` and emits `AggSeedLocked`/`AuditSeedLocked`. If `blockhash(seedBlock) == 0` (more than 256 blocks passed), re-anchors instead (`seedBlock = block.number + disputeSeedDelay`, `AggSeedReanchored`/`AuditSeedReanchored`) rather than storing a zero-derived seed. -- **Gate:** `autoCreateTier1AndTier2` reverts `TC_AggSeedNotLocked` and `createAuditorsBatches` reverts `TC_AuditSeedNotLocked` unless `aggSeed[_GI]`/`auditSeed[_GI]` is already non-zero. `DINTaskAuditor.createAuditorsBatches(uint, bytes32)` independently rejects a zero seed too (`TA_AuditSeedNotLocked`), defense-in-depth on top of the coordinator's own check. +Earlier findings from the [foundry/src security review](../audits/foundry-src-security-review.md) should be read alongside these. Several are now fixed: unbounded registration (capped at 300), zero-CID collision (`TC_ZeroCID`), missing quorum (`TC_InsufficientSubmissions`), grindable shuffle (H-2, §6.3) and copy-the-leader aggregation (M-1, §6.5). -**Residual trust and gaps** (raised in the PR #191 review, not fixed by this mechanism; tracked as BL-26 in [`Developer/BACK_LOG.md`](../../../Developer/BACK_LOG.md)): -- **Sequencer trust:** `blockhash` is sequencer-produced on OP Stack, so this design trusts the sequencer not to grind — same caveat as the dispute seed. VRF is the mainnet-grade follow-up (issue #178). -- **Re-roll by declining to lock:** once `seedBlock` is mined, its hash (and therefore the resulting shuffle) is computable off-chain by anyone watching. The model owner can simply not call `lock…Seed` if they dislike the preview, wait out the 256-block window, and get a fresh anchor on the next lock call — repeatable. The permissionless lock only protects against this if another party locks first. -- **Post-lock pool reshaping:** `_activeAggregatorPool`/`_activeAuditorPool` are evaluated at `autoCreateTier1AndTier2`/`createAuditorsBatches` call time, *after* the seed is already public. A validator can unstake between the lock and the create call to remove themselves from the pool, which re-shuffles everyone else's assignment (Fisher-Yates re-rolls on any pool-size change) — an attacker with several registered addresses can compute all subset outcomes off-chain and unstake whichever produces the batch they want. +- **No. 1 — Runtime bytecode is over the EIP-170 limit.** With commit-reveal aggregation merged, this contract compiles to 24,585 bytes, 9 over the 24,576-byte limit, so it cannot be deployed to Optimism Sepolia or any other real chain from `develop`. `foundry/anvil.sh` starts anvil with `--code-size-limit 4294967295`, and neither `forge test` nor CI checks contract size, so the local devnet and a green test run do not show the problem. Tracked in issue #201 (Part A), which also sets a size budget for every contract. +- **No. 2 — Selective non-reveal is cheaper than being wrong.** Reveals land one at a time. A committed aggregator who sees that peers' CIDs differ from their own can withhold the reveal and take the S2 liveness slash (30% of `minStake` by default) instead of the full-`minStake` bad-consensus slash. The same trade-off exists for auditors. Whether committed-but-unrevealed gets its own reason code and fraction is open in issue #201 (Part B). +- **No. 3 — `expireDispute` can slash an idle fresh subgroup that had no on-chain way to act.** The fresh subgroup cannot submit a recomputation to this contract, so the only thing preventing the timeout slash is the owner calling `settleRecomputation` in time. If the owner does nothing, anyone can slash three uninvolved validators a full `minStake` each. +- **No. 4 — `modelId` is fixed at construction, but assigned at registry approval.** The registry assigns the ID only when it approves the request, which requires this contract to already exist. The deployer must predict the ID. A wrong guess silently applies another model's stake floor (or none). +- **No. 5 — Plurality with three aggregators.** Two colluding aggregators in a batch win the vote; nothing verifies the aggregated model itself. The dispute path (§7) is the only recourse, and the owner adjudicates it. +- **No. 6 — No recovery from a stalled GI.** If a batch never reaches the reveal quorum, `finalizeT1Aggregation` / `finalizeT2Aggregation` keep reverting and there is no owner path to skip the batch or abort the GI. +- **No. 7 — Stale NatSpec:** `slashAggregators` says the slash is always `minStake()` (no-reveal is now the S2 fraction). Comments around the dispute scaffold still say `DinTreasury` "doesn't exist on develop yet" (forfeitures are already forwarded). Several parameters are described as "DAO-settable"; they are `onlyOwner`, i.e. set by the model owner. +- **No. 8 — Leftovers:** `networkFeeFloor` is stored but not enforced. `setTestDataAssignedFlag` gates nothing: evaluation can start without test data being assigned. `releaseGIRegistrationSlots` uses string `require` messages, unlike the rest of the contract. +- **No. 9 — Not upgradeable:** a bug in a model's task contracts requires redeploying them and re-registering the model. +- **No. 10 — dincli lags this contract:** `dincli model-owner deploy task-coordinator` still calls the older one-argument constructor (no `modelId`). `dincli aggregator aggregate-t2` names its working directory, worker job and container after the last T1 batch id, not the T2 batch id (issue #202, Part 2); the on-chain commit is unaffected. --- -## 12. Events - -| Event | Emitted When | -|-------|--------------| -| `DINValidatorRegistered(GI, validator)` | Aggregator registers | -| `Tier1BatchAuto(GI, batchId)` | T1 batch created | -| `Tier2BatchAuto(GI, batchId)` | T2 batch created | -| `T1AggregationCommitted(GI, batchId, aggregator, commitHash)` / `T2AggregationCommitted(...)` | Aggregator commits (§7.4 / §7.5) | -| `T1AggregationSubmitted(GI, batchId, aggregator, cid)` / `T2AggregationSubmitted(...)` | Aggregator reveals; the CID is counted | -| `AggregatorSlashed(GI, batchId, aggregator, reason, requested, actual)` | Aggregator slashed in `slashAggregators` (T1 or T2) | -| `AggSeedLocked(GI, seed)` | `lockAggSeed` locks the T1/T2 batch-assignment seed | -| `AggSeedReanchored(GI, newSeedBlock)` | `lockAggSeed` re-anchors after the 256-block `blockhash` window is missed | -| `AuditSeedLocked(GI, seed)` | `lockAuditSeed` locks the auditor-batch seed | -| `AuditSeedReanchored(GI, newSeedBlock)` | `lockAuditSeed` re-anchors after the 256-block `blockhash` window is missed | - ---- - -## 13. Security Considerations - -| Risk | Mitigation / Status | -|------|---------------------| -| Unauthorized state transitions | All owner functions guarded by `onlyOwner` | -| Wrong GI operations | `onlyCurrentGI` modifier on most functions | -| Weak PRNG for batch assignment | **Fixed** (issue #156 H-2, task_240926_18 Part B): `blockhash(block.number - 1)`/`block.timestamp` replaced by the locked, future-block `aggSeed`/`auditSeed` (§11.1). Residual: sequencer trust, a re-roll available to a model owner who declines to lock, and post-lock pool reshaping via unstaking — see §11.1's residual list. VRF remains the mainnet-grade follow-up (issue #178) | -| Auditor slashing | Implemented on `DINTaskAuditor` (S1 always active, S3 shadow-mode by default) and triggered here via delegation — see `DINTaskAuditor.md` §10 | -| Late aggregator copies an earlier aggregator's CID | **Fixed** (issue #156 M-1, task_240926_18 Part C): commit-then-reveal with a sender-bound commit hash (§7.4). CIDs become visible only after the commit window closes, and a copied commit hash can't be revealed under another address | -| Aggregator collusion (submit same wrong CID) | Plurality voting means 2-of-3 colluding aggregators win; no quorum threshold — design risk | -| No slash appeal mechanism | Slashed aggregators cannot challenge the decision on-chain | -| Aggregator/auditor activity checks depend on `DinValidatorStake` | Both registration and per-round submissions re-check `isValidatorActive` live; an address deactivated mid-GI is excluded from the *next* active-pool filter (batch formation) but a submission already made before deactivation still counts | - ---- - -## 14. Interactions with Other Contracts - -``` -DINTaskCoordinator - ├── reads → DinValidatorStake.isValidatorActive() [aggregator registration, T1/T2 submission, active-pool filtering in autoCreateTier1AndTier2] - ├── reads → DinValidatorStake.isSlasherContract() [coordinator/auditor slasher checks] - ├── reads → DinValidatorStake.minStake() [slashAggregators — slash amount, read live] - ├── calls → DinValidatorStake.slash() [slashAggregators] - ├── calls → DINTaskAuditor.updatePassScore() [startGI(uint, uint) overload] - ├── calls → DINTaskAuditor.createAuditorsBatches() [createAuditorsBatches] - ├── calls → DINTaskAuditor.setTestDataAssignedFlag() [setTestDataAssignedFlag] - ├── calls → DINTaskAuditor.finalizeEvaluation() [closeLMsubmissionsEvaluation] - ├── calls → DINTaskAuditor.slashAuditors() [slashAuditors] - └── reads → DINTaskAuditor.approvedModelIndexes() [autoCreateTier1AndTier2] -``` - ---- - -## 15. Known Limitations & Future Work - -- Aggregator slashing is plurality-based: a 2-of-3 colluding majority wins without any cryptographic verification of the aggregated model. -- No on-chain reward distribution to aggregators — the T2 score is informational only. -- No mechanism to recover from a stalled GI (e.g., if T1 never reaches submissions). -- T2 always produces exactly one batch; no fallback if insufficient aggregators remain after T1 assignment. -- Commit-then-reveal T1/T2 aggregation adds two more owner-driven steps (`startT1AggregationReveal`, `startT2AggregationReveal`); forgetting one stalls the GI in the commit window (finalize reverts) without corrupting state. -- Selective non-reveal: reveals land sequentially, so a committed aggregator who sees peers' CIDs diverge from their own can withhold the reveal and take the no-submission slash instead of the bad-consensus slash. Same trade-off exists on the auditor side; tracked in issue #201 Part B. -- The commit-then-reveal evaluation phase (`LMSevaluationStarted` → `LMSevaluationRevealStarted` → `LMSevaluationClosed`) adds an explicit owner-driven step (`startLMsubmissionsEvaluationReveal`) between commit and reveal; forgetting to call it simply stalls the GI (no reveal is accepted) rather than corrupting any state, but it is one more manual step in the model-owner workflow than the pre-commit-reveal design had. +## 11. Change Log + +### P3 (foundry) + +- Reward pool gate on `startGI`; per-aggregator reward weights handed to `settleRewards` at `endGI` (BL-10). +- Commit-then-reveal evaluation phase (`startLMsubmissionsEvaluationReveal`, `LMSevaluationRevealStarted`). +- Registration caps and floors (`MAX_REGISTERED_AGGREGATORS`, per-model stake floor, concurrent-registration cap) with `releaseGIRegistrationSlots`. +- Submission quorum and zero-CID rejection on T1/T2 finalization. +- S2 partial slashing via `slashPartial`; full-severity slashing kept for bad consensus. +- S4 aggregation dispute scaffold (bonds, fresh subgroups, recomputation settlement, expiry), with a locked future-block seed for the fresh subgroup. +- Forfeited dispute bonds forwarded to the platform treasury (`slashTreasury()`), replacing the per-contract treasury address (issue #152). +- `GIStateChanged` emitted on every state change; `T1BatchFinalized` / `T2Finalized` on finalization. +- `modelId` constructor argument. +- Locked batch-assignment seeds for auditor and T1/T2 batches (`lockAuditSeed`, `lockAggSeed`; issue #156 H-2, PR #191). +- Commit-then-reveal T1/T2 aggregation with a sender-bound commit hash (`commitT*Aggregation`, `revealT*Aggregation`, `startT*AggregationReveal`; issue #156 M-1, PR #197). Adds states `T1AggregationRevealStarted` (18) and `T2AggregationRevealStarted` (21), shifting later ordinals. diff --git a/Documentation/technical/contracts/DinCoordinator.md b/Documentation/technical/contracts/DinCoordinator.md index 5c8a032f..9bd9883d 100644 --- a/Documentation/technical/contracts/DinCoordinator.md +++ b/Documentation/technical/contracts/DinCoordinator.md @@ -1,21 +1,22 @@ # DinCoordinator — Technical Documentation -> **File:** `hardhat/contracts/DinCoordinator.sol` +> **File:** [`foundry/src/DinCoordinator.sol`](../../../foundry/src/DinCoordinator.sol) > **SPDX-License-Identifier:** UNLICENSED > **Solidity:** `^0.8.28` +> **Deployment:** once per network behind an OpenZeppelin Transparent Proxy --- ## 1. Overview -`DinCoordinator` is the **entry-point and treasury contract** for the DIN Protocol. Its two core responsibilities are: - -1. **Token issuance** — Accept ETH deposits from users and mint an equivalent amount of DIN tokens into their wallets. -2. **Slasher management** — Act as the privileged caller that can register or de-register slasher contracts on `DinValidatorStake` on behalf of the DAO representative. - -The contract is deployed **once per network behind an OpenZeppelin Transparent Proxy** and configured through `initialize(address dinToken_)` instead of a constructor. It does **not** deploy `DinToken` itself: the token is deployed first (as its own proxy) and its address is passed to `initialize`. Minting rights are granted in a separate post-deploy step by calling `DinToken.setCoordinator(coordinatorProxy)` — see [§10 Deployment & Initialization Sequence](#10-deployment--initialization-sequence). +`DinCoordinator` is the **DIN issuance hub and slasher administrator** of the DIN Protocol. Its responsibilities are: +1. **Faucet issuance** — accept ETH deposits and mint DIN at the `dinPerEth` rate (`depositAndMint`). The faucet can be capped (`mintCap`) and permanently retired (`retireFaucet`). +2. **Emission issuance** — mint the per-GI DIN emission subsidy on behalf of the wired `DinEmission` contract (`mintEmission`), under the same cap/retirement rules. +3. **Fee routing** — forward the ETH collected by the faucet to `DinFeeRouter` (`sweepFeesToRouter`), which splits it across validator pool / treasury / storage / public goods. +4. **Slasher management** — the privileged caller that registers or de-registers slasher contracts on `DinValidatorStake`, on behalf of the DIN-Representative. +The contract is configured through `initialize(address dinToken_)`. It does **not** deploy `DinToken`: the token proxy is deployed first and its address passed to `initialize`. Minting rights are granted separately by `DinToken.setCoordinator(coordinatorProxy)` — see [§10](#10-deployment--initialization-sequence). --- @@ -24,12 +25,13 @@ The contract is deployed **once per network behind an OpenZeppelin Transparent P | Component | Source | Purpose | |-----------|--------|---------| | `Initializable` | OpenZeppelin (upgradeable) | Initializer guard for the proxy pattern | -| `OwnableUpgradeable` | OpenZeppelin (upgradeable) | DAO admin access control; owner set in `initialize` | -| `ReentrancyGuardTransient` | OpenZeppelin (L2-optimized) | Prevents re-entrancy on ETH flows | +| `OwnableUpgradeable` | OpenZeppelin (upgradeable) | Admin access control (the DIN-Representative); owner set in `initialize` | +| `ReentrancyGuardTransient` | OpenZeppelin | Re-entrancy lock on the mint and ETH-forwarding paths | | `DinToken` | Local | Token proxy reference, wired in `initialize` | -| `IDinValidatorStake` (local interface) | Local | Typed calls to `DinValidatorStake` | +| `IDinValidatorStake` (local interface) | Local | `addSlasherContract` / `removeSlasherContract` calls | +| `IDinFeeRouter` (local interface) | Local | `routeFeeETH(payer)` call used by `sweepFeesToRouter` | -`ReentrancyGuardTransient` is the L2-optimised variant that uses transient storage (EIP-1153), reducing gas cost for the re-entrancy lock on L2 networks. It is the non-upgradeable OpenZeppelin contract, which is safe behind a proxy because it is stateless — the lock lives in transient storage and occupies no storage slots. +`ReentrancyGuardTransient` keeps its lock in EIP-1153 transient storage, so it occupies no persistent storage slots and is safe behind a proxy. --- @@ -37,10 +39,17 @@ The contract is deployed **once per network behind an OpenZeppelin Transparent P | Variable | Type | Visibility | Description | |----------|------|-----------|-------------| -| `dinToken` | `DinToken` | `public` | Reference to the `DinToken` proxy. Set once in `initialize` (a regular storage variable — `immutable` is not usable behind a proxy). | -| `dinValidatorStakeContract` | `IDinValidatorStake` | `public` | Mutable reference to the validator staking contract. Set after deployment via `updateValidatorStakeContract`. | -| `dinPerEth` | `uint256` | `public` | Exchange rate: how many raw DIN tokens (18-decimal units) are minted per 1 ETH wei. Default (set in `initialize`): `1,000,000 × 10¹⁸` (i.e., 1M DIN per ETH). | -| `__gap` | `uint256[50]` | `private` | Reserved storage slots so future versions can append state variables without shifting the layout of child storage. | +| `dinToken` | `DinToken` | `public` | `DinToken` proxy. Set once in `initialize`. | +| `dinValidatorStakeContract` | `IDinValidatorStake` | `public` | Validator stake proxy. Set via `updateValidatorStakeContract`. | +| `dinPerEth` | `uint256` | `public` | Faucet rate: raw DIN units minted per 1 ETH, 1e18-scaled. Default `1,000,000 × 10¹⁸` (1M DIN per ETH). | +| `faucetRetired` | `bool` | `public` | One-way flag. Once `true`, `depositAndMint`, `mintEmission` and `setMintCap` all revert. | +| `mintCap` | `uint256` | `public` | Cap on `totalMinted`. `0` = uncapped (DevNet default). | +| `totalMinted` | `uint256` | `public` | DIN minted through this contract (faucet + emission combined). | +| `feeRouter` | `IDinFeeRouter` | `public` | Destination for `sweepFeesToRouter`. | +| `emissionContract` | `address` | `public` | The only address allowed to call `mintEmission` (the `DinEmission` proxy). | +| `__gap` | `uint256[50]` | `private` | Reserved storage slots for future variables. | + +Slot order is recorded in [storage_layout.md](../storage_layout.md#dincoordinator). --- @@ -48,10 +57,14 @@ The contract is deployed **once per network behind an OpenZeppelin Transparent P | Error | Condition | |-------|-----------| -| `InvalidAddress()` | Zero-address provided to an address parameter | -| `ValidatorStakeContractNotSet()` | Slasher management called before `dinValidatorStakeContract` is configured | -| `ZeroValue()` | ETH deposit of zero value, or exchange rate update to zero | -| `TransferFailed()` | Low-level ETH transfer from `withdraw()` reverted | +| `InvalidAddress()` | Zero address passed to `initialize`, `setFeeRouter`, slasher management, `updateValidatorStakeContract`, or `setEmissionContract` | +| `ValidatorStakeContractNotSet()` | Slasher management called before `dinValidatorStakeContract` is set | +| `ZeroValue()` | `depositAndMint` with `msg.value == 0`, `mintEmission` with `amount == 0`, or `updateDinPerEth(0)` | +| `FaucetRetired()` | `depositAndMint`, `mintEmission`, `setMintCap` or `retireFaucet` after the faucet was retired | +| `ZeroMintAmount()` | `depositAndMint` with a deposit so small that the computed DIN amount rounds to zero | +| `MintCapExceeded()` | A mint would push `totalMinted` above a non-zero `mintCap` | +| `FeeRouterNotSet()` | `sweepFeesToRouter` before `setFeeRouter` | +| `UnauthorizedEmissionCaller()` | `mintEmission` called by anything other than `emissionContract` | --- @@ -60,28 +73,41 @@ The contract is deployed **once per network behind an OpenZeppelin Transparent P | Event | Parameters | Emitted When | |-------|-----------|--------------| | `EthDepositAndDINminted` | `address indexed user`, `uint256 ethAmount`, `uint256 mintAmount` | Successful `depositAndMint()` | -| `SlasherContractAdded` | `address indexed slasher` | Slasher registered on validator stake contract | +| `EmissionMinted` | `address indexed to`, `uint256 amount` | Successful `mintEmission()` | +| `FeesSweptToRouter` | `uint256 amount` | `sweepFeesToRouter()` forwarded a non-zero balance | +| `FeeRouterUpdated` | `address indexed feeRouter` | `setFeeRouter()` | +| `MintCapUpdated` | `uint256 newCap` | `setMintCap()` | +| `FaucetRetiredEvent` | — | `retireFaucet()` | +| `EmissionContractUpdated` | `address indexed emissionContract` | `setEmissionContract()` | +| `SlasherContractAdded` | `address indexed slasher` | Slasher registered on the stake contract | | `SlasherContractRemoved` | `address indexed slasher` | Slasher de-registered | | `ValidatorStakeContractUpdated` | `address indexed validatorStakeContract` | Stake contract reference updated | -| `DinPerEthUpdated` | `uint256 newRate` | Exchange rate changed | +| `DinPerEthUpdated` | `uint256 newRate` | Faucet rate changed | --- ## 6. Access Control ``` -owner() — OwnableUpgradeable; set to the account that ran initialize (DAO representative / deployer) - ├── withdraw() +owner() — OwnableUpgradeable; the account that ran initialize (DIN-Representative) + ├── sweepFeesToRouter() + ├── setFeeRouter() + ├── setMintCap() + ├── retireFaucet() + ├── setEmissionContract() ├── addSlasherContract() ├── removeSlasherContract() ├── updateValidatorStakeContract() └── updateDinPerEth() +emissionContract (the DinEmission proxy) + └── mintEmission() + Any address (permissionless) - └── depositAndMint() ← payable, guarded by nonReentrant + └── depositAndMint() ← payable, nonReentrant ``` -A second, independent control plane exists at the proxy level: the **ProxyAdmin** contract that can upgrade the implementation. See [§9 Ownership & Upgradeability](#9-ownership--upgradeability). +A second control plane sits at the proxy level: the per-proxy **ProxyAdmin** that can upgrade the implementation. See [§9](#9-ownership--upgradeability). --- @@ -91,156 +117,145 @@ A second, independent control plane exists at the proxy level: the **ProxyAdmin* ```solidity constructor() -``` - -- Runs only on the raw implementation contract, never through the proxy. -- Calls `_disableInitializers()`, permanently locking the implementation: calling `initialize` directly on it reverts with `InvalidInitialization`. This prevents anyone from "adopting" the unproxied implementation. - -```solidity function initialize(address dinToken_) external initializer ``` -- Runs exactly once, atomically with proxy deployment (the deploy script encodes the call into the proxy's constructor data, so there is no front-running window). -- Reverts with `InvalidAddress()` if `dinToken_ == address(0)`. -- `__Ownable_init(msg.sender)` — the deployer account becomes `owner()`. -- Stores the externally deployed `DinToken` proxy address in `dinToken`. -- Sets `dinPerEth` to the default `1_000_000 * 1e18`. - -> **Note:** Unlike the pre-proxy version, the coordinator does **not** deploy `DinToken` and is not automatically its minter. Minting rights are granted afterwards via `DinToken.setCoordinator(coordinatorProxy)`; until that step, `depositAndMint()` reverts with `DinToken.Unauthorized()`. +- The constructor only calls `_disableInitializers()`, so the raw implementation can never be initialized or owned. +- `initialize` runs once, atomically with proxy deployment: reverts with `InvalidAddress()` on a zero token, sets `owner()` to the deployer, stores `dinToken`, and sets `dinPerEth = 1_000_000 * 1e18`. +- The coordinator is not a minter until `DinToken.setCoordinator(coordinatorProxy)` runs; before that, every mint reverts with `DinToken.Unauthorized()`. --- -### 7.2 `depositAndMint` — Token Issuance Mechanism +### 7.2 `depositAndMint` — Faucet ```solidity function depositAndMint() external payable nonReentrant ``` -**Purpose:** Converts ETH to DIN tokens at the current exchange rate. - -**Algorithm:** -1. Revert with `ZeroValue()` if `msg.value == 0`. -2. Compute `mintAmount`: - ``` - mintAmount = (msg.value × dinPerEth) / 10¹⁸ - ``` - - This performs safe decimal math: `dinPerEth` is stored as a 10¹⁸-scaled value, so dividing by 10¹⁸ correctly normalises the result. - - Example: `msg.value = 1 ETH (10¹⁸ wei)` → `mintAmount = (10¹⁸ × 1_000_000 × 10¹⁸) / 10¹⁸ = 1_000_000 × 10¹⁸ raw DIN units`. -3. Call `dinToken.mint(msg.sender, mintAmount)`. -4. Emit `EthDepositAndDINminted`. +1. Revert `FaucetRetired()` if the faucet is retired; revert `ZeroValue()` if `msg.value == 0`. +2. `mintAmount = (msg.value × dinPerEth) / 10¹⁸` — e.g. 1 ETH → `1_000_000 × 10¹⁸` raw DIN units at the default rate. Revert `ZeroMintAmount()` if this rounds to zero. +3. Revert `MintCapExceeded()` if `mintCap > 0 && totalMinted + mintAmount > mintCap`. +4. `totalMinted += mintAmount`, `dinToken.mint(msg.sender, mintAmount)`, emit `EthDepositAndDINminted`. -**Re-entrancy protection:** `nonReentrant` using transient storage. The ETH remains in the contract's balance until `withdraw()` is called. +The ETH stays in the coordinator until `sweepFeesToRouter()` forwards it. --- -### 7.3 `withdraw` +### 7.3 `mintEmission` — Emission Subsidy ```solidity -function withdraw() external onlyOwner nonReentrant +function mintEmission(address to, uint256 amount) external nonReentrant ``` -Transfers the full ETH balance to the `owner()`. Silent no-op if balance is zero. Uses a low-level `.call` for ETH transfer; reverts with `TransferFailed()` if the call fails. +1. Revert `UnauthorizedEmissionCaller()` unless `msg.sender == emissionContract`. +2. Revert `FaucetRetired()` if the faucet is retired; revert `ZeroValue()` if `amount == 0`. +3. Revert `MintCapExceeded()` under the same cap rule as the faucet. +4. `totalMinted += amount`, `dinToken.mint(to, amount)`, emit `EmissionMinted`. + +`to` is the `DinEmission` contract itself: `DinEmission.fundGI(gi, taskAuditor)` mints to itself, approves, and calls `DINTaskAuditor.depositRewards(gi, amount)` to fund that GI's reward pool. Emission deliberately shares the faucet's supply controls: `mintCap` bounds faucet + emission together, and **retiring the faucet also stops emission**. --- -### 7.4 `addSlasherContract` +### 7.4 `sweepFeesToRouter` ```solidity -function addSlasherContract(address slasherContract) external onlyOwner +function sweepFeesToRouter() external onlyOwner nonReentrant ``` -Delegates to `dinValidatorStakeContract.addSlasherContract(slasherContract)`. Enforces: -- `slasherContract != address(0)`. -- `dinValidatorStakeContract` is set. - -Used by the DAO representative to authorise `DINTaskCoordinator` and `DINTaskAuditor` contracts to call `slash()` on validators. +Reverts `FeeRouterNotSet()` if no router is wired; returns silently on a zero balance. Otherwise forwards the whole ETH balance via `feeRouter.routeFeeETH{value: balance}(address(this))` and emits `FeesSweptToRouter`. The router only accepts the call if the coordinator is on its fee-source allowlist (`DinFeeRouter.addFeeSource`, done by the deploy script). There is no direct ETH withdrawal to an address. --- -### 7.5 `removeSlasherContract` +### 7.5 Supply controls: `setMintCap`, `retireFaucet` ```solidity -function removeSlasherContract(address slasherContract) external onlyOwner +function setMintCap(uint256 newCap) external onlyOwner +function retireFaucet() external onlyOwner ``` -Symmetric reverse of `addSlasherContract`. Delegates to `dinValidatorStakeContract.removeSlasherContract(slasherContract)`. +- `setMintCap` sets the cap on `totalMinted` (`0` = uncapped); reverts `FaucetRetired()` after retirement. Setting a cap below the current `totalMinted` is allowed and simply blocks all further mints. +- `retireFaucet` flips `faucetRetired` to `true` permanently (reverts `FaucetRetired()` if already retired) and emits `FaucetRetiredEvent`. --- -### 7.6 `updateValidatorStakeContract` +### 7.6 Wiring setters ```solidity +function setFeeRouter(address feeRouter_) external onlyOwner +function setEmissionContract(address emissionContract_) external onlyOwner function updateValidatorStakeContract(address validatorStakeContract) external onlyOwner +function updateDinPerEth(uint256 newRate) external onlyOwner ``` -Updates the mutable `dinValidatorStakeContract` reference. Intended to be called once after `DinValidatorStake` is deployed. Reverts on zero address. +All reject the zero address (`updateDinPerEth` rejects a zero rate with `ZeroValue()`) and emit their matching event. None is one-shot: the owner can re-point any of them at any time. --- -### 7.7 `updateDinPerEth` +### 7.7 Slasher management ```solidity -function updateDinPerEth(uint256 newRate) external onlyOwner +function addSlasherContract(address slasherContract) external onlyOwner +function removeSlasherContract(address slasherContract) external onlyOwner ``` -Updates the ETH→DIN exchange rate. Reverts on zero. Emits `DinPerEthUpdated`. +Delegate to `dinValidatorStakeContract.addSlasherContract` / `removeSlasherContract` after checking `slasherContract != address(0)` and that the stake contract is set. Used by the DIN-Representative to authorise each model's `DINTaskCoordinator` and `DINTaskAuditor` (`dincli dinrep add-slasher`). --- -## 8. Token Issuance Economics +## 8. Issuance Economics -| Parameter | Default Value | Description | -|-----------|--------------|-------------| -| `dinPerEth` | `1,000,000 × 10¹⁸` | Raw DIN units minted per 1 ETH wei | -| Effective rate | 1 ETH → 1,000,000 DIN | Adjustable by DAO admin | +| Parameter | Default | Description | +|-----------|---------|-------------| +| `dinPerEth` | `1,000,000 × 10¹⁸` | Faucet rate (1 ETH → 1M DIN); owner-adjustable | +| `mintCap` | `0` (uncapped) | Cap on faucet + emission mints combined | +| `faucetRetired` | `false` | One-way switch that ends faucet **and** emission minting | +| Faucet ETH | forwarded by `sweepFeesToRouter` | Split by `DinFeeRouter`'s ETH split (default 95% validator pool / 5% treasury) | -The ETH collected accumulates in this contract and is withdrawable by `owner()` at any time. +The testnet values for `mintCap`, `dinPerEth` and the emission schedule are open decisions (issue #155). --- ## 9. Ownership & Upgradeability -The contract has **two independent control planes** that must not be confused: - | Plane | Who | Controls | |-------|-----|----------| -| Contract owner (`owner()`) | Account that ran `initialize` (deployer / DAO representative) | `withdraw`, slasher management, `updateValidatorStakeContract`, `updateDinPerEth`; transferable via `transferOwnership` | -| Proxy admin (`ProxyAdmin` contract) | Deployed by the OpenZeppelin upgrades plugin at proxy deployment; owned by the deployer | Swapping the implementation contract (i.e., changing *all* code and rules) | +| Contract owner (`owner()`) | Account that ran `initialize` (DIN-Representative) | Everything in §6 except `depositAndMint` / `mintEmission`; transferable via `transferOwnership` | +| Proxy admin (`ProxyAdmin`) | One ProxyAdmin per proxy (OZ v5), created at proxy deployment and owned by the deployer | Swapping the implementation | -Upgrade mechanics: - -- **Proxy kind:** OpenZeppelin **Transparent Proxy** (`upgrades.deployProxy(..., { kind: "transparent" })`). The proxy address is permanent; only the implementation behind it changes. -- **Upgrade path:** `CONTRACT=DinCoordinator npx hardhat run scripts/upgrade-platform.ts --network `. The script loads the proxy address from `hardhat/deployments/.json`, runs `upgrades.upgradeProxy`, and records the new implementation address back into the file. -- **Storage-layout safety:** state variables must only ever be appended. The trailing `uint256[50] __gap` reserves room for future variables. Upgrade tests (`hardhat/test/DinCoordinator.upgrade.test.ts`) call `upgrades.validateUpgrade` against a V2 fixture (`hardhat/contracts/upgrade/DinCoordinatorV2.sol`) and assert that `dinToken`, `dinPerEth`, balances, and owner-only restrictions survive the upgrade. -- **Implementation lock:** the constructor's `_disableInitializers()` means the raw implementation can never be initialized or owned — only the proxy has state. -- **Trust implication:** every guarantee in this document holds only as long as the ProxyAdmin owner is honest; an upgrade can replace any rule, including `onlyOwner` checks. +- **Upgrade path:** `cd foundry && CONTRACT=DinCoordinator forge script script/UpgradePlatform.s.sol --rpc-url --broadcast ...` — reads the proxy address from `foundry/deployments/.json` (see [UpgradePlatform](foundry/script/UpgradePlatform.md)). +- **Storage-layout safety:** variables are append-only above `__gap`; `foundry/test/UpgradeValidation.t.sol` runs `Upgrades.validateImplementation`, and `DinCoordinatorUpgradeTest` in `foundry/test/DeployPlatform.t.sol` upgrades to `foundry/src/upgrade/DinCoordinatorV2.sol` and checks rate, wiring and access control survive. +- **Trust implication:** every rule here holds only while the ProxyAdmin owner is honest — an upgrade can replace any of it. There is no timelock. --- ## 10. Deployment & Initialization Sequence -Automated by `hardhat/scripts/deploy-platform.ts` (mirrored by the test fixture `hardhat/test/helpers/platform.ts`). Each contract is a Transparent Proxy whose `initialize` runs atomically at deployment: +Automated by `foundry/script/DeployPlatform.s.sol` (see [DeployPlatform](foundry/script/DeployPlatform.md)). The steps that touch the coordinator: ``` -1. Deploy DinToken proxy → initialize() -2. Deploy DinCoordinator proxy → initialize(dinTokenAddress) -3. dinToken.setCoordinator(dinCoordinatorAddress) ← one-shot; grants minting rights -4. Deploy DinValidatorStake proxy → initialize(dinTokenAddress, dinCoordinatorAddress) -5. dinCoordinator.updateValidatorStakeContract(dinValidatorStakeAddress) -6. Deploy DINModelRegistry proxy → initialize(dinValidatorStakeAddress) -7. Addresses (proxies + shared proxyAdmin) saved to hardhat/deployments/.json - -Later, per model: - dinCoordinator.addSlasherContract(taskCoordinatorAddress) - dinCoordinator.addSlasherContract(taskAuditorAddress) +2. Deploy DinToken proxy → initialize() +3. Deploy DinFeeRouter proxy → initialize(dinToken, dinTreasury) +4. Deploy DinCoordinator proxy → initialize(dinToken) +5. dinToken.setCoordinator(dinCoordinator) ← grants minting rights (one-shot) +6. dinCoordinator.setFeeRouter(dinFeeRouter) + dinFeeRouter.addFeeSource(dinCoordinator) ← lets sweepFeesToRouter through +7. Deploy DinValidatorStake proxy → initialize(dinToken, dinCoordinator) +8. dinCoordinator.updateValidatorStakeContract(dinValidatorStake) +13. Deploy DinEmission proxy → initialize(dinCoordinator, dinToken, schedule…) +14. dinCoordinator.setEmissionContract(dinEmission) +15. dinCoordinator.updateDinPerEth / setMintCap ← only when DIN_PER_ETH / MINT_CAP are set to non-default values + +Later, per model (dincli dinrep add-slasher): + dinCoordinator.addSlasherContract(taskCoordinator) + dinCoordinator.addSlasherContract(taskAuditor) ``` -**Partially wired states** (between steps, or if a wiring step is skipped): - | Missing step | Symptom | |--------------|---------| -| Step 3 (`setCoordinator`) not done | `depositAndMint()` reverts with `DinToken.Unauthorized()` — the token has no minter yet | -| Step 5 (`updateValidatorStakeContract`) not done | `addSlasherContract` / `removeSlasherContract` revert with `ValidatorStakeContractNotSet()` | +| 5 (`setCoordinator`) | Every mint reverts with `DinToken.Unauthorized()` | +| 6 (router wiring) | `sweepFeesToRouter` reverts `FeeRouterNotSet()` (or `NotFeeSource` on the router) | +| 8 (`updateValidatorStakeContract`) | Slasher management reverts `ValidatorStakeContractNotSet()` | +| 14 (`setEmissionContract`) | `mintEmission` reverts `UnauthorizedEmissionCaller()` | --- @@ -248,13 +263,14 @@ Later, per model: | Risk | Mitigation | |------|-----------| -| Re-entrancy via ETH deposit | `ReentrancyGuardTransient` on `depositAndMint` and `withdraw` | -| Rogue slasher registration | `onlyOwner` on add/remove slasher functions | -| Exchange rate manipulation | Only `owner` can update `dinPerEth` | -| ETH locked | `withdraw()` allows owner to drain contract at any time | -| Re-initialization | `initializer` modifier — `initialize` can run exactly once per proxy | -| Implementation hijack | Constructor calls `_disableInitializers()` on the implementation | -| Malicious upgrade | Governed by ProxyAdmin ownership (deployer); no on-chain timelock — operational key security is the only safeguard | +| Re-entrancy on mint / ETH forwarding | `nonReentrant` on `depositAndMint`, `mintEmission`, `sweepFeesToRouter` | +| Unbounded supply | Optional `mintCap` over faucet + emission; `retireFaucet` ends issuance permanently | +| Rogue emission mints | Only `emissionContract` can call `mintEmission`; setting it is `onlyOwner` | +| Rogue slasher registration | `onlyOwner` on add/remove slasher | +| Rate manipulation | Only `owner` can call `updateDinPerEth` | +| ETH diverted | ETH leaves only through `sweepFeesToRouter` to the configured router; no arbitrary-recipient withdrawal | +| Re-initialization / implementation hijack | `initializer` + `_disableInitializers()` | +| Malicious upgrade | Bounded only by ProxyAdmin key security; no timelock | --- @@ -262,31 +278,38 @@ Later, per model: ``` DinCoordinator (proxy) - ├── calls → DinToken.mint(user, amount) [on depositAndMint] - ├── calls → DinValidatorStake.addSlasherContract() - └── calls → DinValidatorStake.removeSlasherContract() - -DinToken (proxy) - └── setCoordinator(dinCoordinator) authorises this contract as sole minter [post-deploy wiring] + ├── → DinToken.mint(user, amount) [depositAndMint] + ├── → DinToken.mint(to, amount) [mintEmission, from DinEmission] + ├── → DinFeeRouter.routeFeeETH{value}(this) [sweepFeesToRouter] + ├── → DinValidatorStake.addSlasherContract() + └── → DinValidatorStake.removeSlasherContract() + +DinEmission → DinCoordinator.mintEmission() [per-GI emission] +DinToken.setCoordinator(dinCoordinator) [one-shot minter wiring] ``` --- ## 13. Change Log +### P3 — supply controls, emission, fee routing (foundry) + +- **Removed** `withdraw()` and its `TransferFailed` error; faucet ETH now leaves only via `sweepFeesToRouter()` to `DinFeeRouter` (new `feeRouter`, `setFeeRouter`, `FeeRouterNotSet`, `FeeRouterUpdated`, `FeesSweptToRouter`). +- Added supply controls: `mintCap` / `setMintCap` / `MintCapExceeded` / `MintCapUpdated`, `totalMinted`, and the one-way `faucetRetired` / `retireFaucet` / `FaucetRetired` / `FaucetRetiredEvent`. +- Added emission minting: `emissionContract` / `setEmissionContract` / `mintEmission` with `UnauthorizedEmissionCaller`, `EmissionContractUpdated`, `EmissionMinted`. + ### 2026-07 — Upgradeable conversion (PR 13) -- Converted to a Transparent Proxy: `Ownable` → `Initializable` + `OwnableUpgradeable`; constructor replaced by `_disableInitializers()` plus `initialize(address dinToken_)`. -- No longer deploys `DinToken` in its constructor — the token proxy is deployed separately and injected via `initialize`; minting rights are granted afterwards through `DinToken.setCoordinator` (see §10). -- `dinToken` lost `immutable` (regular storage, set once in `initialize`); the `dinPerEth` default moved from an inline initializer into `initialize` (value unchanged). -- Added `uint256[50] __gap` storage reserve. -- Unchanged: `depositAndMint`, `withdraw`, slasher management, `updateValidatorStakeContract`, `updateDinPerEth`, and all events/errors. +- Converted to a Transparent Proxy (`Initializable` + `OwnableUpgradeable`, `_disableInitializers()` constructor, `initialize(address dinToken_)`). +- No longer deploys `DinToken`; minting rights granted via `DinToken.setCoordinator`. +- `dinToken` lost `immutable`; `dinPerEth` default moved into `initialize`; added `uint256[50] __gap`. --- ## 14. Review Notes & Open Caveats -- **No. 1 — Ownership is claimed at initialization, not implementation deployment:** whoever runs the deploy script becomes `owner()`; transfer to the DAO multisig should be part of the deployment runbook. -- **No. 2 — Wiring is a two-transaction trust window:** between proxy deployment and `DinToken.setCoordinator`, `depositAndMint` reverts. The deploy script performs the wiring immediately, but a manual deployment that skips it leaves the exchange non-functional (fails closed, not open). -- **No. 3 — `updateValidatorStakeContract` has no one-shot guard:** the owner can re-point the stake contract at any time (pre-existing behavior; the NatSpec now documents it as an operational responsibility). -- **No. 4 — Upgrade power is absolute:** the ProxyAdmin owner can replace all logic, including the exchange rate and withdrawal rules, with no timelock (see §9). +- **No. 1 — Retiring the faucet also stops emission:** `mintEmission` checks `faucetRetired`, so `retireFaucet()` ends *all* issuance through this contract, not only the ETH faucet. Intended per the NatSpec ("emission cannot bypass the supply cap machinery"), but easy to miss operationally. +- **No. 2 — Swept ETH mostly stays in the router:** `dincli dinrep coordinator sweep-fees` calls `sweepFeesToRouter()`. With the default split only the treasury share is paid out; the validator-pool share accrues in `DinFeeRouter`, which has no withdrawal path yet. +- **No. 3 — Ownership is claimed at initialization:** whoever runs the deploy script becomes `owner()`; transfer is a deliberate runbook step. +- **No. 4 — Wiring setters have no one-shot guard:** `setFeeRouter`, `setEmissionContract`, `updateValidatorStakeContract` can be re-pointed at any time. +- **No. 5 — Upgrade power is absolute:** the ProxyAdmin owner can replace all logic, with no timelock. diff --git a/Documentation/technical/contracts/DinToken.md b/Documentation/technical/contracts/DinToken.md index 4971e652..3c768639 100644 --- a/Documentation/technical/contracts/DinToken.md +++ b/Documentation/technical/contracts/DinToken.md @@ -1,6 +1,6 @@ # DinToken — Technical Documentation -> **File:** `hardhat/contracts/DinToken.sol` +> **File:** [`foundry/src/DinToken.sol`](../../../foundry/src/DinToken.sol) > **SPDX-License-Identifier:** MIT > **Solidity:** `^0.8.28` > **Standard:** ERC-20 (OpenZeppelin upgradeable) @@ -9,9 +9,9 @@ ## 1. Overview -`DinToken` is the native utility token of the DIN Protocol ecosystem. It is a minimal ERC-20 contract deployed **behind an OpenZeppelin Transparent Proxy**, whose minting authority is bound to a single address — the `DinCoordinator` proxy — wired once after deployment via the one-shot `setCoordinator()`. The token carries 18 decimal places (inherited from OpenZeppelin's `ERC20Upgradeable`). +`DinToken` is the native utility token of the DIN Protocol ecosystem. It is a minimal ERC-20 contract deployed **behind an OpenZeppelin Transparent Proxy**, whose minting authority is bound to a single address — the `DinCoordinator` proxy — wired once after deployment via the one-shot `setCoordinator()`. Any holder can burn their own tokens via `burn()`. The token carries 18 decimal places (inherited from OpenZeppelin's `ERC20Upgradeable`). -The token serves as the staking and slashing currency: validators acquire DIN tokens through `DinCoordinator.depositAndMint()`, then lock them in `DinValidatorStake` to participate in the network. +The token serves as the staking, slashing and reward currency: validators acquire DIN tokens through `DinCoordinator.depositAndMint()` (the ETH faucet) and lock them in `DinValidatorStake`; new DIN also enters circulation as the per-GI emission subsidy via `DinCoordinator.mintEmission()`, called by `DinEmission`. --- @@ -55,6 +55,7 @@ Custom errors are preferred over `require` strings for gas efficiency. | Event | Parameters | Emitted When | |-------|-----------|--------------| | `TokensMinted` | `address indexed to`, `uint256 amount` | Every successful `mint()` call, in addition to the inherited ERC-20 `Transfer` event. | +| `TokensBurned` | `address indexed from`, `uint256 amount` | Every successful `burn()` call, in addition to the inherited ERC-20 `Transfer(from, address(0), amount)` event. | | `CoordinatorSet` | `address indexed coordinator` | The one-shot `setCoordinator()` wiring call. | --- @@ -70,6 +71,9 @@ owner() — OwnableUpgradeable; set to the account that ran initialize (deployer coordinator — the DinCoordinator proxy, wired via setCoordinator() └── mint() ← guarded by onlyCoordinator +any holder + └── burn() ← burns msg.sender's own balance; no extra access control + ProxyAdmin (proxy level, owned by deployer) └── can upgrade the implementation (see §9) ``` @@ -94,7 +98,7 @@ constructor() ``` - Runs only on the raw implementation contract, never through the proxy. -- Calls `_disableInitializers()`, so the implementation itself can never be initialized — a direct `initialize()` on it reverts with `InvalidInitialization` (covered by `hardhat/test/DinToken.upgrade.test.ts`). +- Calls `_disableInitializers()`, so the implementation itself can never be initialized — a direct `initialize()` on it reverts with `InvalidInitialization` (covered by `ReInitializerProtectionTest` in `foundry/test/DeployPlatform.t.sol`). ```solidity function initialize() external initializer @@ -147,7 +151,29 @@ function mint(address to, uint256 amount) external onlyCoordinator - Emits `Transfer(address(0), to, amount)`. 4. Emits `TokensMinted(to, amount)` for off-chain indexing. -Called exclusively by `DinCoordinator.depositAndMint()`. +Called only by `DinCoordinator`, from two paths: +- `depositAndMint()` — the ETH→DIN faucet. +- `mintEmission()` — the per-GI emission subsidy, callable only by the wired `DinEmission` contract. + +Both paths go through the coordinator's `faucetRetired` / `mintCap` / `totalMinted` checks before calling `mint` (see [DinCoordinator.md](DinCoordinator.md)). + +--- + +### 7.4 `burn` + +```solidity +function burn(uint256 amount) external +``` + +| Parameter | Type | Description | +|-----------|------|-------------| +| `amount` | `uint256` | Number of the caller's own tokens to destroy. | + +**Algorithm:** +1. Calls OpenZeppelin's internal `_burn(msg.sender, amount)`, which reverts with `ERC20InsufficientBalance` if the caller holds less than `amount`, decrements `balanceOf[msg.sender]` and `totalSupply`, and emits `Transfer(msg.sender, address(0), amount)`. +2. Emits `TokensBurned(msg.sender, amount)`. + +No extra access control — the same trust model as `transfer`: a holder can only destroy their own balance. In the protocol it is called by `DinFeeRouter` on its own balance, after pulling DIN fees via `transferFrom`, for the burn share of its DIN split (`burnBps`, 0% by default). ETH is never burned. --- @@ -160,9 +186,10 @@ Called exclusively by `DinCoordinator.depositAndMint()`. | Decimals | `18` | | Initial Supply | `0` (no pre-mint) | | Minting Authority | `DinCoordinator` proxy (one-shot `setCoordinator`) | -| Burning | Not implemented | +| Burning | Holder-initiated `burn()` (used by `DinFeeRouter` for its DIN burn share) | +| Supply cap | None in the token; enforced upstream by `DinCoordinator.mintCap` (0 = uncapped) | -**Minting rate:** Defined entirely by `DinCoordinator.dinPerEth`. Default is `1,000,000 DIN per 1 ETH` (i.e., 1 ETH → 1M × 10¹⁸ raw token units). +**Minting rate:** Faucet mints are defined by `DinCoordinator.dinPerEth` (default `1,000,000 DIN per 1 ETH`, i.e. 1 ETH → 1M × 10¹⁸ raw token units). Emission mints follow the `DinEmission` schedule. --- @@ -175,23 +202,25 @@ Called exclusively by `DinCoordinator.depositAndMint()`. | Proxy admin (`ProxyAdmin`) | Deployed by the OZ upgrades plugin, owned by the deployer | Swapping the implementation | - **Proxy kind:** OpenZeppelin Transparent Proxy; the token address that balances live at is permanent, only code changes on upgrade. -- **Upgrade path:** `CONTRACT=DinToken npx hardhat run scripts/upgrade-platform.ts --network ` (reads/writes `hardhat/deployments/.json`). -- **Storage-layout safety:** the `__gap` array reserves 50 slots; ERC-20 balances live in OZ's namespaced (ERC-7201) storage. `hardhat/test/DinToken.upgrade.test.ts` validates a V2 fixture (`hardhat/contracts/upgrade/DinTokenV2.sol`) with `upgrades.validateUpgrade` and asserts balances, `coordinator` wiring, and both access-control paths survive an upgrade. +- **Upgrade path:** `cd foundry && CONTRACT=DinToken forge script script/UpgradePlatform.s.sol --rpc-url --broadcast ...` (reads the proxy address from `foundry/deployments/.json`; see [UpgradePlatform](foundry/script/UpgradePlatform.md)). +- **Storage-layout safety:** the `__gap` array reserves 50 slots; ERC-20 balances live in OZ's namespaced (ERC-7201) storage. `foundry/test/UpgradeValidation.t.sol` runs `Upgrades.validateImplementation`, and `DinTokenUpgradeTest` in `foundry/test/DeployPlatform.t.sol` upgrades to `foundry/src/upgrade/DinTokenV2.sol` (`Upgrades.upgradeProxy` validates first) and checks that balances, coordinator wiring and access control survive. - **Trust implication:** the "coordinator can never change" guarantee is enforced at the *implementation* level. A ProxyAdmin-authorized upgrade could replace that rule (or the entire token logic), so the guarantee is ultimately bounded by the security of the ProxyAdmin owner key. --- ## 10. Deployment & Post-Deploy Wiring -From `hardhat/scripts/deploy-platform.ts`: the token is the **first** platform contract deployed, because everything else references it. +From `foundry/script/DeployPlatform.s.sol` (see [DeployPlatform](foundry/script/DeployPlatform.md)): the token is deployed right after `DinTreasury`, before everything that references it. ``` -1. Deploy DinToken proxy → initialize() (owner = deployer, no minter yet) -2. Deploy DinCoordinator proxy → initialize(dinToken) -3. dinToken.setCoordinator(dinCoordinator) ← minting goes live here +1. Deploy DinTreasury proxy +2. Deploy DinToken proxy → initialize() (owner = deployer, no minter yet) +3. Deploy DinFeeRouter proxy → initialize(dinToken, dinTreasury) +4. Deploy DinCoordinator proxy → initialize(dinToken) +5. dinToken.setCoordinator(dinCoordinator) ← minting goes live here ``` -Between steps 1 and 3 every `mint()` reverts with `Unauthorized()` — including `DinCoordinator.depositAndMint()` — so a half-wired deployment cannot mint. Because `setCoordinator` is one-shot, an attacker who somehow raced step 3 would permanently brick minting rather than gain it (and the call is `onlyOwner` anyway). +Between steps 2 and 5 every `mint()` reverts with `Unauthorized()` — including `DinCoordinator.depositAndMint()` — so a half-wired deployment cannot mint. Because `setCoordinator` is one-shot, an attacker who somehow raced step 5 would permanently brick minting rather than gain it (and the call is `onlyOwner` anyway). --- @@ -203,6 +232,7 @@ Between steps 1 and 3 every `mint()` reverts with `Unauthorized()` — including | Minting before wiring | `coordinator` defaults to `address(0)`; `mint` fails closed. | | Minting to zero address | Explicit `InvalidAddress()` guard before `_mint`. | | Re-entrancy | N/A — no ETH is transferred; pure ERC-20 state update. | +| Burning others' tokens | `burn` only destroys `msg.sender`'s balance; no `burnFrom`. | | Owner abuse | `owner()` cannot mint; its only power is the one-shot `setCoordinator` (spent at deployment) — though it persists as a role via `transferOwnership`. | | Re-initialization / implementation hijack | `initializer` modifier + `_disableInitializers()` in the constructor. | | Malicious upgrade | Governed by ProxyAdmin ownership; no timelock — see §9. | @@ -212,23 +242,27 @@ Between steps 1 and 3 every `mint()` reverts with `Unauthorized()` — including ## 12. Interactions with Other Contracts ``` -Deploy script (hardhat/scripts/deploy-platform.ts) - ├── deploys DinToken proxy first +Deploy script (foundry/script/DeployPlatform.s.sol) + ├── deploys DinToken proxy (after DinTreasury) └── wires DinToken.setCoordinator(dinCoordinator) after the coordinator exists DinCoordinator - └── calls DinToken.mint(user, amount) on every depositAndMint() + ├── calls DinToken.mint(user, amount) on every depositAndMint() + └── calls DinToken.mint(to, amount) on every mintEmission() (from DinEmission) + +DinFeeRouter + └── calls DinToken.burn(amount) on its own balance for the DIN burn share -DinValidatorStake - └── holds DIN tokens on behalf of stakers (via ERC-20 transferFrom) +DinValidatorStake / DINTaskAuditor / DINTaskCoordinator / DinTreasury + └── hold and move DIN (stakes, reward pools, slashed stake) via ERC-20 transfer/transferFrom ``` --- ## 13. Known Limitations & Future Work -- No `burn` function — slashed tokens stay locked in `DinValidatorStake` with no on-chain destruction (see `TODO` in `DinValidatorStake.sol`). - No `pause` or emergency stop mechanism. +- No supply cap in the token itself; the cap lives in `DinCoordinator.mintCap` (0 = uncapped on DevNet). - Minting authority cannot be re-pointed at the contract level (one-shot `setCoordinator`); moving it would require an implementation upgrade or a new token proxy. - The OZ owner role persists after its single job (`setCoordinator`) is done; renouncing it post-deployment would remove that surface but also forfeit any future admin hooks an upgrade might add. @@ -236,6 +270,11 @@ DinValidatorStake ## 14. Change Log +### P3 — burn support (foundry) + +- Added holder-initiated `burn(uint256)` and the `TokensBurned` event, used by `DinFeeRouter` for the DIN burn share. +- Mint callers grew a second path: `DinCoordinator.mintEmission()` (from `DinEmission`), in addition to `depositAndMint()`. + ### 2026-07 — Upgradeable conversion (PR 13) - Converted to a Transparent Proxy: `ERC20` → `Initializable` + `ERC20Upgradeable` + `OwnableUpgradeable`; pragma bumped `^0.8.19` → `^0.8.28`. @@ -252,4 +291,4 @@ DinValidatorStake - **No. 1 — Two roles now share the "owner" vocabulary:** the OZ `owner()` (deployer, admin) and the `coordinator` (minter) are different parties; older docs/tools that equated "owner" with "minter" must be updated. - **No. 2 — Deployment gains a mandatory wiring step:** until `setCoordinator` runs, all minting (and therefore `DinCoordinator.depositAndMint`) reverts. Fails closed, but a skipped step looks like a broken exchange. - **No. 3 — "Set once, forever" is implementation-level only:** the one-shot guard can be bypassed by a ProxyAdmin-authorized upgrade that resets `coordinator`, so the immutability guarantee is bounded by upgrade-key security (see §9). -- **No. 4 — Owner role outlives its purpose:** after wiring, `owner()` has no remaining function but stays transferable; consider renouncing or transferring to the DAO multisig as a deliberate post-deployment decision. +- **No. 4 — Owner role outlives its purpose:** after wiring, `owner()` has no remaining function but stays transferable; consider renouncing or transferring it as a deliberate post-deployment decision (on-chain DIN-DAO governance is deferred to post-mainnet). diff --git a/Documentation/technical/contracts/DinValidatorStake.md b/Documentation/technical/contracts/DinValidatorStake.md index 3d76d31d..e5bbf1f5 100644 --- a/Documentation/technical/contracts/DinValidatorStake.md +++ b/Documentation/technical/contracts/DinValidatorStake.md @@ -1,615 +1,267 @@ # DinValidatorStake — Technical Documentation -Technical documentation for [`hardhat/contracts/DinValidatorStake.sol`](../../../hardhat/contracts/DinValidatorStake.sol). +> **File:** [`foundry/src/DinValidatorStake.sol`](../../../foundry/src/DinValidatorStake.sol) +> **SPDX-License-Identifier:** MIT +> **Solidity:** `^0.8.28` +> **Deployment:** once per network behind an OpenZeppelin Transparent Proxy -## Overview - -`DinValidatorStake` is the staking ledger for DIN validators. It holds DIN tokens inside the contract, tracks each validator's staking lifecycle, exposes whether a validator is currently active, and lets authorized slasher contracts reduce stake. - -It is deployed once per network behind an OpenZeppelin **Transparent Proxy** and configured via `initialize(dinToken, dinCoordinator)` rather than a constructor (see [Initialization](#initialization) and [Ownership and upgradeability](#ownership-and-upgradeability)). - -It is responsible for: - -- accepting validator stake in DIN tokens; -- tracking validator lifecycle status; -- enforcing delayed withdrawals through an unbonding period; -- keeping unbonding funds slashable until they are actually claimed; -- allowing authorized slasher contracts to penalize validators; -- allowing the contract owner to blacklist and unblacklist validators; -- exposing validator eligibility to other protocol contracts. - -The contract manages two balances per validator: - -- `activeStake`: stake currently counted toward validator participation. -- `pendingWithdrawals`: stake that has been unstaked but is still locked until the unbonding period ends. +--- -Pending withdrawals remain slashable until they are claimed. +## 1. Overview -## Inheritance and dependencies +`DinValidatorStake` is the staking ledger and validator-lifecycle contract for DIN validators (auditors and aggregators). It: -### Inheritance +- holds validators' DIN and tracks each validator's lifecycle status (`None` / `Active` / `Exiting` / `Jailed` / `Blacklisted`); +- enforces an unbonding delay during which unstaked DIN **stays slashable**; +- lets authorised slasher contracts (each model's `DINTaskCoordinator` / `DINTaskAuditor`) slash, with three flavours: full-severity `slash`, partial `slashPartial` with **S5 recidivism** escalation, and the **S6 no-participation** counter; +- splits every slashed amount **50% burned / 50% to `slashTreasury`** (`DinTreasury`); +- supports **jailing** (automatic on S5 escalation) and self-service `reactivate()` after the jail period; +- stores governable parameters (`MIN_STAKE`, `UNBONDING_PERIOD`, per-model stake floors, concurrent-registration cap, S5/S6 parameters) that the task contracts read at registration and slashing time; +- keeps validators' X25519 encryption keys for encrypted test-data delivery, and a per-validator active-registration counter. -| Component | Source | Purpose | -|-----------|--------|---------| -| `Initializable` | OpenZeppelin (upgradeable) | Initializer guard for the proxy pattern | -| `OwnableUpgradeable` | OpenZeppelin (upgradeable) | DAO admin ownership; owner set in `initialize` | -| `ReentrancyGuardTransient` | OpenZeppelin (L2-optimised) | Re-entrancy protection on ERC-20 flows | +Its central safety property: a validator cannot misbehave and then withdraw before the penalty lands, because exits are delayed and still slashable. -`ReentrancyGuardTransient` is the plain (non-upgradeable) OpenZeppelin contract, which is safe behind a proxy because it is stateless: the lock lives in EIP-1153 transient storage and occupies no storage slots. +--- -### Dependencies +## 2. Inheritance & Dependencies | Component | Source | Purpose | |-----------|--------|---------| -| `IERC20` | OpenZeppelin | Token interface | -| `SafeERC20` | OpenZeppelin | Safe ERC-20 transfers (handles non-standard return values) | - - -## Core Design - -### Main Rules - -- A validator is only eligible for new work when its status is `Active`. -- A validator with a pending withdrawal is `Exiting`, even if its remaining active stake is still large. -- `pendingWithdrawals` remain slashable until claimed. -- Blacklisted validators cannot stake, start exits, or claim exits. -- Slashing is capped by the validator’s total slashable funds inside the contract. - - - -### Initialization - -```solidity -constructor() -``` - -The constructor only calls `_disableInitializers()`. It runs on the raw implementation contract (never through the proxy) and permanently locks it: calling `initialize` directly on the implementation reverts with `InvalidInitialization`. All real state lives in the proxy. +| `Initializable` | OpenZeppelin (upgradeable) | Initializer guard | +| `OwnableUpgradeable` | OpenZeppelin (upgradeable) | Owner = DIN-Representative (parameters, blacklist, slash treasury) | +| `ReentrancyGuardTransient` | OpenZeppelin | Re-entrancy lock (transient storage, no slots used) | +| `IERC20` + `SafeERC20` | OpenZeppelin | Stake custody and transfers | +| `IBurnableToken` (local interface) | Local | `DinToken.burn` for the burned half of each slash | -```solidity -function initialize(address dinToken, address dinCoordinator) external initializer -``` - -Initialization rules: - -- Runs exactly once per proxy, atomically with proxy deployment (the deploy script encodes the call into the proxy's constructor data). -- `dinToken` must not be `address(0)`. -- `dinCoordinator` must not be `address(0)`. -- `__Ownable_init(msg.sender)` — the deployer becomes `owner()` (blacklist administration). -- `DIN_TOKEN` is stored as an ERC-20 reference and `DIN_COORDINATOR` as the access-control address. Both are regular storage variables set once here (`immutable` is not usable behind a proxy); the SCREAMING_CASE names are retained from the pre-proxy version. - -Unlike `DinToken` and `DinCoordinator`, this contract receives **both** of its dependencies at initialization and needs no further wiring of its own. The one remaining step is on the coordinator's side: `DinCoordinator.updateValidatorStakeContract(thisProxy)` must be called so slasher management can reach this contract. +--- -### Constants and storage +## 3. State Variables -#### Constants +### 3.1 Platform references -| Name | Value | Meaning | -|---|---:|---| -| `MIN_STAKE` | `10 * 1e18` | Minimum amount accepted by each `stake()` call | -| `UNBONDING_PERIOD` | `7 days` | Delay between `unstake()` and `claimUnstaked()` | +| Variable | Type | Description | +|----------|------|-------------| +| `DIN_TOKEN` | `IERC20` | Stake token (`DinToken` proxy). Set once in `initialize`; SCREAMING_CASE kept from the pre-proxy `immutable` version. | +| `DIN_COORDINATOR` | `address` | Only address allowed to manage the slasher registry (`DinCoordinator` proxy). Set once in `initialize`. | +| `slashTreasury` | `address` | Receives 50% of every slash; if unset, that half is burned too. Set via `setSlashTreasury` (the deploy script wires `DinTreasury`). The task contracts read the same address for their own treasury flows: the reward-pool treasury share and the treasury half of forfeited dispute bonds and penalties. | -`MIN_STAKE` is enforced per `stake(amount)` call, not on the validator's total post-stake balance. +### 3.2 Governable parameters (owner-settable) -#### Platform addresses +| Variable | Default (set in `initialize`) | Setter | Meaning | +|----------|------------------------------|--------|---------| +| `MIN_STAKE` | `10 × 10¹⁸` (10 DIN) | `setMinStake` (non-zero) | Minimum per `stake()` call; `Active` requires `activeStake ≥ MIN_STAKE`; unit for S5/S6 slash sizes | +| `UNBONDING_PERIOD` | `7 days` | `setUnbondingPeriod` (non-zero) | Delay between `unstake` and `claimUnstaked`; not retroactive | +| `modelMinStakeBounds[modelId]` | unset (`{0,0}`) | `setModelStakeBounds(modelId, min, max)` (`min ≤ max`) | Per-model stake floor; `min` is enforced by the task contracts at registration when non-zero (`max` is stored but unused) | +| `maxConcurrentRegistrationsPerStakeUnit` | `0` (off) | `setMaxConcurrentRegistrationsPerStakeUnit` | When non-zero, task contracts cap a validator's concurrent GI registrations at `(stake / MIN_STAKE) × value` | +| `s5RecidivismWindow` | `5` GIs | `setS5RecidivismParams` | Rolling window for counting partial slashes | +| `s5RecidivismThreshold` | `3` | `setS5RecidivismParams` (`0 < threshold ≤ window`) | Partial slashes within the window that trigger escalation | +| `s5JailDuration` | `7 days` | `setS5RecidivismParams` (non-zero) | Jail length applied on escalation | +| `s6NoParticipationThreshold` | `3` | `setS6NoParticipationThreshold` (non-zero) | No-participation count at which S6 slashing starts | -| Name | Meaning | -|---|---| -| `DIN_TOKEN` | ERC-20 token accepted as stake (the `DinToken` proxy) | -| `DIN_COORDINATOR` | Only address allowed to manage slasher contracts (the `DinCoordinator` proxy) | +`MIN_STAKE` (10 DIN) is one of the testnet values still to be decided (issue #155). `DeployPlatform.s.sol` can override `MIN_STAKE` and the S5/S6 parameters at deploy time from environment variables (`MIN_STAKE`, `S5_RECIDIVISM_WINDOW`, `S5_RECIDIVISM_THRESHOLD`, `S5_JAIL_DURATION`, `S6_NO_PARTICIPATION_THRESHOLD`). -Both are set once in `initialize` and have no setter — they are fixed for the life of the proxy short of an implementation upgrade. +### 3.3 Per-validator state -#### Storage reserve +| Variable | Type | Description | +|----------|------|-------------| +| `validators` | `mapping(address => ValidatorInfo)` | Lifecycle record (§4) | +| `slasherContracts` | `mapping(address => bool)` | Authorised slashers | +| `encryptionKeys` | `mapping(address => bytes)` | Registered 32-byte X25519 public keys | +| `activeRegistrationCount` | `mapping(address => uint256)` | Open GI registrations across all task contracts | +| `s6NoParticipationCount` | `mapping(address => uint256)` | Lifetime no-participation count (never decays) | +| `_partialSlashGIs` | `mapping(address => mapping(address => uint256[]))` (private) | Partial-slash GI indices per validator **per calling slasher contract** (S5 ring) | +| `__gap` | `uint256[50]` | Reserved slots | -```solidity -uint256[50] private __gap; -``` +Slot order is in [storage_layout.md](../storage_layout.md#dinvalidatorstake). -Fifty storage slots reserved after the declared state so future implementation versions can append variables without corrupting the proxy storage layout. +--- -### Slasher registry +## 4. Validator Record & Status ```solidity -mapping(address => bool) public slasherContracts; -``` - -Only addresses marked `true` can call `slash()`. +enum ValidatorStatus { None, Active, Exiting, Jailed, Blacklisted } -### Validator status - -```solidity -enum ValidatorStatus { - None, - Active, - Exiting, - Jailed, - Blacklisted -} -``` - -| Status | Meaning in the current contract | -|---|---| -| `None` | No active stake and no pending withdrawal | -| `Active` | Validator has no pending withdrawal and `activeStake >= MIN_STAKE` | -| `Exiting` | Validator has a pending withdrawal, or has some active stake but less than `MIN_STAKE` | -| `Jailed` | Reserved in storage and sync logic, but no public function currently places a validator into jail | -| `Blacklisted` | Owner-blocked state that disables stake, unstake, and withdrawal claim | - -### Validator record - -```solidity struct ValidatorInfo { - uint256 activeStake; - uint256 pendingWithdrawals; - uint64 withdrawAvailableAt; - uint64 jailedUntil; + uint256 activeStake; // backing current activity + uint256 pendingWithdrawals; // unbonding, still slashable + uint64 withdrawAvailableAt; // earliest claimUnstaked time + uint64 jailedUntil; // jail deadline ValidatorStatus status; } ``` -| Field | Meaning | -|---|---| -| `activeStake` | Stake currently backing validator activity | -| `pendingWithdrawals` | Unbonding stake still held by the contract | -| `withdrawAvailableAt` | Earliest timestamp when `claimUnstaked()` can succeed | -| `jailedUntil` | Timestamp checked by `_syncValidatorStatus()` if status is `Jailed` | -| `status` | Current lifecycle state | - -### Public mapping - -```solidity -mapping(address => ValidatorInfo) public validators; -``` - -Each validator address maps to its full staking record. +| Status | Meaning | +|--------|---------| +| `None` | No active stake, no pending withdrawal | +| `Active` | No pending withdrawal and `activeStake ≥ MIN_STAKE` — the only status eligible for new work (`isValidatorActive`) | +| `Exiting` | Has a pending withdrawal, or `0 < activeStake < MIN_STAKE` | +| `Jailed` | Set by `_jailInternal` (S5 escalation or `jailValidator`); persists while `jailedUntil > now` | +| `Blacklisted` | Owner-imposed; blocks `stake`, `unstake`, `claimUnstaked` and jailing | -## Access control +### Status synchronization (`_syncValidatorStatus`) -| Function group | Allowed caller | -|---|---| -| `stake`, `unstake`, `claimUnstaked` | Any address acting on its own validator record | -| `blacklistValidator`, `unblacklistValidator` | `owner()` | -| `addSlasherContract`, `removeSlasherContract` | `DIN_COORDINATOR` only | -| `slash` | Registered slasher contracts only | - -The contract uses two modifiers: - -- `onlyDinCoordinator`: reverts with `NotDINCoordinator()` unless `msg.sender == DIN_COORDINATOR`. -- `onlySlasherContract`: reverts with `NotSlasherContract()` unless `slasherContracts[msg.sender]` is `true`. +Run after every stake/unstake/claim/slash/unblacklist/reactivate: -`owner()` comes from `OwnableUpgradeable` and is set to the account that ran `initialize` (the deployer / DAO representative); it is transferable via `transferOwnership`. +1. `Blacklisted` → unchanged. +2. `Jailed` with `jailedUntil > now` → unchanged. +3. `pendingWithdrawals > 0` → `Exiting`. +4. else `activeStake ≥ MIN_STAKE` → `Active`. +5. else `activeStake > 0` → `Exiting`. +6. else → `None`. -## Ownership and upgradeability +Once a jail has expired, *any* call that syncs status (e.g. `stake`) recomputes the status from rules 3–6, so `reactivate()` is not the only way out of `Jailed` (see §13 No. 3). -Three privilege planes coexist: - -| Plane | Who | Controls | -|---|---|---| -| Contract owner (`owner()`) | `initialize` caller (deployer / DAO representative) | `blacklistValidator`, `unblacklistValidator` | -| `DIN_COORDINATOR` | `DinCoordinator` proxy | Slasher registry (`addSlasherContract` / `removeSlasherContract`) | -| Proxy admin (`ProxyAdmin` contract) | Deployed by the OZ upgrades plugin, owned by the deployer | Swapping the implementation behind the proxy | - -Upgrade mechanics: - -- **Proxy kind:** OpenZeppelin Transparent Proxy. The proxy address — where all staked DIN and validator records live — is permanent; upgrades replace only the code. -- **Upgrade path:** `CONTRACT=DinValidatorStake npx hardhat run scripts/upgrade-platform.ts --network `, which loads the proxy address from `hardhat/deployments/.json` and records the new implementation address there. -- **Storage-layout safety:** state may only be appended; the `__gap` array reserves headroom. `hardhat/test/DinValidatorStake.upgrade.test.ts` runs `upgrades.validateUpgrade` against a V2 fixture (`hardhat/contracts/upgrade/DinValidatorStakeV2.sol`) and asserts that stakes, pending withdrawals, statuses, the slasher registry, and access control all survive an upgrade. -- **Trust implication:** every invariant in this document (unbonding delay, slashing caps, blacklist behavior) holds only as long as the ProxyAdmin owner is honest — an upgrade can rewrite any of it while keeping custody of all staked funds. +--- -### Deployment position and wiring +## 5. Access Control -From `hardhat/scripts/deploy-platform.ts`, this contract is deployed **third**, because `initialize` needs both earlier proxies: +| Function group | Allowed caller | +|----------------|----------------| +| `stake`, `unstake`, `claimUnstaked`, `reactivate`, `registerEncryptionKey` | Any address, acting on its own record | +| `addSlasherContract`, `removeSlasherContract` | `DIN_COORDINATOR` only (`NotDINCoordinator`) | +| `slash`, `slashPartial`, `recordNoParticipation`, `jailValidator`, `incrementActiveRegistration`, `decrementActiveRegistration` | Registered slasher contracts only (`NotSlasherContract`) | +| `blacklistValidator`, `unblacklistValidator`, `setMinStake`, `setUnbondingPeriod`, `setModelStakeBounds`, `setMaxConcurrentRegistrationsPerStakeUnit`, `setSlashTreasury`, `setS5RecidivismParams`, `setS6NoParticipationThreshold` | `owner()` (DIN-Representative) | -``` -1. DinToken proxy initialize() -2. DinCoordinator proxy initialize(dinToken) -3. dinToken.setCoordinator(dinCoordinator) -4. DinValidatorStake proxy initialize(dinToken, dinCoordinator) ← this contract -5. dinCoordinator.updateValidatorStakeContract(dinValidatorStake) -6. DINModelRegistry proxy initialize(dinValidatorStake) -``` +--- -Until step 5, `DinCoordinator.addSlasherContract` / `removeSlasherContract` revert with `ValidatorStakeContractNotSet()`, so no slasher can be registered here. Staking itself (`stake`) works as soon as step 4 completes, provided validators hold DIN (which requires step 3). - -## Events - -| Event | Emitted when | -|---|---| -| `ValidatorStaked` | stake is added | -| `ValidatorSlashed` | a slash succeeds for a non-zero amount | -| `ValidatorUnstakeRequested` | an unstake request starts the unbonding period | -| `ValidatorWithdrawalClaimed` | pending stake is claimed after unbonding | -| `ValidatorBlacklisted` | owner blacklists a validator | -| `ValidatorUnblacklisted` | owner unblacklists a validator | -| `SlasherContractAdded` | coordinator authorizes a slasher | -| `SlasherContractRemoved` | coordinator removes a slasher | - -## Custom errors - -The contract uses custom errors instead of revert strings: - -- `NotDINCoordinator` -- `ValidatorIsBlacklisted` -- `ValidatorNotBlacklisted` -- `InvalidAddress` -- `NotSlasherContract` -- `AmountLessThanMinStake` -- `NotEnoughStake` -- `SlasherContractAlreadyAdded` -- `SlasherContractNotAdded` -- `InvalidSlashAmount` -- `InvalidUnstakeAmount` -- `PendingWithdrawalExists` -- `NoPendingWithdrawal` -- `WithdrawalNotReady` - -## Functional behavior +## 6. Staking Lifecycle ### `stake(uint256 amount)` - -Adds DIN stake for `msg.sender`. - -Behavior: - -- Reverts with `AmountLessThanMinStake()` if `amount < MIN_STAKE`. -- Reverts with `ValidatorIsBlacklisted()` if the validator is blacklisted. -- Transfers `amount` DIN from the caller to the contract. -- Increases `validators[msg.sender].activeStake`. -- Calls `_syncValidatorStatus(...)`. -- Emits `ValidatorStaked(msg.sender, amount)`. - -Notes: - -- Every deposit must be at least `MIN_STAKE`, even if the validator already has stake. -- A validator with enough active stake and no pending withdrawal becomes `Active`. +Reverts `AmountLessThanMinStake` if `amount < MIN_STAKE` (per call, not on the total) and `ValidatorIsBlacklisted` if blacklisted. Adds to `activeStake`, syncs status, pulls DIN via `safeTransferFrom` (validator must `approve` first), emits `ValidatorStaked`. ### `unstake(uint256 amount)` - -Starts an unbonding withdrawal for `msg.sender`. - -Behavior: - -- Reverts with `ValidatorIsBlacklisted()` if blacklisted. -- Reverts with `InvalidUnstakeAmount()` if `amount == 0`. -- Reverts with `PendingWithdrawalExists()` if there is already a pending withdrawal. -- Reverts with `NotEnoughStake()` if `activeStake < amount`. -- Decreases `activeStake` by `amount`. -- Sets `pendingWithdrawals = amount`. -- Sets `withdrawAvailableAt = uint64(block.timestamp + UNBONDING_PERIOD)`. -- Calls `_syncValidatorStatus(...)`. -- Emits `ValidatorUnstakeRequested`. - -Notes: - -- Only one pending withdrawal can exist per validator at a time. -- No tokens leave the contract during `unstake()`. -- If the remaining `activeStake` falls below `MIN_STAKE`, the validator becomes `Exiting`. +Reverts if blacklisted, `amount == 0` (`InvalidUnstakeAmount`), a withdrawal is already pending (`PendingWithdrawalExists` — only one at a time), or `activeStake < amount` (`NotEnoughStake`). Moves `amount` to `pendingWithdrawals`, sets `withdrawAvailableAt = now + UNBONDING_PERIOD`, syncs (→ `Exiting`), emits `ValidatorUnstakeRequested`. ### `claimUnstaked()` +Reverts if blacklisted, nothing pending (`NoPendingWithdrawal`), or before `withdrawAvailableAt` (`WithdrawalNotReady`). Pays out whatever remains pending (after any slashing), clears the withdrawal, syncs, emits `ValidatorWithdrawalClaimed`. -Claims matured pending withdrawals for `msg.sender`. - -Behavior: - -- Reverts with `ValidatorIsBlacklisted()` if blacklisted. -- Reverts with `NoPendingWithdrawal()` if `pendingWithdrawals == 0`. -- Reverts with `WithdrawalNotReady()` if `block.timestamp < withdrawAvailableAt`. -- Copies the pending amount to a local variable. -- Clears `pendingWithdrawals` and `withdrawAvailableAt`. -- Calls `_syncValidatorStatus(...)`. -- Transfers the pending DIN amount to the caller. -- Emits `ValidatorWithdrawalClaimed`. - -Notes: - -- This is the only function that releases unstaked funds from the contract. -- Once claimed, those tokens are no longer slashable by this contract. +### `reactivate()` +For a `Jailed` validator: reverts `NotJailed`, `JailPeriodNotExpired`, or `StakeBelowFloor` (`activeStake < MIN_STAKE`). Clears `jailedUntil`, syncs status, emits `ValidatorReactivated`. -### `slash(address validator, uint256 amount, bytes32 reason)` - -Reduces a validator's slashable stake. Callable only by an authorized slasher contract. - -Behavior: - -- Reverts with `InvalidAddress()` if `validator == address(0)`. -- Reverts with `InvalidSlashAmount()` if `amount == 0`. -- Reads the validator's total slashable stake as `activeStake + pendingWithdrawals`. -- Caps the slash to the validator's available slashable amount. -- Returns `0` immediately if nothing is slashable. -- Deducts from `activeStake` first. -- If needed, deducts the remainder from `pendingWithdrawals`. -- Sets `withdrawAvailableAt = 0` if pending withdrawals are fully consumed. -- Calls `_syncValidatorStatus(...)`. -- Emits `ValidatorSlashed`. -- Returns the actual slashed amount. - -Notes: - -- Slashing does not transfer, burn, or redistribute tokens in this contract. -- Slashed value remains held by the contract unless another mechanism is added elsewhere. +--- -### `addSlasherContract(address slasherContract)` +## 7. Slashing -Authorizes a slasher contract. Callable only by `DIN_COORDINATOR`. +### 7.1 Common mechanics (`_applySlash`) -Behavior: +- The slash is **capped** at `activeStake + pendingWithdrawals`; a zero result returns `0` without an event. +- Active stake is consumed first, then pending withdrawals (clearing `withdrawAvailableAt` if the withdrawal is wiped out). +- **Distribution:** `burn = amount / 2` is burned via `DinToken.burn`; the rest goes to `slashTreasury` via `safeTransfer`, or is also burned if `slashTreasury` is unset. +- Emits `ValidatorSlashed(validator, actualAmount, reason, slasher)` and returns the actual amount. -- Reverts with `InvalidAddress()` if `slasherContract == address(0)`. -- Reverts with `SlasherContractAlreadyAdded()` if already authorized. -- Sets `slasherContracts[slasherContract] = true`. -- Emits `SlasherContractAdded`. +### 7.2 `slash(validator, amount, reason)` — full severity -### `removeSlasherContract(address slasherContract)` +For faults like bad consensus or S3 score deviation. Reverts on zero address / zero amount (`InvalidSlashAmount`); otherwise `_applySlash`. -Removes slasher authorization. Callable only by `DIN_COORDINATOR`. +### 7.3 `slashPartial(validator, amount, reason, giIndex)` — S1/S2 with S5 recidivism -Behavior: +Used by the task contracts for liveness faults (auditor didn't reveal a vote — S1; aggregator didn't reveal a CID — S2). `amount` is computed by the caller as a fraction of the global `MIN_STAKE`: `minStake() × s1SlashFractionBps / 10 000` (auditor) or `× s2SlashFractionBps` (aggregator), with the fractions set per model on the task contracts. -- Reverts with `InvalidAddress()` if `slasherContract == address(0)`. -- Reverts with `SlasherContractNotAdded()` if not currently authorized. -- Sets `slasherContracts[slasherContract] = false`. -- Emits `SlasherContractRemoved`. +1. Appends `giIndex` to `_partialSlashGIs[validator][msg.sender]` and trims entries with `giIndex − entry ≥ s5RecidivismWindow`. +2. If the ring length reaches `s5RecidivismThreshold` → **S5 escalation**: slash a full `MIN_STAKE` (reason `S5_RECIDIVISM`), jail for `s5JailDuration`, emit `ValidatorEscalatedS5`, and clear this caller's ring. +3. Otherwise slash `amount` with the given reason. -### `blacklistValidator(address validator)` +The ring is namespaced by the **calling task contract** because `giIndex` is a per-model counter: keying by validator alone would interleave different models' GI sequences and break the ascending-order trim. The consequence is that recidivism is counted **per model**, not across models. -Owner-only emergency block on a validator. +### 7.4 `recordNoParticipation(validator, reason)` — S6 -Behavior: +Increments `s6NoParticipationCount[validator]` and emits `S6NoParticipationRecorded`. Below `s6NoParticipationThreshold` it returns `0`. At or above it, it slashes `MIN_STAKE × (count − threshold + 1) / 10`, capped at `MIN_STAKE` (10% more per breach), and emits `S6PartialSlashFired`. The count never resets. -- Reverts with `InvalidAddress()` if `validator == address(0)`. -- Sets `validators[validator].status = ValidatorStatus.Blacklisted`. -- Emits `ValidatorBlacklisted`. +> No contract in `foundry/src` currently calls `recordNoParticipation` — the task contracts deliberately skip it where `slashPartial` already applies ("No S6 recordNoParticipation here…"), so S6 is implemented but not wired (§13 No. 1). -Effects: +### 7.5 Jailing — `jailValidator(validator, duration, reason)` -- The validator cannot call `stake()`, `unstake()`, or `claimUnstaked()`. -- Existing funds remain in the contract. -- Slashing still works because `slash()` does not check the validator's status. +Slasher-only. Reverts on zero address or zero duration (`InvalidJailDuration`); jailing a blacklisted validator reverts `ValidatorIsBlacklisted`. Extends (never shortens) `jailedUntil`, sets `Jailed`, emits `ValidatorJailed`. No task contract calls it today; jails come from S5 escalation. -### `unblacklistValidator(address validator)` +--- -Removes blacklist status and restores the validator to the state implied by current balances and jail timing. +## 8. Registration Support for Task Contracts -Behavior: +- **Per-model floor:** at aggregator/auditor registration the task contracts revert `TC_/TA_StakeBelowModelFloor` if `getModelStakeMin(modelId) > 0` and `getStake(validator)` is below it. +- **Concurrency cap:** when `maxConcurrentRegistrationsPerStakeUnit > 0`, they revert `TC_/TA_ConcurrentRegistrationCapReached` if `activeRegistrationCount ≥ (getStake / minStake) × cap`. +- **Counter:** `incrementActiveRegistration` on registration, `decrementActiveRegistration` at GI end (saturates at zero); both emit an event. +- **Encryption keys:** `registerEncryptionKey(bytes pubkey)` stores a 32-byte X25519 key (`InvalidEncryptionKey` otherwise). `DINTaskAuditor` requires every auditor in a batch to have one before assigning encrypted test-data keys. -- Reverts with `InvalidAddress()` if `validator == address(0)`. -- Reverts with `ValidatorNotBlacklisted()` if current status is not `Blacklisted`. -- If `jailedUntil > block.timestamp`, sets status to `Jailed`. -- Otherwise sets status to `None`. -- Calls `_syncValidatorStatus(...)`. -- Emits `ValidatorUnblacklisted`. +--- -Result: +## 9. Blacklisting -- If jail is still active, the validator stays `Jailed`. -- Otherwise status recalculates to `Active`, `Exiting`, or `None` based on stake and pending withdrawals. +- `blacklistValidator(v)` sets `Blacklisted` unconditionally (even for addresses with no record). +- `unblacklistValidator(v)` reverts `ValidatorNotBlacklisted` if not blacklisted; restores `Jailed` if the jail is still running, otherwise `None`, then syncs. +- While blacklisted, `stake` / `unstake` / `claimUnstaked` revert, so funds are frozen — but the validator can still be slashed. -## View functions +--- -### `minStake()` +## 10. Views -Returns `MIN_STAKE`. +| Function | Returns | +|----------|---------| +| `minStake()` | `MIN_STAKE` | +| `isValidatorActive(v)` | `status == Active` | +| `getStake(v)` | `activeStake` | +| `slashableStakeOf(v)` | `activeStake + pendingWithdrawals` | +| `isSlasherContract(a)` | slasher flag | +| `getEncryptionKey(v)` | registered key or empty bytes | +| `getModelStakeMin(modelId)` | `modelMinStakeBounds[modelId].min` | +| `getPartialSlashGIs(v, slasher)` | the S5 ring for that validator/caller pair | -### `isValidatorActive(address validator)` +Plus the public getters for all state in §3. -- Returns `true` only if `validators[validator].status == ValidatorStatus.Active`. +--- -- Other contracts should treat `isValidatorActive(address)` as the canonical eligibility check. +## 11. Events & Errors -### `getStake(address validator)` +**Events:** `ValidatorStaked`, `ValidatorUnstakeRequested`, `ValidatorWithdrawalClaimed`, `ValidatorSlashed`, `ValidatorJailed`, `ValidatorReactivated`, `ValidatorEscalatedS5`, `S6NoParticipationRecorded`, `S6PartialSlashFired`, `ValidatorBlacklisted`, `ValidatorUnblacklisted`, `SlasherContractAdded`, `SlasherContractRemoved`, `ActiveRegistrationIncremented`, `ActiveRegistrationDecremented`, `EncryptionKeyRegistered`, `MinStakeUpdated`, `UnbondingPeriodUpdated`, `ModelStakeBoundsUpdated`, `MaxConcurrentRegistrationsPerStakeUnitUpdated`, `SlashTreasuryUpdated`, `S5RecidivismParamsUpdated`, `S6ParamsUpdated`. -Returns `validators[validator].activeStake`. +**Errors:** `NotDINCoordinator`, `NotSlasherContract`, `InvalidAddress`, `ValidatorIsBlacklisted`, `ValidatorNotBlacklisted`, `AmountLessThanMinStake`, `NotEnoughStake`, `InvalidUnstakeAmount`, `PendingWithdrawalExists`, `NoPendingWithdrawal`, `WithdrawalNotReady`, `InvalidSlashAmount`, `SlasherContractAlreadyAdded`, `SlasherContractNotAdded`, `InvalidJailDuration`, `NotJailed`, `JailPeriodNotExpired`, `StakeBelowFloor`, `InvalidMinStake`, `InvalidUnbondingPeriod`, `InvalidStakeBounds`, `InvalidEncryptionKey`, `InvalidS5Params`, `InvalidS6Params`. -This does not include `pendingWithdrawals`. +--- -### `slashableStakeOf(address validator)` +## 12. Deployment, Ownership & Upgradeability -Returns: +From `foundry/script/DeployPlatform.s.sol` (see [DeployPlatform](foundry/script/DeployPlatform.md)): -```solidity -validators[validator].activeStake + validators[validator].pendingWithdrawals ``` - -### `isSlasherContract(address slasherContract)` - -Returns whether the address is currently authorized to call `slash()`. - -## Status synchronization - -The contract derives validator state through the internal function: - -```solidity -function _syncValidatorStatus(ValidatorInfo storage validator) internal +7. DinValidatorStake proxy initialize(dinToken, dinCoordinator) ← this contract +8. dinCoordinator.updateValidatorStakeContract(dinValidatorStake) +9. dinValidatorStake.setSlashTreasury(dinTreasury) +10. DINModelRegistry proxy initialize(dinValidatorStake) ``` -Priority order: - -1. If status is `Blacklisted`, leave it unchanged. -2. If status is `Jailed` and `jailedUntil > block.timestamp`, leave it unchanged. -3. If `pendingWithdrawals > 0`, set status to `Exiting`. -4. Else if `activeStake >= MIN_STAKE`, set status to `Active`. -5. Else if `activeStake > 0`, set status to `Exiting`. -6. Else set status to `None`. - -This means: - -- A validator with any pending withdrawal is never `Active`. -- A validator with positive stake below `MIN_STAKE` is `Exiting`, not `None`. -- `Jailed` currently persists only if some external path has already set that status and the jail time is still active. - -## Practical implications - -- Validators must approve the DIN token before calling `stake()`. -- Partial exits are supported, but only one unbonding withdrawal can be pending at once. -- A validator can remain funded while no longer active if it falls below `MIN_STAKE`. -- Slashing applies to both active stake and unclaimed pending withdrawals. -- The contract currently has no public jail entrypoint and no mechanism that disposes of slashed tokens. - -## Workflow - -This section shows the normal validator workflow from staking to exit. - -### Validator Onboarding Workflow - -1. Validator obtains DIN. -2. Validator approves `DinValidatorStake` to spend DIN. -3. Validator calls `stake(amount)`. -4. Contract transfers DIN in and updates `activeStake`. -5. If stake is at least `MIN_STAKE`, status becomes `Active`. -6. Other DIN contracts query `isValidatorActive()` before allowing validator participation. - -### Validator Exit Workflow - -1. Validator calls `unstake(amount)`. -2. Contract moves `amount` from `activeStake` to `pendingWithdrawals`. -3. Contract sets `withdrawAvailableAt`. -4. Validator status becomes `Exiting`. -5. Validator is no longer eligible for new work. -6. During the unbonding period, slasher contracts may still slash the pending amount. -7. After the unbonding period, validator calls `claimUnstaked()`. -8. Contract transfers the remaining pending amount to the validator. -9. Status becomes `Active`, `Exiting`, or `None` depending on remaining stake. - - - -## Scenarios - -These examples show how the lifecycle behaves in practice. - -### Scenario 1: Normal Validator Entry - -- Validator stakes `20 DIN`. -- `activeStake = 20 DIN` -- `pendingWithdrawals = 0` -- status becomes `Active` - -Result: validator is eligible for new work. +Until step 8, slasher management through the coordinator reverts `ValidatorStakeContractNotSet`. Until step 9, both halves of every slash are burned. -### Scenario 2: Partial Exit with Remaining Active Stake - -- Validator starts with `30 DIN`. -- Validator calls `unstake(10 DIN)`. -- `activeStake = 20 DIN` -- `pendingWithdrawals = 10 DIN` -- status becomes `Exiting` - -Result: even though `activeStake` is still above `MIN_STAKE`, the validator is not active because an exit is in progress. - -### Scenario 3: Full Exit - -- Validator starts with `20 DIN`. -- Validator calls `unstake(20 DIN)`. -- `activeStake = 0` -- `pendingWithdrawals = 20 DIN` -- status becomes `Exiting` -- after 7 days, validator calls `claimUnstaked()` -- pending amount is transferred out -- status becomes `None` - -Result: validator fully exits only after the unbonding period. - -### Scenario 4: Slashed During Unbonding - -- Validator starts with `20 DIN`. -- Validator calls `unstake(10 DIN)`. -- now `activeStake = 10 DIN`, `pendingWithdrawals = 10 DIN` -- slasher contract later calls `slash(..., 15 DIN, reason)` - -Slash behavior: -- first `10 DIN` is removed from `activeStake` -- remaining `5 DIN` is removed from `pendingWithdrawals` - -Final state: -- `activeStake = 0` -- `pendingWithdrawals = 5 DIN` -- status remains `Exiting` - -Result: validator cannot escape penalties by exiting first. - -### Scenario 5: Claim After Partial Slash - -- Continuing Scenario 4 -- validator waits until `withdrawAvailableAt` -- validator calls `claimUnstaked()` -- only the remaining `5 DIN` is paid out - -Result: the validator receives whatever remains after slashing, not the original requested exit amount. - -### Scenario 6: Blacklisted Validator - -- Stake contract owner blacklists validator -- validator attempts `stake()` -- validator attempts `unstake()` -- validator attempts `claimUnstaked()` - -Result: those actions revert. Funds remain trapped unless governance introduces a separate recovery path in future logic. - -### Scenario 7: Validator Falls Below Minimum Stake Due to Slashing - -- Validator starts with `12 DIN` -- Slasher removes `3 DIN` -- `activeStake = 9 DIN` -- status becomes `Exiting` - -Result: validator is no longer eligible for new work because active stake fell below `MIN_STAKE`. +| Plane | Who | Controls | +|-------|-----|----------| +| `owner()` | DIN-Representative (`initialize` caller) | Parameters, blacklist, slash treasury | +| `DIN_COORDINATOR` | `DinCoordinator` proxy | Slasher registry | +| Slasher contracts | Each model's task contracts | Slashing, jailing, S6, registration counters | +| ProxyAdmin | One per proxy, owned by the deployer | Implementation upgrades | +- **Upgrade path:** `cd foundry && CONTRACT=DinValidatorStake forge script script/UpgradePlatform.s.sol ...` (see [UpgradePlatform](foundry/script/UpgradePlatform.md)); `foundry/test/UpgradeValidation.t.sol` runs `Upgrades.validateImplementation` on the implementation, and `DinValidatorStakeUpgradeTest` in `foundry/test/DeployPlatform.t.sol` upgrades to `foundry/src/upgrade/DinValidatorStakeV2.sol` and checks stakes and access control survive. +- **Trust implication:** this contract custodies all staked DIN; the ProxyAdmin owner can replace every rule here without moving the balance. --- -## Contract Interactions - -### With `DINTaskCoordinator` and `DINTaskAuditor` - -These contracts should: -- check `isValidatorActive()` before assigning or accepting validator work; -- use `minStake()` as the single source of stake threshold truth; -- call `slash()` only if they are registered as slasher contracts. - -### With `DINCoordinator` - -`DINCoordinator` is the administrative control point for: -- adding slashers; -- removing slashers. - -### With Frontends and Off-Chain Services - -Off-chain systems should distinguish: -- active stake: `getStake()`; -- total slashable stake: `slashableStakeOf()`; -- validator eligibility: `isValidatorActive()`; -- exit maturity: `validators[addr].withdrawAvailableAt`. - -### With Governance +## 13. Review Notes & Open Caveats -DinValidatorStake owner - ├── calls → DinValidatorStake.blacklistValidator() - └── calls → DinValidatorStake.unblacklistValidator() - - --- - -## Summary - -`DinValidatorStake` is not just a token vault. It is a validator lifecycle contract. - -Its main production-grade property is that exits are delayed and still slashable. That design closes the most dangerous staking failure mode: a validator doing work, misbehaving, and withdrawing before penalties can be enforced. +- **No. 1 — S6 is not wired:** `recordNoParticipation` exists and is tested, but no task contract calls it, so the S6 counter never moves in practice. +- **No. 2 — S5 escalation on a blacklisted validator reverts the whole slash:** escalation calls `_jailInternal`, which reverts `ValidatorIsBlacklisted`. A task contract's slashing loop hitting a blacklisted repeat offender would revert, not just skip that validator. +- **No. 3 — Jail exit does not require `reactivate()`:** after `jailedUntil` passes, any status-syncing call (e.g. `stake`) recomputes the status, bypassing `reactivate()`'s `StakeBelowFloor` check (the later sync still requires `≥ MIN_STAKE` for `Active`). +- **No. 4 — Stale NatSpec:** `setModelStakeBounds` / `setMaxConcurrentRegistrationsPerStakeUnit` say "not yet enforced", but the task contracts enforce both (§8). `getModelStakeMin` says "set by the model owner", but the setter is `onlyOwner` (DIN-Representative). `modelMinStakeBounds[].max` is never read. +- **No. 5 — Recidivism is per model:** the S5 ring is keyed by calling contract, so a validator faulting across many models never escalates unless it hits the threshold within one model. +- **No. 6 — Blacklisted funds are frozen:** blacklisted validators cannot unstake or claim; there is no recovery path other than unblacklisting. +- **No. 7 — Custody meets upgradeability:** see §12. --- -## Change Log +## 14. Change Log -### 2026-07 — Upgradeable conversion (PR 13) - -- Converted to a Transparent Proxy: `Ownable` → `Initializable` + `OwnableUpgradeable`; pragma bumped `^0.8.20` → `^0.8.28`. -- `constructor(dinToken, dinCoordinator)` replaced by a `_disableInitializers()` constructor plus `initialize(dinToken, dinCoordinator)` with the identical zero-address checks; `owner()` is set to the `initialize` caller. -- `DIN_TOKEN` and `DIN_COORDINATOR` lost `immutable` — now regular storage variables set once in `initialize` (names kept in SCREAMING_CASE). -- Added `uint256[50] __gap` storage reserve. -- **Zero logic changes:** all errors, events, constants, the `ValidatorStatus`/`ValidatorInfo` types, both modifiers, and every function body (`stake`, `unstake`, `claimUnstaked`, `slash`, slasher registry, blacklisting, views, `_syncValidatorStatus`) are unchanged. +### P3 — slashing, jailing, parameters (foundry) ---- +- Slashed DIN is now disposed of: 50% burned, 50% to `slashTreasury` (`setSlashTreasury`). +- Added `slashPartial` with S5 recidivism escalation (per-caller ring), `recordNoParticipation` (S6), `jailValidator` / `reactivate`. +- `MIN_STAKE` and `UNBONDING_PERIOD` became owner-settable storage; added per-model stake bounds, the concurrent-registration cap, the active-registration counter, and X25519 encryption-key registration. -## Review Notes & Open Caveats +### 2026-07 — Upgradeable conversion (PR 13) -- **No. 1 — Custody meets upgradeability:** this contract holds all staked DIN, and the ProxyAdmin owner can replace its logic (unbonding delay, slash caps, withdrawal rules) without touching the balance. The staking guarantees are only as strong as the upgrade keys — see [Ownership and upgradeability](#ownership-and-upgradeability). -- **No. 2 — SCREAMING_CASE without `immutable`:** `DIN_TOKEN` / `DIN_COORDINATOR` read as constants but are now plain storage; a future refactor touching them should not assume compile-time immutability. -- **No. 3 — Stateless re-entrancy guard is intentional:** the non-upgradeable `ReentrancyGuardTransient` is kept deliberately — it stores its lock in EIP-1153 transient storage and does not affect the proxy storage layout. -- **No. 4 — Pre-existing gaps unchanged by the conversion:** no public jail entrypoint, slashed tokens accumulate in the contract with no burn/redistribution, and blacklisted validators' funds remain trapped pending a governance recovery path. +- Converted to a Transparent Proxy (`Initializable` + `OwnableUpgradeable`, `_disableInitializers()` constructor, `initialize(dinToken, dinCoordinator)`); `DIN_TOKEN` / `DIN_COORDINATOR` lost `immutable`; added `__gap`. diff --git a/Documentation/technical/mechanisms/staking-mechanism.md b/Documentation/technical/mechanisms/staking-mechanism.md index 16638910..eadc71b0 100644 --- a/Documentation/technical/mechanisms/staking-mechanism.md +++ b/Documentation/technical/mechanisms/staking-mechanism.md @@ -25,36 +25,42 @@ That matters especially for exits, slashing, blacklisting, and controlled unblac --- -## Implemented Lifecycle and Slashing Semantics (verified July 19, 2026) +## Implemented Lifecycle and Slashing Semantics (verified against `foundry/src/` on 2026-09-30) ### Validator state machine ```mermaid stateDiagram-v2 - [*] --> Active : stake ≥ MIN_STAKE (10 DIN) - Active --> Exiting : unstake() → pending withdrawal\n(7-day unbonding, still slashable) + [*] --> Active : stake ≥ MIN_STAKE (default 10 DIN) + Active --> Exiting : unstake() → pending withdrawal\n(UNBONDING_PERIOD, default 7 days, still slashable) + Active --> Exiting : slashed below MIN_STAKE Exiting --> Active : re-stake above floor Exiting --> [*] : claimUnstaked() after unbonding + Active --> Jailed : S5 recidivism escalation\n(or jailValidator by a slasher) + Jailed --> Active : reactivate() after jail period\n(stake ≥ MIN_STAKE) Active --> Blacklisted : owner blacklistValidator() Exiting --> Blacklisted : owner blacklistValidator() - Blacklisted --> Active : owner unblacklistValidator()\n(via status sync) + Jailed --> Blacklisted : owner blacklistValidator() + Blacklisted --> Active : owner unblacklistValidator()\n(via status sync; Jailed if jail still running) ``` -`_syncValidatorStatus()` recomputes status on every state-modifying call: `Blacklisted` is sticky until unblacklisted; pending withdrawals force `Exiting`; `activeStake ≥ MIN_STAKE` gives `Active`; a nonzero balance below the floor is `Exiting`; zero is `None`. The `Jailed` status is defined and respected by the sync logic, but **no function currently sets it** — jailing is not reachable on `develop`. +`_syncValidatorStatus()` recomputes status on every state-modifying call: `Blacklisted` is sticky until unblacklisted; `Jailed` is sticky while `jailedUntil` is in the future; pending withdrawals force `Exiting`; `activeStake ≥ MIN_STAKE` gives `Active`; a nonzero balance below the floor is `Exiting`; zero is `None`. ### Implemented behaviour reference -| Mechanic | Behaviour on `develop` | +| Mechanic | Behaviour on `develop` (`foundry/src/`) | |---|---| -| Stake entry | `stake(amount)` requires `amount ≥ MIN_STAKE` (10 DIN constant), rejects blacklisted callers, transfers DIN in, syncs status | -| Unbonding | `unstake(amount)` moves stake to a **single** pending withdrawal with `withdrawAvailableAt = now + 7 days` (constant); `claimUnstaked()` pays out after maturity; a second `unstake` while one is pending reverts | +| Stake entry | `stake(amount)` requires `amount ≥ MIN_STAKE` (default 10 DIN, owner-settable via `setMinStake`), rejects blacklisted callers, transfers DIN in, syncs status | +| Unbonding | `unstake(amount)` moves stake to a **single** pending withdrawal with `withdrawAvailableAt = now + UNBONDING_PERIOD` (default 7 days, owner-settable, not retroactive); `claimUnstaked()` pays out after maturity | | Slashability window | `slashableStakeOf = activeStake + pendingWithdrawals` — exit does not escape slashing until the claim | -| Slashing | `slash(validator, amount, reason)` is **non-blocking**: caps at the slashable balance, consumes active stake before pending withdrawals, returns the actual amount, emits `ValidatorSlashed(validator, amount, reason, slasher)`. Only authorized slasher contracts may call it | -| Slashed-stake destination | None — slashed DIN remains inside the contract (a de-facto burn; an explicit destination is a design decision, see [`Developer/design/staking-design.md`](../../../Developer/design/staking-design.md)) | -| Slash amount policy | Task contracts slash a flat `minStake()` per offence (liveness faults only: missed audit vote, missed T1/T2 submission) | -| Eligibility gates | `DINTaskCoordinator` and `DINTaskAuditor` check `isValidatorActive()` at **registration, batch creation, and submission** — raw `getStake()` is informational only | -| Threshold source of truth | `minStake()` exposed via the `DINShared` interface; task contracts hold no independent stake-threshold copies | -| Blacklist | `blacklistValidator`/`unblacklistValidator` are direct `owner()` actions; blacklist freezes `stake`/`unstake`/`claimUnstaked` while funds remain slashable (details in the sections below) | +| Slashing | Non-blocking: caps at the slashable balance, consumes active stake before pending withdrawals, returns the actual amount, emits `ValidatorSlashed`. Only authorised slasher contracts may call it | +| Slashed-stake destination | **50% burned** (`DinToken.burn`), **50% to `slashTreasury`** (`DinTreasury`, wired at deploy); if the treasury is unset, both halves are burned | +| Liveness faults (S1/S2) | `slashPartial`: an audit vote that was never revealed (S1) or a T1/T2 aggregation CID that was never revealed (S2) costs `minStake × s1/s2SlashFractionBps` (default 30%). Committing without revealing counts the same as not committing; whether it should cost more is open in issue #201 | +| Recidivism (S5) | The same task contract recording `s5RecidivismThreshold` (3) partial slashes within `s5RecidivismWindow` (5) GIs escalates to a full `MIN_STAKE` slash plus a `s5JailDuration` (7 days) jail | +| Full-severity faults | `slash(minStake)`: aggregator bad consensus, S3 audit-score deviation (behind `s3SlashingEnabled`, off by default), S4 invalid aggregation / fresh-subgroup timeout | +| No-participation (S6) | `recordNoParticipation` exists (escalating 10%-per-breach slash past a threshold of 3) but **no task contract calls it** | +| Eligibility gates | Task contracts require `isValidatorActive()` at registration, commit/reveal and submission, and filter inactive registrants at batch creation. When configured they also enforce a per-model stake floor (`getModelStakeMin`) and a concurrent-registration cap (`(stake / minStake) × maxConcurrentRegistrationsPerStakeUnit`) | +| Blacklist | `blacklistValidator`/`unblacklistValidator` are direct `owner()` (DIN-Representative) actions; blacklist freezes `stake`/`unstake`/`claimUnstaked` while funds remain slashable | Per-contract references: [`DinValidatorStake.md`](../contracts/DinValidatorStake.md), [`DINTaskCoordinator.md`](../contracts/DINTaskCoordinator.md), [`DINTaskAuditor.md`](../contracts/DINTaskAuditor.md), [`DinCoordinator.md`](../contracts/DinCoordinator.md). @@ -71,31 +77,34 @@ The core staking and validator-lifecycle contract. It: - enforces delayed withdrawals with an unbonding period; - keeps pending withdrawals slashable; - exposes `isValidatorActive()` for downstream eligibility checks; +- splits every slash 50% burn / 50% treasury, tracks S5 recidivism (`slashPartial`) and jails on escalation; +- stores the governable parameters task contracts read (per-model stake floors, concurrent-registration cap, S5/S6 settings) and validators' X25519 encryption keys; - allows emergency blacklist and restoration through direct owner authority. ### `DINTaskCoordinator.sol` Manages Aggregator participation and task-round execution. It should: - use `isValidatorActive()` when checking whether a validator may register or keep participating; -- call `slash()` when Aggregators fail liveness or correctness requirements; +- call `slashPartial()` for missed submissions (S2) and `slash()` for bad consensus or S4 dispute outcomes; - refuse blacklisted, jailed, or exiting validators through the shared stake state. ### `DINTaskAuditor.sol` Manages Auditor participation. It should: - use `isValidatorActive()` as the role eligibility gate; -- call `slash()` for Auditor faults; +- call `slashPartial()` for missed votes (S1) and `slash()` for S3 score deviation; - refuse blacklisted, jailed, or exiting validators via stake state. ### `DinCoordinator.sol` The coordinator is the admin-facing control point for staking-related policy wiring. In the current architecture it: -- mints DIN against ETH deposits; +- mints DIN against ETH deposits (faucet) and for the emission subsidy (`mintEmission`, from `DinEmission`), under an optional `mintCap`; +- forwards faucet ETH to `DinFeeRouter`; - registers or removes authorized slasher contracts on `DinValidatorStake`. Blacklist and unblacklist actions are not routed through `DinCoordinator` in the current implementation. They are direct `owner()` actions on `DinValidatorStake`. -### Governance / DAO Admin +### Governance (DIN-Representative today) Governance should not directly act as a slasher for routine faults. Routine faults belong in task contracts via `slash()`. @@ -164,9 +173,9 @@ Those role contracts should: 1. Validator misses work, submits invalid work, or fails a consensus rule. 2. Authorized task contract determines slash amount and reason. -3. Task contract calls `DinValidatorStake.slash()`. +3. Task contract calls `DinValidatorStake.slashPartial()` (liveness faults, with S5 recidivism escalation) or `slash()` (full-severity faults). 4. Slash is applied first against `activeStake`, then against `pendingWithdrawals` if needed. -5. Validator status is resynchronized. +5. Half the slashed DIN is burned, half sent to `DinTreasury`; validator status is resynchronized (or set to `Jailed` on S5 escalation). 6. Validator may lose `Active` status if stake falls too low or if exit is already in progress. ### 4. Exit / Unbonding @@ -235,12 +244,12 @@ In `DinValidatorStake.sol`: ### What Does Not Exist Today - no governance review flow on-chain; -- no public jail-to-blacklist escalation path; +- no automatic jail-to-blacklist escalation (jailing comes only from S5 escalation; blacklisting is a manual owner action); - no release policy for funds locked under blacklist status. ### Important Architecture Note -`DinValidatorStake` still uses `DIN_COORDINATOR` for slasher management, but blacklist and unblacklist authority now lives directly on the stake contract owner. This is the intended current model: DIN admin now, DAO-governed owner later. +`DinValidatorStake` still uses `DIN_COORDINATOR` for slasher management, but blacklist and unblacklist authority now lives directly on the stake contract owner. This is the intended current model: the DIN-Representative's single admin key now; on-chain DIN-DAO governance is deferred to post-mainnet. --- diff --git a/Documentation/technical/storage_layout.md b/Documentation/technical/storage_layout.md index c6e08d3d..0582e099 100644 --- a/Documentation/technical/storage_layout.md +++ b/Documentation/technical/storage_layout.md @@ -6,7 +6,10 @@ upgradeable platform contracts: `DinToken`, `DinCoordinator`, `DinValidatorStake `DinFairLaunchDistributor`. All follow the OpenZeppelin Transparent Proxy pattern (`Initializable`, `OwnableUpgradeable`) and end their own block with a `uint256[50] private __gap` reservation to allow safe future additions. Contract-own slot numbers below are from -`forge inspect storageLayout`. +`forge inspect storageLayout`. OpenZeppelin v5 upgradeable bases +(`Initializable`, `OwnableUpgradeable`, `ERC20Upgradeable`) keep their state in +namespaced ERC-7201 storage, so they do not occupy these sequential slots; they are +listed in brackets for completeness. --- @@ -25,9 +28,10 @@ yet, so this repo currently adds new variables above `__gap` without shrinking i there's no deployed slot layout to preserve. Start shrinking `__gap` per addition as soon as a proxy is actually deployed and holds state worth preserving. -**Inherited slots are fixed.** Slots occupied by OpenZeppelin base contracts -(`_initialized`, `_owner`, etc.) are determined by their upstream storage layout and -must not be touched. +**Inherited storage is namespaced.** OpenZeppelin v5 upgradeable bases +(`_initialized`, `_owner`, ERC-20 balances, etc.) live at ERC-7201 namespaced +locations determined upstream, not in the contract's sequential slots. They must not +be touched, and they do not shift when contract-own variables are added. **`ReentrancyGuardTransient` is slot-neutral.** `DinCoordinator`, `DinValidatorStake`, `DinEmission`, `DinFeeRouter`, `DinTreasury`, @@ -42,33 +46,22 @@ storage slots. ## DinToken ``` -[Initializable] - _initialized : uint64 (packed with _initializing bool) -[OwnableUpgradeable] - _owner : address -[ERC20Upgradeable] - _balances : mapping(address => uint256) - _allowances : mapping(address => mapping(address => uint256)) - _totalSupply : uint256 - _name : string - _symbol : string +[Initializable] (ERC-7201 namespaced) +[ERC20Upgradeable] (ERC-7201 namespaced: balances, allowances, totalSupply, name, symbol) +[OwnableUpgradeable] (ERC-7201 namespaced: _owner) ─────────────────────────────────── contract-own slots ─── - coordinator : address - __gap : uint256[50] ← 50 reserved slots + coordinator : address slot 0 + __gap : uint256[50] slots 1–50 ``` `setCoordinator` is one-shot; `coordinator` will not change after initial wiring. -Future variables must be inserted above `__gap`, reducing its size accordingly. --- ## DinCoordinator ``` -[Initializable] - _initialized : uint64 -[OwnableUpgradeable] - _owner : address +[Initializable] [OwnableUpgradeable] (ERC-7201 namespaced) ─────────────────────────────────── contract-own slots ─── dinToken : DinToken (slot 0) dinValidatorStakeContract : IDinValidatorStake (slot 1) @@ -92,10 +85,7 @@ above for why that's fine pre-deployment. ## DinValidatorStake ``` -[Initializable] - _initialized : uint64 -[OwnableUpgradeable] - _owner : address +[Initializable] [OwnableUpgradeable] (ERC-7201 namespaced) ─────────────────────────────────── contract-own slots ─── DIN_TOKEN : IERC20 (slot 0) DIN_COORDINATOR : address (slot 1) @@ -131,10 +121,7 @@ contract's top-level slot numbering. ## DINModelRegistry ``` -[Initializable] - _initialized : uint64 -[OwnableUpgradeable] - _owner : address +[Initializable] [OwnableUpgradeable] (ERC-7201 namespaced) [ReentrancyGuardTransient] (transient lock only — no persistent slot) ─────────────────────────────────── contract-own slots ─── @@ -163,10 +150,7 @@ Ownership is plain `OwnableUpgradeable` — there is no separate `daoAdmin` slot ## DinEmission ``` -[Initializable] - _initialized : uint64 -[OwnableUpgradeable] - _owner : address +[Initializable] [OwnableUpgradeable] (ERC-7201 namespaced) [ReentrancyGuardTransient] (transient lock only — no persistent slot) ─────────────────────────────────── contract-own slots ─── @@ -187,10 +171,7 @@ Ownership is plain `OwnableUpgradeable` — there is no separate `daoAdmin` slot ## DinFeeRouter ``` -[Initializable] - _initialized : uint64 -[OwnableUpgradeable] - _owner : address +[Initializable] [OwnableUpgradeable] (ERC-7201 namespaced) [ReentrancyGuardTransient] (transient lock only — no persistent slot) ─────────────────────────────────── contract-own slots ─── @@ -215,10 +196,7 @@ to 16 × `uint16`), but inserting a variable between `treasury` and ## DinTreasury ``` -[Initializable] - _initialized : uint64 -[OwnableUpgradeable] - _owner : address +[Initializable] [OwnableUpgradeable] (ERC-7201 namespaced) [ReentrancyGuardTransient] (transient lock only — no persistent slot) ─────────────────────────────────── contract-own slots ─── @@ -233,10 +211,7 @@ whole block is reserved. ## DinFairLaunchDistributor ``` -[Initializable] - _initialized : uint64 -[OwnableUpgradeable] - _owner : address +[Initializable] [OwnableUpgradeable] (ERC-7201 namespaced) [ReentrancyGuardTransient] (transient lock only — no persistent slot) ─────────────────────────────────── contract-own slots ─── diff --git a/Documentation/technical/testing/dincli-testing-guide.md b/Documentation/technical/testing/dincli-testing-guide.md index 24d436f7..831fdfd5 100644 --- a/Documentation/technical/testing/dincli-testing-guide.md +++ b/Documentation/technical/testing/dincli-testing-guide.md @@ -1,15 +1,23 @@ # dincli Integration Test Guide This guide explains how to run the `tests/dincli/` integration test harness -against a local Hardhat node, and documents the architecture of the harness -itself. +against a local chain, and documents the architecture of the harness itself. + +The chain backend and platform deploy script are chosen by +`PLATFORM_DEPLOY_TOOLCHAIN` (`tests/dincli/constants.py`): **`foundry` +(default)** runs Anvil (`foundry/anvil.sh`) and `foundry/script/DeployPlatform.s.sol`; +`hardhat` runs a Hardhat node and `hardhat/scripts/deploy-platform.ts`. Both use +chain ID 1337 on `http://127.0.0.1:8545`. + +> [!WARNING] +> `foundry/anvil.sh` starts Anvil with `--code-size-limit 4294967295`, so the local chain accepts contracts above the 24,576-byte EIP-170 limit. `DINTaskCoordinator` is currently over that limit (issue #201), so a green local run does not show that a contract can be deployed to a real chain. --- ## Quick start -The harness is self-contained. All prerequisites (contract compilation, Hardhat -node, IPFS daemon) are managed automatically by the conftest. The only manual +The harness is self-contained. All prerequisites (contract compilation, the +local chain node, IPFS daemon) are managed automatically by the conftest. The only manual requirement is Docker — it must be running before Phase 4 client training begins. ```bash @@ -27,8 +35,11 @@ python -m pytest tests/dincli/test_01_platform.py \ ``` That's it. The conftest will: -- Compile all Solidity contracts via `npx hardhat compile` -- Kill any existing Hardhat node and start a fresh one (clean EVM state) +- Compile contracts: `npx hardhat compile` always (the task-contract deploy and + `dump-abi` tests still use Hardhat artifacts), plus `forge build` for the + default `foundry` toolchain +- Kill any existing chain node and start a fresh one (clean EVM state): Anvil + for `foundry`, a Hardhat node for `hardhat` - Start the IPFS daemon if it is not already running - Restore `dincli/config/din_info.json` to its committed state after the run @@ -42,11 +53,13 @@ that isolates dependencies and coordinates services. ### Managed services (`managed_services` fixture) Before any test runs, the fixture does the following: -1. **Solidity compilation** — runs `npx hardhat compile` inside - `/path/to/devnet/hardhat` to generate fresh contract ABIs. -2. **Fresh Hardhat node** — kills any active node process on port `8545` and - launches a clean local node (`npx hardhat node`) with the customized - account count. +1. **Solidity compilation** — runs `npx hardhat compile` in `hardhat/` (the + task-contract deploys and `dump-abi` tests use Hardhat artifacts), and + `forge build` in `foundry/` when the toolchain is `foundry` (the platform is + deployed from `foundry/out/` via `DeployPlatform.s.sol`). +2. **Fresh chain node** — kills any running node and launches a clean one on + port `8545`: `foundry/anvil.sh` (70 accounts, chain ID 1337) for `foundry`, + or `npx hardhat node` for `hardhat`. 3. **IPFS daemon** — checks if the IPFS API (`http://127.0.0.1:5001`) is running, starting it (`ipfs daemon`) if not. If it was already running externally, it is left running at teardown. @@ -118,11 +131,13 @@ between them automatically per command — no manual activation needed: `TORCHENV_PYTHON` / `PYDIN_PYTHON` are defined in `tests/dincli/constants.py`. If you see torch import errors in Phase 4, confirm those paths are correct. -### Hardhat accounts +### Chain accounts -The GI harness uses accounts 0–22 and 50–58 (59 accounts total). Hardhat's -default is 20 accounts. `hardhat.config.ts` must have `accounts.count` set to -at least 60. If you see an "account index out of range" error: +The GI harness uses accounts 0–22 and 50–58 (59 accounts total). +`foundry/anvil.sh` already starts Anvil with `--accounts 70`. For the +`hardhat` toolchain, Hardhat's default is 20 accounts, so `hardhat.config.ts` +must set `accounts.count` to at least 60. If you see an "account index out of +range" error there: ```ts // hardhat/hardhat.config.ts @@ -315,7 +330,9 @@ All output is written to `~/tempdir/dincli/`: |------|----------| | `results/last_run.txt` | Full pytest output of the most recent run | | `results/hardhat_compile.log` | `npx hardhat compile` output | -| `results/hardhat_node.log` | Hardhat node stdout/stderr | +| `results/forge_build.log` | `forge build` output (`foundry` toolchain) | +| `results/anvil_node.log` | Anvil stdout/stderr (`foundry` toolchain) | +| `results/hardhat_node.log` | Hardhat node stdout/stderr (`hardhat` toolchain) | | `results/ipfs_daemon.log` | IPFS daemon stdout/stderr (if started by conftest) | | `config/` | Isolated dincli config for the test session | | `cache/` | Isolated dincli cache for the test session | @@ -327,8 +344,8 @@ All output is written to `~/tempdir/dincli/`: **Docker not running** — Phase 4 client training uses containerised execution. Start Docker before running the suite. -**Hardhat node failed to start** — check -`~/tempdir/dincli/results/hardhat_node.log`. +**Chain node failed to start** — check +`~/tempdir/dincli/results/anvil_node.log` (default) or `hardhat_node.log`. **IPFS not responding** — check `~/tempdir/dincli/results/ipfs_daemon.log`. diff --git a/Documentation/technical/upgradable-contracts/proxy-deployment-architecture.md b/Documentation/technical/upgradable-contracts/proxy-deployment-architecture.md index 5e3ca5f8..1e3b080d 100644 --- a/Documentation/technical/upgradable-contracts/proxy-deployment-architecture.md +++ b/Documentation/technical/upgradable-contracts/proxy-deployment-architecture.md @@ -5,6 +5,13 @@ **Decides:** How `dincli` deploys the four upgradeable platform contracts (`DinToken`, `DinCoordinator`, `DinValidatorStake`, `DINModelRegistry`), and why the Hardhat-vs-Foundry question is almost entirely irrelevant to that decision. **Related:** [`hardhat/README.md`](./hardhat/README.md) (contract design), [`storage_layout.md`](../storage_layout.md), `Developer/discussion/migrate_to_foundy.md` +> **Status update (2026-09-25).** The decision below still stands, but some of the context around it has changed: +> - Foundry (`foundry/src/`) is now the reference implementation. It has **seven** platform proxies (plus `DinTreasury`, `DinFeeRouter`, `DinEmission`) and its own `DeployPlatform.s.sol` / `UpgradePlatform.s.sol` — see [`foundry/README.md`](./foundry/README.md). +> - `hardhat/` was kept as a secondary toolchain rather than deleted. The two contract trees are **no longer byte-identical**: Hardhat lags Foundry. +> - The interim flow is `forge script DeployPlatform.s.sol` followed by `dincli system import-deployments` (Foundry by default). dincli's old constructor-based `dinrep deploy` commands have been removed; native proxy deployment is still the target ([dincli-native-proxy-deployment.md](../../../Developer/issues/dincli-native-proxy-deployment.md)). +> +> Statements below about "the four contracts", identical trees, or Hardhat's pending deletion reflect 2026-07-06. + --- ## 1. The decision in one paragraph From 2f04896eae6be54b8f5d379ae78ddee2451aafef Mon Sep 17 00:00:00 2001 From: umermjd11 Date: Wed, 30 Sep 2026 18:16:42 +0500 Subject: [PATCH 4/5] chore(hardhat): remove the unused DAOAdminUpdated event (#203) setDAOAdmin() was its only emitter and went with the shims. The hardhat docs no longer say the declaration remains. Co-Authored-By: Claude Opus 5.5 --- .../contracts/hardhat/tests/DINModelRegistry.upgrade.test.md | 2 +- Documentation/technical/upgradable-contracts/hardhat/README.md | 2 +- .../hardhat/test/DINModelRegistry.upgrade.test.md | 2 +- hardhat/contracts/DINModelRegistry.sol | 1 - 4 files changed, 3 insertions(+), 4 deletions(-) diff --git a/Documentation/technical/contracts/hardhat/tests/DINModelRegistry.upgrade.test.md b/Documentation/technical/contracts/hardhat/tests/DINModelRegistry.upgrade.test.md index 61801cbe..117ed9ff 100644 --- a/Documentation/technical/contracts/hardhat/tests/DINModelRegistry.upgrade.test.md +++ b/Documentation/technical/contracts/hardhat/tests/DINModelRegistry.upgrade.test.md @@ -72,5 +72,5 @@ so it can never have an owner or wired stake contract — the contract is only ## 5. Coverage Gaps - Manifest update requests, the kill switch (`disableModel`), and `withdrawFees` are not exercised across an upgrade. -- The former `daoAdmin()` / `setDAOAdmin()` compatibility shims have been removed from `hardhat/contracts/DINModelRegistry.sol` (the unused `DAOAdminUpdated` event declaration remains). +- The former `daoAdmin()` / `setDAOAdmin()` compatibility shims and the `DAOAdminUpdated` event have been removed from `hardhat/contracts/DINModelRegistry.sol`. - No functional (non-upgrade) suite exists for the registry at all — request/approve/reject edge cases are only covered incidentally by this file. diff --git a/Documentation/technical/upgradable-contracts/hardhat/README.md b/Documentation/technical/upgradable-contracts/hardhat/README.md index c80f8f8c..3e9e2ae3 100644 --- a/Documentation/technical/upgradable-contracts/hardhat/README.md +++ b/Documentation/technical/upgradable-contracts/hardhat/README.md @@ -108,7 +108,7 @@ Model registration requests, manifest updates, fee tiers, and per-model disable - **Registration is request/approve:** `requestModelRegistration` (fee-paying) validates that both task contracts are currently authorized slashers and owned by the requester; `approveModel` (owner-only) **re-validates all four conditions at approval time** — slasher status or task-contract ownership changing between request and approval causes a typed revert (`CoordinatorNoLongerSlasher`, `AuditorOwnershipChanged`, …). This closes the TOCTOU gap between submission and review. - **Manifest updates** follow the same request/approve pattern with their own fee tier, gated by `onlyModelOwner` + `notDisabled`. - **Fees:** individual setters plus an atomic `setFees(...)`; `withdrawFees` uses the `call{value:}` + `TransferFailed` pattern. -- **Access model.** The pre-upgrade contract used a bespoke `daoAdmin` field; the upgradeable version standardizes on `OwnableUpgradeable` (`transferOwnership`). The `daoAdmin()` / `setDAOAdmin()` compatibility shims it originally kept have since been removed (the unused `DAOAdminUpdated` event declaration remains). +- **Access model.** The pre-upgrade contract used a bespoke `daoAdmin` field; the upgradeable version standardizes on `OwnableUpgradeable` (`transferOwnership`). The `daoAdmin()` / `setDAOAdmin()` compatibility shims it originally kept, and the `DAOAdminUpdated` event only `setDAOAdmin()` emitted, have since been removed. ## 5. Deployment order and wiring diff --git a/Documentation/technical/upgradable-contracts/hardhat/test/DINModelRegistry.upgrade.test.md b/Documentation/technical/upgradable-contracts/hardhat/test/DINModelRegistry.upgrade.test.md index 53178917..ec2c9d0e 100644 --- a/Documentation/technical/upgradable-contracts/hardhat/test/DINModelRegistry.upgrade.test.md +++ b/Documentation/technical/upgradable-contracts/hardhat/test/DINModelRegistry.upgrade.test.md @@ -42,7 +42,7 @@ Raw implementation deploy; `initialize(signer.address)` must revert with `Invali ## Not covered here (by design) -The manifest-update request flow has no dedicated upgrade test — it uses the same request-array pattern proven in Test 3, and is exercised at the CLI integration layer (`tests/dincli/test_03_registration.py`). (The former `daoAdmin()` / `setDAOAdmin()` compatibility shims have been removed from the contract.) +The manifest-update request flow has no dedicated upgrade test — it uses the same request-array pattern proven in Test 3, and is exercised at the CLI integration layer (`tests/dincli/test_03_registration.py`). (The former `daoAdmin()` / `setDAOAdmin()` compatibility shims and the `DAOAdminUpdated` event have been removed from the contract.) ## What V2 is diff --git a/hardhat/contracts/DINModelRegistry.sol b/hardhat/contracts/DINModelRegistry.sol index 5a0609e9..4434fc2f 100644 --- a/hardhat/contracts/DINModelRegistry.sol +++ b/hardhat/contracts/DINModelRegistry.sol @@ -65,7 +65,6 @@ contract DINModelRegistry is Initializable, OwnableUpgradeable { uint256 proprietaryUpdateFee ); event FeesWithdrawn(address indexed to, uint256 amount); - event DAOAdminUpdated(address indexed oldAdmin, address indexed newAdmin); struct Model { address owner; From 82abbcdafe025e1885aa740ecf528ce70ac695f5 Mon Sep 17 00:00:00 2001 From: umermjd11 Date: Wed, 30 Sep 2026 18:16:42 +0500 Subject: [PATCH 5/5] docs: apply issue No. 203 review amendments (#203) - DinValidatorStake.md, staking-mechanism.md: every mention of the 50/50 slash split now says the treasury half is also burned when no treasury is set. - ROADMAP P4-IDX2: refer to list_pending_requests by name, not by a line range that shifts. - dincli-native-proxy-deployment.md: "Interim state" describes the Foundry flow (DeployPlatform.s.sol, then import-deployments) as the current one, with the Hardhat script as the secondary path. Co-Authored-By: Claude Opus 5.5 --- Developer/ROADMAP.md | 2 +- .../issues/dincli-native-proxy-deployment.md | 30 +++++++++---------- .../technical/contracts/DinValidatorStake.md | 4 +-- .../technical/mechanisms/staking-mechanism.md | 4 +-- 4 files changed, 20 insertions(+), 20 deletions(-) diff --git a/Developer/ROADMAP.md b/Developer/ROADMAP.md index 35661a92..5f22f955 100644 --- a/Developer/ROADMAP.md +++ b/Developer/ROADMAP.md @@ -167,7 +167,7 @@ By the end of P4, DIN has an operational daemon capable of autonomous task disco | P4-9.2 | Daemon | Daemon Release (dind v1.0.0) | Package `dind` binary and Docker image alongside `din-node` · Installation and usage documentation · Release notes · Community onboarding materials | Public release. | P4-9.1 | Critical | Long-term (P4) | | 1 week | | Nov 23–27, 2026 | | Santiago | | 📋 Planned | P4 Week 13. | | **— P4: Core Protocol / On-chain Indexer (Robbert) —** | | | | | | | | | | | | | | | | | | P4-IDX1 | Core Protocol / Contracts | On-chain Indexer Design | Choose indexing approach: The Graph Protocol subgraph (preferred — Robbert has production experience) vs. lighter alternative (Ponder, custom event-poller) — document tradeoff · Design entity schema: queryable entities for all 4 platform contract event streams (`ModelRegistered`, `ValidatorSlashed`, `RewardClaimed`, `GIStarted`/`GIEnded`) · Write `subgraph.yaml` and `schema.graphql` · Set up local Graph node against Hardhat local chain for development testing · Design P4 daemon event schema: which events `dind` needs to subscribe to, what current coverage gaps exist (feeds into P4-7.1) | Foundation for replacing RPC-loop call sites in `dincli` with indexer-backed queries. Dynamic data sources for task-level contracts (`DINTaskCoordinator`/`DINTaskAuditor`) are deferred to P5+ (materially harder pattern). | P3-6.3b (stable contract ABIs), P3-PR13 (proxy contracts deployed) | High | Medium-term | | 1 week | | Oct 2026 | | Robbert | | ⚡ In Progress | Implemented in PR [#29](https://github.com/InfiniteZeroFoundation/DevNet/pull/29) (schema, 34 entities) + [#72](https://github.com/InfiniteZeroFoundation/DevNet/pull/72) (P3-staking event wiring, verified byte-for-byte against `DinValidatorStake.sol`), against `feat/din-indexer` — not yet merged to `develop` (no `subgraph/` there today). PR #29 has two small, real blockers: invalid GraphQL syntax at `schema.graphql:412` breaking `graph codegen`, and a broken (non-`CliRunner`) test file; full detail in [discussion #139](https://github.com/InfiniteZeroFoundation/DevNet/discussions/139). Dynamic task-contract indexing, originally deferred to P5+, was reclassified in-scope (Umer, Jul 13) but the underlying contract events it needs (`LocalModelSubmitted`, `T1AggregationSubmitted`, etc.) don't exist anywhere in `foundry/src` yet and have no owner — the one piece of #24 that's unstarted, not just unmerged. | -| P4-IDX2 | Core Protocol / Contracts | On-chain Indexer Implementation | Implement AssemblyScript (or TypeScript for Ponder) mapping handlers for all platform contract events · Deploy subgraph to local Graph node · Verify all event entities index correctly against test transactions · Replace `dincli/cli/dinrep.py` pending-request enumeration loop (~lines 378–406) with indexer-backed query (clearest candidate: `for idx in range(totalModelRequests)`) · Document: setup steps, local run instructions, ≥3 example queries covering validator registry, model registry, and reward history | Converts the most expensive RPC-polling loop to an indexed query. Other candidates for follow-up: `dincli/cli/modelownerd/lms.py` ~56–68 and `aggregation.py` ~76–144. | P4-IDX1 | High | Short-term (P4) | | 2 weeks | | Oct–Nov 2026 | | Robbert | | ⚡ In Progress | Handlers implemented in PR [#29](https://github.com/InfiniteZeroFoundation/DevNet/pull/29)/[#72](https://github.com/InfiniteZeroFoundation/DevNet/pull/72) (31 handlers across 4 data sources, local Graph node via `docker-compose`, 14+ example queries) — `dincli/cli/dinrep.py`'s RPC-loop replacement is done in the PR, just not merged to `develop` yet. Recommended merge order (#29 fixes first, then #72) in [discussion #139](https://github.com/InfiniteZeroFoundation/DevNet/discussions/139). 5 of the 31 handlers are commented out pending the unowned contract-event work noted in P4-IDX1. | +| P4-IDX2 | Core Protocol / Contracts | On-chain Indexer Implementation | Implement AssemblyScript (or TypeScript for Ponder) mapping handlers for all platform contract events · Deploy subgraph to local Graph node · Verify all event entities index correctly against test transactions · Replace the pending-request enumeration loops in `list_pending_requests` (`dincli/cli/dinrep.py`) with indexer-backed query (clearest candidate: `for idx in range(totalModelRequests)`) · Document: setup steps, local run instructions, ≥3 example queries covering validator registry, model registry, and reward history | Converts the most expensive RPC-polling loop to an indexed query. Other candidates for follow-up: `dincli/cli/modelownerd/lms.py` ~56–68 and `aggregation.py` ~76–144. | P4-IDX1 | High | Short-term (P4) | | 2 weeks | | Oct–Nov 2026 | | Robbert | | ⚡ In Progress | Handlers implemented in PR [#29](https://github.com/InfiniteZeroFoundation/DevNet/pull/29)/[#72](https://github.com/InfiniteZeroFoundation/DevNet/pull/72) (31 handlers across 4 data sources, local Graph node via `docker-compose`, 14+ example queries) — `dincli/cli/dinrep.py`'s RPC-loop replacement is done in the PR, just not merged to `develop` yet. Recommended merge order (#29 fixes first, then #72) in [discussion #139](https://github.com/InfiniteZeroFoundation/DevNet/discussions/139). 5 of the 31 handlers are commented out pending the unowned contract-event work noted in P4-IDX1. | | P4-IDX3 | Core Protocol / Contracts | Indexer Integration + P3 Docs Wrap-up | Wire indexer into `dincli` test suite · Verify replaced RPC-loop call site passes tests against indexed local node · Address any open audit findings requiring contract changes · Finalize any outstanding P3 public documentation · Handoff document: what P4 contract work follows (task-level contract indexing, event schema extensions for `dind`) | Completes contract-side P4 integration and closes all P3 documentation gaps. | P4-IDX2, P3-6.3b | High | Long-term (P4) | | 1 week | | Nov 2026 | | Robbert | | 📋 Planned | Blocked behind P4-IDX1/IDX2 actually merging to `develop` first — neither has, as of Sep 11, 2026. | | **— P4: Tokenomics / Fair Launch —** | | | | | | | | | | | | | | | | | | P4-FL1 | Cryptoeconomics / Tokenomics | Fair-Launch Validator Token Distributor (contract slice) | Merkle-drop claim contract (Transparent Proxy, treasury-Safe admin) · Funded once from treasury · Cliff + linear vesting on claim | Abraham's fair-launch vision: no ICO/pre-sale/VC allocation, DIN distributed to validators/active participants only. Resolves the ICO-vs-airdrop fork of [MECHANISM_DESIGN §9 item 14](design/MECHANISM_DESIGN.md#9-consolidated-open-decision-list) in favor of the airdrop path. Was not in this table at all — tracked only via [BL-18](BACK_LOG.md) and [issue #75](https://github.com/InfiniteZeroFoundation/DevNet/issues/75); added here to keep the roadmap matching reality. | — | High | — (ad hoc) | | — | | Aug–Sep 2026 | | Robbert (contract), Umer (eligibility scoping) | | ⚡ In Progress | Contract-only slice merged as [PR #77](https://github.com/InfiniteZeroFoundation/DevNet/pull/77) ([task_050826_9](tasks/task_050826_9.md)). Issue #75 stays open: eligibility computation is deliberately deferred until the on-chain indexer (P4-IDX1/IDX2) exists, since eligibility should be computed off indexed data rather than a raw RPC-log script. | diff --git a/Developer/issues/dincli-native-proxy-deployment.md b/Developer/issues/dincli-native-proxy-deployment.md index 301a9622..1b0bea29 100644 --- a/Developer/issues/dincli-native-proxy-deployment.md +++ b/Developer/issues/dincli-native-proxy-deployment.md @@ -7,25 +7,25 @@ Implement the chosen architecture of `dincli dinrep deploy ...` performs Transparent-Proxy deployment **natively in web3.py** — no `npx hardhat run` / `forge script` at runtime. -## Interim state (2026-07-15) +## Interim state -The dincli integration harness (Phase 1, `tests/dincli/test_01_platform.py` on -the PR 13 lineage) currently uses the **interim script flow**, which is the -record's *rejected* Option A kept only as test scaffolding: +The dincli integration harness (Phase 1, `tests/dincli/test_01_platform.py`) +uses the **interim script flow**, which is the record's *rejected* Option A +kept as scaffolding until the native deploy lands: -1. `npx hardhat run scripts/deploy-platform.ts --network localhost` (OZ upgrades plugin) -2. `dincli system import-deployments` maps `hardhat/deployments/.json` → `din_info.json` +1. `cd foundry && forge script script/DeployPlatform.s.sol --rpc-url --broadcast ...` + deploys and wires the seven platform proxies +2. `dincli system import-deployments` (`--foundry` is the default) maps + `foundry/deployments/.json` → `din_info.json` -This works and keeps the harness green, but has the known drawbacks (Node as a -runtime dependency, hardhat env keys instead of the dincli wallet, coupling to -the deployments file) and dies when `hardhat/` is deleted (Foundry-only -decision, 2026-07-03). +The Hardhat script is the secondary path: `npx hardhat run +scripts/deploy-platform.ts --network `, then `dincli system +import-deployments --hardhat`. -Update (2026-09-25): the interim flow now defaults to Foundry — -`forge script foundry/script/DeployPlatform.s.sol` (seven platform contracts) -followed by `dincli system import-deployments` (reads -`foundry/deployments/.json`); the hardhat script remains the secondary -path via `--hardhat`. +This works and keeps the harness green, but has the known drawbacks: a +toolchain (forge, plus Node for the upgrade-safety validation) as a runtime +dependency, the toolchain's signing setup instead of the dincli wallet, and +coupling to the deployments file. ## Work diff --git a/Documentation/technical/contracts/DinValidatorStake.md b/Documentation/technical/contracts/DinValidatorStake.md index e5bbf1f5..e4200ce9 100644 --- a/Documentation/technical/contracts/DinValidatorStake.md +++ b/Documentation/technical/contracts/DinValidatorStake.md @@ -14,7 +14,7 @@ - holds validators' DIN and tracks each validator's lifecycle status (`None` / `Active` / `Exiting` / `Jailed` / `Blacklisted`); - enforces an unbonding delay during which unstaked DIN **stays slashable**; - lets authorised slasher contracts (each model's `DINTaskCoordinator` / `DINTaskAuditor`) slash, with three flavours: full-severity `slash`, partial `slashPartial` with **S5 recidivism** escalation, and the **S6 no-participation** counter; -- splits every slashed amount **50% burned / 50% to `slashTreasury`** (`DinTreasury`); +- splits every slashed amount **50% burned / 50% to `slashTreasury`** (`DinTreasury`), or burns that half too if no treasury is set; - supports **jailing** (automatic on S5 escalation) and self-service `reactivate()` after the jail period; - stores governable parameters (`MIN_STAKE`, `UNBONDING_PERIOD`, per-model stake floors, concurrent-registration cap, S5/S6 parameters) that the task contracts read at registration and slashing time; - keeps validators' X25519 encryption keys for encrypted test-data delivery, and a per-validator active-registration counter. @@ -258,7 +258,7 @@ Until step 8, slasher management through the coordinator reverts `ValidatorStake ### P3 — slashing, jailing, parameters (foundry) -- Slashed DIN is now disposed of: 50% burned, 50% to `slashTreasury` (`setSlashTreasury`). +- Slashed DIN is now disposed of: 50% burned, 50% to `slashTreasury` (`setSlashTreasury`), or also burned if no treasury is set. - Added `slashPartial` with S5 recidivism escalation (per-caller ring), `recordNoParticipation` (S6), `jailValidator` / `reactivate`. - `MIN_STAKE` and `UNBONDING_PERIOD` became owner-settable storage; added per-model stake bounds, the concurrent-registration cap, the active-registration counter, and X25519 encryption-key registration. diff --git a/Documentation/technical/mechanisms/staking-mechanism.md b/Documentation/technical/mechanisms/staking-mechanism.md index eadc71b0..bf38f4c5 100644 --- a/Documentation/technical/mechanisms/staking-mechanism.md +++ b/Documentation/technical/mechanisms/staking-mechanism.md @@ -77,7 +77,7 @@ The core staking and validator-lifecycle contract. It: - enforces delayed withdrawals with an unbonding period; - keeps pending withdrawals slashable; - exposes `isValidatorActive()` for downstream eligibility checks; -- splits every slash 50% burn / 50% treasury, tracks S5 recidivism (`slashPartial`) and jails on escalation; +- splits every slash 50% burn / 50% treasury (the treasury half is also burned if no treasury is set), tracks S5 recidivism (`slashPartial`) and jails on escalation; - stores the governable parameters task contracts read (per-model stake floors, concurrent-registration cap, S5/S6 settings) and validators' X25519 encryption keys; - allows emergency blacklist and restoration through direct owner authority. @@ -175,7 +175,7 @@ Those role contracts should: 2. Authorized task contract determines slash amount and reason. 3. Task contract calls `DinValidatorStake.slashPartial()` (liveness faults, with S5 recidivism escalation) or `slash()` (full-severity faults). 4. Slash is applied first against `activeStake`, then against `pendingWithdrawals` if needed. -5. Half the slashed DIN is burned, half sent to `DinTreasury`; validator status is resynchronized (or set to `Jailed` on S5 escalation). +5. Half the slashed DIN is burned, half sent to `DinTreasury` (or also burned if no treasury is set); validator status is resynchronized (or set to `Jailed` on S5 escalation). 6. Validator may lose `Active` status if stake falls too low or if exit is already in progress. ### 4. Exit / Unbonding