From ab3ca816467d6342f9d395480062b5846bac5878 Mon Sep 17 00:00:00 2001 From: santiagocetran Date: Fri, 2 Oct 2026 18:40:52 -0300 Subject: [PATCH 01/48] test: prepare isolated integration harness for CI smoke --- .../technical/testing/dincli-testing-guide.md | 48 +++- tests/dincli/conftest.py | 102 +++++++-- tests/dincli/constants.py | 24 +- tests/dincli/demo.py | 54 +++++ tests/dincli/services.py | 154 +++++++++++++ tests/dincli/test_01_platform.py | 4 +- tests/test_integration_demo.py | 82 +++++++ tests/test_integration_harness.py | 194 ++++++++++++++++ tests/test_integration_services.py | 214 ++++++++++++++++++ 9 files changed, 856 insertions(+), 20 deletions(-) create mode 100644 tests/dincli/demo.py create mode 100644 tests/dincli/services.py create mode 100644 tests/test_integration_demo.py create mode 100644 tests/test_integration_harness.py create mode 100644 tests/test_integration_services.py diff --git a/Documentation/technical/testing/dincli-testing-guide.md b/Documentation/technical/testing/dincli-testing-guide.md index de926f45..0f574a00 100644 --- a/Documentation/technical/testing/dincli-testing-guide.md +++ b/Documentation/technical/testing/dincli-testing-guide.md @@ -16,9 +16,11 @@ chain ID 1337 on `http://127.0.0.1:8545`. ## Quick start -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. +The conftest manages contract compilation and local chain/IPFS startup. +Install the Python dependencies and chain/IPFS toolchains first. The existing +manual path also requires a repo-root `.env` and a packaged +`dincli/config/accounts.json` containing the public development accounts. +Docker must be running before Phase 4 client training begins. ```bash # 1. Ensure Docker daemon is running @@ -50,6 +52,46 @@ That's it. The conftest will: The test suite is structured around a centralized session-scoped `conftest.py` that isolates dependencies and coordinates services. +### CI smoke groundwork + +The first CI preparation change adds opt-in `DIN_TEST_ISOLATED=1` service +ownership and corrects demo bootstrap to use `connect-demo-wallet`. It does +not yet add a Docker runner, a smoke workflow job, or complete seven-contract +deployment assertions. The Python CI job still selects `not integration`. +Full lifecycle wallet switches and later phases are not verified by this change. + +In a disposable checkout/environment, isolated mode requires absolute +`DIN_TEST_TMPDIR` and `DIN_TEST_RESULTS_DIR` paths. Scratch must not already +exist; results must be outside scratch. The harness refuses checkout dotenv +files, uses a local-only subprocess environment, creates a fresh offline Kubo +repository, and rejects occupied ports without terminating their processes. +Only newly started process groups are stopped, including when setup fails +before fixture yield. Startup logs remain in the external results directory. +Both compiler paths remain enabled at this foundation stage; the Foundry-only +selection will accompany the later smoke runner. + +Isolated bootstrap generates accounts 0/1 from the public Anvil mnemonic at +the disposable package's `dincli/config/accounts.json`, refuses an existing +file, and removes the generated file on teardown. `connect-demo-wallet` reads +this file; setting `ETH_PRIVATE_KEY_N` alone does not supply demo accounts. +Existing manual runs must provide their own public account file with enough +entries for the chosen phases. Real-wallet commands deliberately reject demo +wallets and must not be substituted during bootstrap. + +The focused tests below exercise startup failures, process ownership, +interruption, account setup and retained evidence without launching Anvil or +IPFS, compiling contracts or contacting a chain: + +```bash +python -m pytest tests/test_integration_demo.py \ + tests/test_integration_services.py tests/test_integration_harness.py -q +``` + +Isolated mode is a harness setting, not a container or a guarantee that an +arbitrary checkout is disposable. Use it only inside an environment you own +for testing. The forthcoming Docker runner will establish that boundary and +provide the shared GitHub/local reproduction command. + ### Managed services (`managed_services` fixture) Before any test runs, the fixture does the following: diff --git a/tests/dincli/conftest.py b/tests/dincli/conftest.py index 5bb79482..0e10359d 100644 --- a/tests/dincli/conftest.py +++ b/tests/dincli/conftest.py @@ -19,8 +19,9 @@ Run (fail-fast — recommended, because every test depends on the previous one): pytest tests/dincli/ -v -x -m integration --tb=short 2>&1 | tee ~/tempdir/dincli/results/last_run.txt -All test output / logs are written to ~/tempdir/dincli/ (override with -DIN_TEST_TMPDIR; see tests/dincli/constants.py for all env overrides). +DIN_TEST_ISOLATED=1 opts into owned service groups and fresh disposable state. +It requires explicit scratch and external results paths; it does not create a +container. The default manual service behavior remains unchanged. """ import json @@ -31,6 +32,7 @@ import sys import time from pathlib import Path +from contextlib import ExitStack import pytest import requests @@ -49,8 +51,14 @@ DIN_TEMP, NPX_BIN, IPFS_BIN, + ANVIL_BIN, + ISOLATED_MODE, + RESULTS_DIR, ) +from tests.dincli.demo import bootstrap_demo, generate_demo_accounts +from tests.dincli.services import start_service, rpc_ready, ipfs_ready + # --------------------------------------------------------------------------- # Service helpers @@ -106,6 +114,7 @@ def _compile_contracts(results_dir: Path) -> None: capture_output=True, text=True, timeout=180, + env=_isolated_env(DIN_TEMP) if ISOLATED_MODE else None, ) log_path.write_text(result.stdout + result.stderr, encoding="utf-8") if result.returncode != 0: @@ -129,6 +138,7 @@ def _build_foundry_contracts(results_dir: Path) -> None: capture_output=True, text=True, timeout=180, + env=_isolated_env(DIN_TEMP) if ISOLATED_MODE else None, ) log_path.write_text(result.stdout + result.stderr, encoding="utf-8") if result.returncode != 0: @@ -263,6 +273,17 @@ def din_tmp(): config/ and cache/ are wiped at session start; results/ accumulates runs. """ + if ISOLATED_MODE: + for directory in (DEVNET_ROOT, FOUNDRY_DIR): + if any(path.name != ".env.example" for path in directory.glob(".env*")): + pytest.fail("Isolated mode requires a disposable checkout without dotenv files") + DIN_TEMP.mkdir(parents=True, exist_ok=False) + try: + RESULTS_DIR.mkdir(parents=True, exist_ok=True) + yield DIN_TEMP + finally: + shutil.rmtree(DIN_TEMP) + return DIN_TEMP.mkdir(parents=True, exist_ok=True) for subdir in ("config", "cache"): d = DIN_TEMP / subdir @@ -270,7 +291,7 @@ def din_tmp(): shutil.rmtree(d) d.mkdir(parents=True, exist_ok=True) (DIN_TEMP / "results").mkdir(exist_ok=True) - return DIN_TEMP + yield DIN_TEMP # --------------------------------------------------------------------------- @@ -292,7 +313,7 @@ def managed_services(din_tmp): depend on it transitively via bootstrap). On teardown, any processes we started are terminated. """ - results_dir = din_tmp / "results" + results_dir = RESULTS_DIR chain_proc = None ipfs_proc = None @@ -302,6 +323,37 @@ def managed_services(din_tmp): if PLATFORM_DEPLOY_TOOLCHAIN == "foundry": _build_foundry_contracts(results_dir) + if ISOLATED_MODE: + env = _isolated_env(din_tmp) + with ExitStack() as resources: + if PLATFORM_DEPLOY_TOOLCHAIN == "foundry": + command = [ANVIL_BIN, "--host", "127.0.0.1", "--chain-id", "1337", + "--accounts", "70", "--balance", "10000", "--block-time", "2", + "--code-size-limit", "4294967295", + "--mnemonic", "test test test test test test test test test test test junk"] + cwd = FOUNDRY_DIR + else: + command = [NPX_BIN, "hardhat", "node", "--hostname", "127.0.0.1"] + cwd = HARDHAT_DIR + chain = start_service(command, cwd=cwd, env=env, + log_path=results_dir / "chain_node.log", + ready=lambda: rpc_ready(HARDHAT_RPC), port=8545) + resources.callback(chain.close) + # Never initialize or reuse an existing IPFS repository. + if Path(env["IPFS_PATH"]).exists(): + raise RuntimeError("Isolated IPFS repository already exists") + with (results_dir / "ipfs_init.log").open("w") as log: + for args in (["init"], ["config", "--json", "Bootstrap", "[]"], + ["config", "--json", "Discovery.MDNS.Enabled", "false"]): + subprocess.run([IPFS_BIN, *args], env=env, check=True, + stdout=log, stderr=subprocess.STDOUT, timeout=30) + ipfs = start_service([IPFS_BIN, "daemon", "--offline"], cwd=din_tmp, env=env, + log_path=results_dir / "ipfs_daemon.log", + ready=ipfs_ready, port=5001) + resources.callback(ipfs.close) + yield + return + # 2. Fresh chain node (kill and restart for clean EVM state) if PLATFORM_DEPLOY_TOOLCHAIN == "foundry": chain_proc = _start_fresh_anvil_node(results_dir) @@ -335,6 +387,23 @@ def managed_services(din_tmp): # --------------------------------------------------------------------------- +def _isolated_env(din_tmp): + """Local-only subprocess settings; do not inherit provider credentials.""" + return { + "PATH": os.environ.get("PATH", os.defpath), + "LANG": "C.UTF-8", "HOME": str(din_tmp / "home"), + "XDG_CONFIG_HOME": str(din_tmp / "config"), + "XDG_CACHE_HOME": str(din_tmp / "cache"), + "XDG_DATA_HOME": str(din_tmp / "data"), + "IPFS_PATH": str(din_tmp / "ipfs"), + "PYTHONPATH": str(DEVNET_ROOT), "LOCAL_RPC_URL": HARDHAT_RPC, + "IPFS_PROVIDER": "env", "IPFS_PUBLIC_GATEWAY": "0", + "IPFS_API_URL_ADD": "http://127.0.0.1:5001/api/v0/add", + "IPFS_API_URL_RETRIEVE": "http://127.0.0.1:5001/api/v0", + "NO_COLOR": "1", "TERM": "dumb", + } + + @pytest.fixture(scope="session") def din_env(din_tmp): """ @@ -346,6 +415,8 @@ def din_env(din_tmp): local dincli/ package without a pip install into the venv. - LOCAL_RPC_URL points at the Hardhat node. """ + if ISOLATED_MODE: + return _isolated_env(din_tmp) env = os.environ.copy() env["XDG_CONFIG_HOME"] = str(din_tmp / "config") env["XDG_CACHE_HOME"] = str(din_tmp / "cache") @@ -366,6 +437,8 @@ def workdir(): dincli/ package is found via PYTHONPATH from here. """ + if ISOLATED_MODE: + return DIN_TEMP shutil.copy(str(DEVNET_ROOT / ".env"), str(DIN_TEMP / ".env")) return DIN_TEMP @@ -478,19 +551,22 @@ def _run( @pytest.fixture(scope="session", autouse=True) def bootstrap(managed_services, din_info_backup, run): """ - Configure demo mode and local network, and register the named role + Configure demo mode and local network, and connect the named demo role wallets (account 0 = dinrep / DIN-Representative, account 1 = modelowner). - Tests switch between them with `system connect-wallet `; dynamic - per-account roles in test_04 self-register via - `register-wallet --account N --name acctN --yes --connect`. + Use `system connect-demo-wallet ` to switch demo accounts. Later + lifecycle phases still require their own wallet-command migration. Depends on managed_services so the Hardhat node is guaranteed to be running before the first command. """ - run(["system", "init"]) - run(["system", "configure-demo"]) - run(["system", "configure-network", "--network", "local"]) - run(["system", "register-wallet", "--account", "0", "--name", "dinrep", "--yes"]) - run(["system", "register-wallet", "--account", "1", "--name", "modelowner", "--yes"]) + accounts_path = DEVNET_ROOT / "dincli" / "config" / "accounts.json" + if ISOLATED_MODE: + generate_demo_accounts(accounts_path) + try: + bootstrap_demo(run) + yield + finally: + if ISOLATED_MODE: + accounts_path.unlink() # --------------------------------------------------------------------------- diff --git a/tests/dincli/constants.py b/tests/dincli/constants.py index c750619a..a8d3a94a 100644 --- a/tests/dincli/constants.py +++ b/tests/dincli/constants.py @@ -15,6 +15,10 @@ IPFS_BIN ipfs binary (default: `ipfs` on PATH, else /usr/local/bin/ipfs) DIN_TEST_TMPDIR scratch dir for config/cache isolation and logs (default: ~/tempdir/dincli) + DIN_TEST_ISOLATED set to 1 only in a disposable execution environment; + ignores checkout dotenv and host Python/nvm fallbacks + DIN_TEST_RESULTS_DIR external log directory, required in isolated mode + ANVIL_BIN explicit Anvil binary for owned isolated startup PLATFORM_DEPLOY_TOOLCHAIN "foundry" or "hardhat" — which platform deploy script test_deploy_platform_via_script runs (default: "foundry", matching `dincli system import-deployments`'s @@ -29,6 +33,7 @@ HARDHAT_RPC = "http://127.0.0.1:8545" DEVNET_ROOT = Path(__file__).resolve().parent.parent.parent +ISOLATED_MODE = os.environ.get("DIN_TEST_ISOLATED") == "1" def _load_dotenv_values(path: Path) -> dict: @@ -41,7 +46,7 @@ def _load_dotenv_values(path: Path) -> dict: return {k: v for k, v in dotenv_values(dotenv_path=path).items() if v is not None} -_DOTENV = _load_dotenv_values(DEVNET_ROOT / ".env") +_DOTENV = {} if ISOLATED_MODE else _load_dotenv_values(DEVNET_ROOT / ".env") def _env(name: str) -> "str | None": @@ -58,6 +63,8 @@ def _resolve_python(env_var: str, default_venv: str) -> str: override = _env(env_var) if override: return override + if ISOLATED_MODE: + return sys.executable candidate = Path.home() / "my_venvs" / default_venv / "bin" / "python" return str(candidate) if candidate.exists() else sys.executable @@ -66,7 +73,10 @@ def _resolve_npx() -> str: override = _env("NPX_BIN") if override: return override - nvm_candidates = sorted(Path.home().glob(".nvm/versions/node/*/bin/npx")) + nvm_candidates = ( + [] if ISOLATED_MODE + else sorted(Path.home().glob(".nvm/versions/node/*/bin/npx")) + ) if nvm_candidates: return str(nvm_candidates[-1]) return shutil.which("npx") or "npx" @@ -92,9 +102,19 @@ def _venv_site_packages(python_bin: str) -> str: NPX_BIN = _resolve_npx() FORGE_BIN = _env("FORGE_BIN") or shutil.which("forge") or "forge" +ANVIL_BIN = _env("ANVIL_BIN") or shutil.which("anvil") or "anvil" IPFS_BIN = _env("IPFS_BIN") or shutil.which("ipfs") or "/usr/local/bin/ipfs" DIN_TEMP = Path(_env("DIN_TEST_TMPDIR") or str(Path.home() / "tempdir" / "dincli")) +RESULTS_DIR = Path(_env("DIN_TEST_RESULTS_DIR") or str(DIN_TEMP / "results")) +if ISOLATED_MODE: + if not os.environ.get("DIN_TEST_TMPDIR") or not os.environ.get("DIN_TEST_RESULTS_DIR"): + raise ValueError("Isolated mode requires DIN_TEST_TMPDIR and DIN_TEST_RESULTS_DIR") + if not DIN_TEMP.is_absolute() or not RESULTS_DIR.is_absolute(): + raise ValueError("Isolated scratch and results paths must be absolute") + if (DIN_TEMP.resolve() == RESULTS_DIR.resolve() + or DIN_TEMP.resolve() in RESULTS_DIR.resolve().parents): + raise ValueError("Isolated results must be outside scratch so cleanup preserves logs") # Platform deployment (PR 13: transparent proxies; PR 35: foundry parity). diff --git a/tests/dincli/demo.py b/tests/dincli/demo.py new file mode 100644 index 00000000..e388bfcc --- /dev/null +++ b/tests/dincli/demo.py @@ -0,0 +1,54 @@ +"""Explicit preparation of disposable wallets for the local integration suite.""" + +import json +from pathlib import Path + +from eth_account import Account + + +# Public Anvil/Hardhat development seed; these accounts are for local tests only. +DEMO_MNEMONIC = "test test test test test test test test test test test junk" +DEMO_ADDRESSES = ( + "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", +) + + +def generate_demo_accounts(accounts_path: Path) -> Path: + """Write accounts 0/1 at the caller's explicit disposable package path. + + An existing destination is always refused, including a symlink. Never call + this with a user's checkout/config path; the caller owns cleanup of the + isolated package containing this file. + """ + accounts_path = Path(accounts_path) + if accounts_path.exists() or accounts_path.is_symlink(): + raise FileExistsError(f"Refusing to overwrite demo accounts: {accounts_path}") + + Account.enable_unaudited_hdwallet_features() + accounts = [] + for index, expected_address in enumerate(DEMO_ADDRESSES): + account = Account.from_mnemonic( + DEMO_MNEMONIC, account_path=f"m/44'/60'/0'/0/{index}" + ) + if account.address != expected_address: + raise ValueError(f"Unexpected public demo address for account {index}") + accounts.append({ + "address": account.address, + "private_key": "0x" + account.key.hex(), + }) + + accounts_path.parent.mkdir(parents=True, exist_ok=True) + with accounts_path.open("x", encoding="utf-8") as file: + json.dump({"hardhat": accounts}, file, indent=2) + file.write("\n") + return accounts_path + + +def bootstrap_demo(run) -> None: + """Configure local demo mode and connect the two named test role wallets.""" + run(["system", "init"]) + run(["system", "configure-demo", "--mode", "yes"]) + run(["system", "configure-network", "--network", "local"]) + run(["system", "connect-demo-wallet", "dinrep", "--account", "0", "--yes"]) + run(["system", "connect-demo-wallet", "modelowner", "--account", "1", "--yes"]) diff --git a/tests/dincli/services.py b/tests/dincli/services.py new file mode 100644 index 00000000..c09b4132 --- /dev/null +++ b/tests/dincli/services.py @@ -0,0 +1,154 @@ +"""Own local integration services without disturbing pre-existing processes.""" + +import os +import re +import signal +import socket +import subprocess +import time +from pathlib import Path +from typing import Callable, IO + +import requests + + +class OwnedService: + """A child session and its log; cleanup is safe to call more than once.""" + + def __init__(self, process: subprocess.Popen, log: IO, log_path: Path): + self.process = process + self.log_path = log_path + self._log = log + self._closed = False + + def close(self) -> None: + if self._closed: + return + self._closed = True + try: + # start_new_session makes the child PID its process-group ID. + try: + os.killpg(self.process.pid, signal.SIGTERM) + except ProcessLookupError: + pass + try: + self.process.wait(timeout=5) + except subprocess.TimeoutExpired: + pass + finally: + # Also reap descendants when the parent exits before them. + try: + os.killpg(self.process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + self.process.wait(timeout=5) + finally: + self._log.close() + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.close() + + +def _check_port(port: int) -> None: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe: + try: + probe.bind(("127.0.0.1", port)) + except OSError as exc: + raise RuntimeError(f"Loopback port {port} is unavailable") from exc + + +def start_service( + command: list, + cwd: Path, + env: dict, + log_path: Path, + ready: Callable[[], bool], + timeout: float = 30, + port: int | None = None, +) -> OwnedService: + """Start a service, returning ownership only after a live child is ready.""" + if port is not None: + _check_port(port) + log = None + service = None + try: + log_path.parent.mkdir(parents=True, exist_ok=True) + log = log_path.open("w") + process = subprocess.Popen( + command, + cwd=cwd, + env=env, + stdout=log, + stderr=subprocess.STDOUT, + start_new_session=True, + ) + service = OwnedService(process, log, log_path) + deadline = time.monotonic() + timeout + while True: + if process.poll() is not None: + raise RuntimeError("Service exited before readiness") + if time.monotonic() >= deadline: + raise RuntimeError(f"Service readiness timed out after {timeout}s") + is_ready = ready() + if process.poll() is not None: + raise RuntimeError("Service exited during readiness probe") + if time.monotonic() >= deadline: + raise RuntimeError(f"Service readiness timed out after {timeout}s") + if is_ready: + return service + time.sleep(min(0.1, max(0, deadline - time.monotonic()))) + except BaseException as exc: + try: + if service is not None: + service.close() + elif log is not None: + log.close() + except Exception as cleanup_exc: + raise RuntimeError( + f"Service startup failed: {exc}; cleanup failed: {cleanup_exc}; log: {log_path}" + ) from exc + if not isinstance(exc, Exception): + raise + raise RuntimeError(f"Service startup failed: {exc}; log: {log_path}") from exc + + +def rpc_ready(url: str, expected_chain_id: int = 1337) -> bool: + try: + response = requests.post( + url, + json={"jsonrpc": "2.0", "method": "eth_chainId", "params": [], "id": 1}, + timeout=3, + ) + if response.status_code != 200: + return False + body = response.json() + return ( + isinstance(body, dict) + and body.get("jsonrpc") == "2.0" + and type(body.get("id")) is int + and body.get("id") == 1 + and "error" not in body + and isinstance(body.get("result"), str) + and re.fullmatch(r"0x[0-9a-fA-F]+", body["result"]) is not None + and int(body["result"], 16) == expected_chain_id + ) + except (requests.RequestException, ValueError, TypeError): + return False + + +def ipfs_ready(url: str = "http://127.0.0.1:5001/api/v0/version") -> bool: + try: + response = requests.post(url, timeout=3) + if response.status_code != 200: + return False + body = response.json() + return ( + isinstance(body, dict) + and isinstance(body.get("Version"), str) + and bool(body["Version"].strip()) + ) + except (requests.RequestException, ValueError, TypeError): + return False diff --git a/tests/dincli/test_01_platform.py b/tests/dincli/test_01_platform.py index 0ff2c027..5df8afa2 100644 --- a/tests/dincli/test_01_platform.py +++ b/tests/dincli/test_01_platform.py @@ -86,8 +86,8 @@ def ensure_artifacts(): def test_connect_wallet_dinrep(run, state): - """Switch to the dinrep wallet (account 0, DIN-Representative).""" - result = run(["system", "connect-wallet", "dinrep"]) + """Switch to the dinrep demo wallet (account 0, DIN-Representative).""" + result = run(["system", "connect-demo-wallet", "dinrep"]) assert result.returncode == 0 state["representative_connected"] = True diff --git a/tests/test_integration_demo.py b/tests/test_integration_demo.py new file mode 100644 index 00000000..9b06ea7f --- /dev/null +++ b/tests/test_integration_demo.py @@ -0,0 +1,82 @@ +"""Local unit coverage for integration setup, without starting its services.""" + +import json + +import pytest +from eth_account import Account + +from tests.dincli.demo import bootstrap_demo, generate_demo_accounts + + + +def test_generate_demo_accounts_uses_public_anvil_accounts(tmp_path): + accounts_path = tmp_path / "dincli" / "config" / "accounts.json" + + assert generate_demo_accounts(accounts_path) == accounts_path + + data = json.loads(accounts_path.read_text(encoding="utf-8")) + assert set(data) == {"hardhat"} + assert len(data["hardhat"]) == 2 + assert [account["address"] for account in data["hardhat"]] == [ + "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + ] + assert data["hardhat"][0]["private_key"] == ( + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" + ) + for account in data["hardhat"]: + assert set(account) == {"address", "private_key"} + assert account["private_key"].startswith("0x") + assert len(account["private_key"]) == 66 + assert Account.from_key(account["private_key"]).address == account["address"] + assert list(tmp_path.rglob("accounts.json")) == [accounts_path] + + +def test_generate_demo_accounts_preserves_existing_file(tmp_path): + accounts_path = tmp_path / "accounts.json" + original = b'{"custom": "user data"}\n' + accounts_path.write_bytes(original) + + with pytest.raises(FileExistsError, match="Refusing to overwrite"): + generate_demo_accounts(accounts_path) + + assert accounts_path.read_bytes() == original + + +def test_generate_demo_accounts_refuses_dangling_symlink(tmp_path): + target = tmp_path / "missing.json" + accounts_path = tmp_path / "accounts.json" + accounts_path.symlink_to(target) + + with pytest.raises(FileExistsError, match="Refusing to overwrite"): + generate_demo_accounts(accounts_path) + + assert accounts_path.is_symlink() + assert not target.exists() + + +def test_bootstrap_demo_uses_noninteractive_commands_in_order(): + commands = [] + + bootstrap_demo(commands.append) + + assert commands == [ + ["system", "init"], + ["system", "configure-demo", "--mode", "yes"], + ["system", "configure-network", "--network", "local"], + ["system", "connect-demo-wallet", "dinrep", "--account", "0", "--yes"], + ["system", "connect-demo-wallet", "modelowner", "--account", "1", "--yes"], + ] + + +def test_bootstrap_demo_stops_after_runner_failure(): + commands = [] + + def failing_run(args): + commands.append(args) + raise RuntimeError("command failed") + + with pytest.raises(RuntimeError, match="command failed"): + bootstrap_demo(failing_run) + + assert commands == [["system", "init"]] diff --git a/tests/test_integration_harness.py b/tests/test_integration_harness.py new file mode 100644 index 00000000..92e300b5 --- /dev/null +++ b/tests/test_integration_harness.py @@ -0,0 +1,194 @@ +"""Exercise isolated integration fixture ownership without starting services.""" + +import importlib +import json +import signal +import subprocess +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest + + +@pytest.fixture +def harness(monkeypatch, tmp_path): + # Import with isolated settings so constants cannot read host dotenv/config. + scratch = tmp_path / "scratch" + results = tmp_path / "evidence" + monkeypatch.setenv("DIN_TEST_ISOLATED", "1") + monkeypatch.setenv("DIN_TEST_TMPDIR", str(scratch)) + monkeypatch.setenv("DIN_TEST_RESULTS_DIR", str(results)) + module = importlib.import_module("tests.dincli.conftest") + checkout = tmp_path / "checkout" + foundry = checkout / "foundry" + foundry.mkdir(parents=True) + for name, value in { + "ISOLATED_MODE": True, + "DEVNET_ROOT": checkout, + "FOUNDRY_DIR": foundry, + "HARDHAT_DIR": checkout / "hardhat", + "DIN_TEMP": scratch, + "RESULTS_DIR": results, + "PLATFORM_DEPLOY_TOOLCHAIN": "foundry", + }.items(): + monkeypatch.setattr(module, name, value) + + def forbidden(*args, **kwargs): + raise AssertionError("No real service, compilation, or network calls allowed") + + for name in ("_compile_contracts", "_build_foundry_contracts", + "_start_fresh_anvil_node", "_start_fresh_hardhat_node", + "_ensure_ipfs_running", "start_service"): + monkeypatch.setattr(module, name, forbidden) + monkeypatch.setattr(module.subprocess, "run", forbidden) + monkeypatch.setattr(module.requests, "post", forbidden) + return module + + +def fake_services(harness, monkeypatch): + """Return real ownership wrappers around fake processes and temporary logs.""" + from tests.dincli.services import OwnedService + from tests.dincli import services + + harness.RESULTS_DIR.mkdir() + handles = [] + signals = [] + monkeypatch.setattr(services.os, "killpg", lambda pid, sig: signals.append((pid, sig))) + monkeypatch.setattr(harness, "_compile_contracts", lambda path: None) + monkeypatch.setattr(harness, "_build_foundry_contracts", lambda path: None) + + def start(command, *, log_path, **kwargs): + log = log_path.open("w", encoding="utf-8") + log.write("retained service evidence\n") + log.flush() + process = SimpleNamespace(pid=10000 + len(handles), wait=Mock()) + handle = OwnedService(process, log, log_path) + handles.append(handle) + return handle + + monkeypatch.setattr(harness, "start_service", start) + return handles, signals + + +def test_ipfs_init_failure_closes_owned_chain_and_retains_logs(harness, monkeypatch): + handles, signals = fake_services(harness, monkeypatch) + + def fail_init(command, **kwargs): + kwargs["stdout"].write("IPFS init failed\n") + raise subprocess.CalledProcessError(1, command) + + monkeypatch.setattr(harness.subprocess, "run", fail_init) + fixture = harness.managed_services.__wrapped__(harness.DIN_TEMP) + + with pytest.raises(subprocess.CalledProcessError): + next(fixture) + + assert len(handles) == 1 + chain = handles[0] + assert chain._log.closed + assert chain.process.wait.call_count == 2 + assert signals == [(chain.process.pid, signal.SIGTERM), (chain.process.pid, signal.SIGKILL)] + assert chain.log_path.read_text() == "retained service evidence\n" + assert (harness.RESULTS_DIR / "ipfs_init.log").read_text() == "IPFS init failed\n" + + +def test_successful_service_teardown_closes_both_owned_handles(harness, monkeypatch): + handles, signals = fake_services(harness, monkeypatch) + init_commands = [] + monkeypatch.setattr(harness.subprocess, "run", lambda args, **kwargs: init_commands.append(args)) + fixture = harness.managed_services.__wrapped__(harness.DIN_TEMP) + + next(fixture) + assert len(handles) == 2 + assert all(not service._log.closed for service in handles) + assert len(init_commands) == 3 + fixture.close() + + assert all(service._log.closed for service in handles) + assert all(service.process.wait.call_count == 2 for service in handles) + assert [pid for pid, sig in signals if sig == signal.SIGTERM] == [ + handles[1].process.pid, handles[0].process.pid, + ] + assert all(service.log_path.read_text() == "retained service evidence\n" for service in handles) + + +def test_isolated_env_filters_credentials_and_uses_local_config(harness, monkeypatch): + monkeypatch.setenv("PINATA_JWT", "injected-production-credential") + monkeypatch.setenv("LOCAL_RPC_URL", "https://production.invalid") + monkeypatch.setenv("IPFS_API_URL_ADD", "https://production.invalid/add") + + env = harness.din_env.__wrapped__(harness.DIN_TEMP) + + assert "PINATA_JWT" not in env + assert "injected-production-credential" not in env.values() + for name, relative in { + "HOME": "home", "XDG_CONFIG_HOME": "config", "XDG_CACHE_HOME": "cache", + "XDG_DATA_HOME": "data", "IPFS_PATH": "ipfs", + }.items(): + assert env[name] == str(harness.DIN_TEMP / relative) + assert env["PYTHONPATH"] == str(harness.DEVNET_ROOT) + assert env["LOCAL_RPC_URL"] == "http://127.0.0.1:8545" + assert env["IPFS_API_URL_ADD"] == "http://127.0.0.1:5001/api/v0/add" + assert env["IPFS_API_URL_RETRIEVE"] == "http://127.0.0.1:5001/api/v0" + assert env["IPFS_PROVIDER"] == "env" + + +def test_isolated_scratch_refuses_existing_data_without_deleting_it(harness): + harness.DIN_TEMP.mkdir() + sentinel = harness.DIN_TEMP / "user-data.txt" + sentinel.write_text("preserve this") + fixture = harness.din_tmp.__wrapped__() + + with pytest.raises(FileExistsError): + next(fixture) + + assert sentinel.read_text() == "preserve this" + assert not harness.RESULTS_DIR.exists() + + +def test_isolated_scratch_cleanup_retains_external_evidence(harness): + fixture = harness.din_tmp.__wrapped__() + assert next(fixture) == harness.DIN_TEMP + (harness.DIN_TEMP / "temporary-wallet.json").write_text("disposable") + evidence = harness.RESULTS_DIR / "result.log" + evidence.write_text("retain this") + + fixture.close() + + assert not harness.DIN_TEMP.exists() + assert evidence.read_text() == "retain this" + + +def test_results_creation_failure_cleans_new_scratch(harness): + harness.RESULTS_DIR.write_text("existing file") + fixture = harness.din_tmp.__wrapped__() + with pytest.raises(FileExistsError): + next(fixture) + assert not harness.DIN_TEMP.exists() + assert harness.RESULTS_DIR.read_text() == "existing file" + + +@pytest.mark.parametrize("fail_bootstrap", [False, True]) +def test_isolated_bootstrap_provisions_then_cleans_demo_accounts(harness, fail_bootstrap): + accounts_path = harness.DEVNET_ROOT / "dincli" / "config" / "accounts.json" + commands = [] + + def run(args): + data = json.loads(accounts_path.read_text()) + assert len(data["hardhat"]) == 2 + assert data["hardhat"][0]["address"] == "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" + commands.append(args) + if fail_bootstrap: + raise RuntimeError("bootstrap command failed") + + fixture = harness.bootstrap.__wrapped__(None, None, run) + if fail_bootstrap: + with pytest.raises(RuntimeError, match="bootstrap command failed"): + next(fixture) + assert commands == [["system", "init"]] + else: + next(fixture) + assert accounts_path.exists() + assert len(commands) == 5 + fixture.close() + assert not accounts_path.exists() diff --git a/tests/test_integration_services.py b/tests/test_integration_services.py new file mode 100644 index 00000000..2dcbb703 --- /dev/null +++ b/tests/test_integration_services.py @@ -0,0 +1,214 @@ +"""Service ownership tests; these never start chain/IPFS nodes or contact them.""" + +import signal +import socket +import subprocess +import os +import sys +from unittest.mock import Mock + +import pytest +import requests + +from tests.dincli import services + + +@pytest.fixture +def harness(monkeypatch, tmp_path): + process = Mock(pid=24680) + process.poll.return_value = None + process.wait.return_value = 0 + popen = Mock(return_value=process) + killpg = Mock() + monkeypatch.setattr(services.subprocess, "Popen", popen) + monkeypatch.setattr(services.os, "killpg", killpg) + clock = [0.0] + monkeypatch.setattr(services.time, "monotonic", lambda: clock[0]) + monkeypatch.setattr(services.time, "sleep", lambda delay: clock.__setitem__(0, clock[0] + delay)) + + def start(ready=lambda: True, timeout=1, **kwargs): + return services.start_service( + ["fake-service", "--flag"], tmp_path, {"PATH": "/bin"}, + tmp_path / "service.log", ready, timeout=timeout, **kwargs, + ) + + return process, popen, killpg, clock, start, tmp_path / "service.log" + + +def assert_closed(process, popen, killpg): + assert popen.call_args.kwargs["stdout"].closed + assert killpg.call_args_list == [ + ((process.pid, signal.SIGTERM),), + ((process.pid, signal.SIGKILL),), + ] + assert all(call.kwargs["timeout"] == 5 for call in process.wait.call_args_list) + + +def test_ready_service_has_its_own_session_and_idempotent_cleanup(harness): + process, popen, killpg, _, start, log_path = harness + service = start() + assert service.process is process + assert service.log_path == log_path + assert popen.call_args.kwargs["start_new_session"] is True + assert popen.call_args.kwargs["stderr"] == subprocess.STDOUT + assert not popen.call_args.kwargs["stdout"].closed + with service: + pass + service.close() + assert_closed(process, popen, killpg) + assert log_path.exists() + + +def test_timeout_cleans_up_and_retains_diagnostic_log(harness): + process, popen, killpg, clock, start, log_path = harness + + def unready(): + popen.call_args.kwargs["stdout"].write("startup diagnostics\n") + return False + + with pytest.raises(RuntimeError, match="timed out"): + start(unready, timeout=0.25) + assert clock[0] == pytest.approx(0.25) + assert_closed(process, popen, killpg) + assert "startup diagnostics" in log_path.read_text() + + +@pytest.mark.parametrize("poll_results", [[9], [None, 9]]) +def test_child_exit_is_failure_even_when_probe_returns_true(harness, poll_results): + process, popen, killpg, _, start, _ = harness + process.poll.side_effect = poll_results + probe = Mock(return_value=True) + with pytest.raises(RuntimeError, match="exited"): + start(probe) + assert probe.call_count == len(poll_results) - 1 + assert_closed(process, popen, killpg) + + +def test_occupied_port_is_rejected_without_spawning(harness): + _, popen, killpg, _, start, log_path = harness + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener: + listener.bind(("127.0.0.1", 0)) + listener.listen() + with pytest.raises(RuntimeError, match="port .* unavailable"): + start(port=listener.getsockname()[1]) + popen.assert_not_called() + killpg.assert_not_called() + assert not log_path.exists() + + +def test_probe_exception_cleans_up(harness): + process, popen, killpg, _, start, log_path = harness + with pytest.raises(RuntimeError, match="broken probe"): + start(Mock(side_effect=ValueError("broken probe"))) + assert_closed(process, popen, killpg) + assert log_path.exists() + + +@pytest.mark.parametrize("interruption", [KeyboardInterrupt, SystemExit]) +def test_startup_interruption_cleans_up_before_ownership_transfer(harness, interruption): + process, popen, killpg, _, start, log_path = harness + with pytest.raises(interruption): + start(Mock(side_effect=interruption())) + assert_closed(process, popen, killpg) + assert log_path.exists() + + +def test_start_failure_closes_log_without_signalling(harness): + _, popen, killpg, _, start, log_path = harness + popen.side_effect = OSError("missing executable") + with pytest.raises(RuntimeError, match="missing executable"): + start() + assert popen.call_args.kwargs["stdout"].closed + killpg.assert_not_called() + assert log_path.exists() + + +def test_cleanup_escalates_when_parent_does_not_exit(harness): + process, popen, killpg, _, start, _ = harness + service = start() + process.wait.side_effect = [subprocess.TimeoutExpired("fake-service", 5), 0] + service.close() + assert_closed(process, popen, killpg) + assert process.wait.call_count == 2 + + +def test_already_exited_group_does_not_prevent_cleanup(harness): + process, popen, killpg, _, start, _ = harness + service = start() + killpg.side_effect = ProcessLookupError + service.close() + assert_closed(process, popen, killpg) + + +def test_probe_cannot_return_success_after_deadline(harness): + process, popen, killpg, clock, start, _ = harness + + def slow_probe(): + clock[0] = 2 + return True + + with pytest.raises(RuntimeError, match="timed out"): + start(slow_probe) + assert_closed(process, popen, killpg) + + +def test_real_child_is_reaped_and_log_preserved(tmp_path): + log_path = tmp_path / "real-service.log" + service = services.start_service( + [sys.executable, "-u", "-c", "import time; print('ready'); time.sleep(60)"], + tmp_path, os.environ.copy(), log_path, + lambda: log_path.read_text().strip() == "ready", timeout=5, + ) + try: + assert os.getpgid(service.process.pid) == service.process.pid + finally: + service.close() + assert service.process.poll() is not None + assert log_path.read_text().strip() == "ready" + + +@pytest.mark.parametrize("body, expected", [ + ({"jsonrpc": "2.0", "id": 1, "result": "0x539"}, True), + ({"jsonrpc": "2.0", "id": 1, "result": "0x1"}, False), + ({"jsonrpc": "2.0", "id": 1, "result": "0x539", "error": {}}, False), + ({"jsonrpc": "2.0", "id": 2, "result": "0x539"}, False), + ({"id": 1, "result": "0x539"}, False), + ({"jsonrpc": "2.0", "id": 1, "result": 1337}, False), + ({"jsonrpc": "2.0", "id": 1, "result": "0xnope"}, False), + ({"jsonrpc": "2.0", "id": 1, "result": "0x539 "}, False), + ({"jsonrpc": "2.0", "id": True, "result": "0x539"}, False), + ([], False), +]) +def test_rpc_requires_matching_jsonrpc_chain(monkeypatch, body, expected): + response = Mock(status_code=200) + response.json.return_value = body + post = Mock(return_value=response) + monkeypatch.setattr(services.requests, "post", post) + assert services.rpc_ready("http://local.invalid") is expected + assert post.call_args.kwargs["timeout"] == 3 + assert post.call_args.kwargs["json"]["method"] == "eth_chainId" + + +@pytest.mark.parametrize("body, expected", [ + ({"Version": "0.32.1"}, True), ({"Version": ""}, False), + ({"Version": " "}, False), ({"Version": 1}, False), ({}, False), ([], False), +]) +def test_ipfs_requires_nonempty_version(monkeypatch, body, expected): + response = Mock(status_code=200) + response.json.return_value = body + post = Mock(return_value=response) + monkeypatch.setattr(services.requests, "post", post) + assert services.ipfs_ready() is expected + assert post.call_args.kwargs["timeout"] == 3 + + +@pytest.mark.parametrize("probe", [services.rpc_ready, services.ipfs_ready]) +@pytest.mark.parametrize("failure", ["http", "json", "network"]) +def test_readiness_handles_failed_http_json_and_network(monkeypatch, probe, failure): + response = Mock(status_code=503 if failure == "http" else 200) + response.json.side_effect = ValueError("invalid JSON") + post = Mock(return_value=response) + if failure == "network": + post.side_effect = requests.ConnectionError("unreachable") + monkeypatch.setattr(services.requests, "post", post) + assert probe("http://local.invalid") is False From 2af68f3067a03858bdf6a4499caa4bbbd8539f2a Mon Sep 17 00:00:00 2001 From: santiagocetran Date: Fri, 2 Oct 2026 19:22:16 -0300 Subject: [PATCH 02/48] test: preserve validated public demo accounts during isolated bootstrap --- .../technical/testing/dincli-testing-guide.md | 7 +-- tests/dincli/conftest.py | 7 ++- tests/dincli/demo.py | 25 +++++++++++ tests/test_integration_demo.py | 45 ++++++++++++++++++- tests/test_integration_harness.py | 22 +++++++++ 5 files changed, 98 insertions(+), 8 deletions(-) diff --git a/Documentation/technical/testing/dincli-testing-guide.md b/Documentation/technical/testing/dincli-testing-guide.md index 0f574a00..f717f2e4 100644 --- a/Documentation/technical/testing/dincli-testing-guide.md +++ b/Documentation/technical/testing/dincli-testing-guide.md @@ -70,9 +70,10 @@ before fixture yield. Startup logs remain in the external results directory. Both compiler paths remain enabled at this foundation stage; the Foundry-only selection will accompany the later smoke runner. -Isolated bootstrap generates accounts 0/1 from the public Anvil mnemonic at -the disposable package's `dincli/config/accounts.json`, refuses an existing -file, and removes the generated file on teardown. `connect-demo-wallet` reads +Isolated bootstrap validates and reuses accounts 0/1 in the checkout's public +`dincli/config/accounts.json`, preserving the file. If absent, it generates +those accounts from the public Anvil mnemonic and removes only that generated +file on teardown. Invalid accounts or symlinks fail setup. `connect-demo-wallet` reads this file; setting `ETH_PRIVATE_KEY_N` alone does not supply demo accounts. Existing manual runs must provide their own public account file with enough entries for the chosen phases. Real-wallet commands deliberately reject demo diff --git a/tests/dincli/conftest.py b/tests/dincli/conftest.py index 0e10359d..23bd6fb4 100644 --- a/tests/dincli/conftest.py +++ b/tests/dincli/conftest.py @@ -56,7 +56,7 @@ RESULTS_DIR, ) -from tests.dincli.demo import bootstrap_demo, generate_demo_accounts +from tests.dincli.demo import bootstrap_demo, prepare_demo_accounts from tests.dincli.services import start_service, rpc_ready, ipfs_ready @@ -559,13 +559,12 @@ def bootstrap(managed_services, din_info_backup, run): running before the first command. """ accounts_path = DEVNET_ROOT / "dincli" / "config" / "accounts.json" - if ISOLATED_MODE: - generate_demo_accounts(accounts_path) + generated_accounts = ISOLATED_MODE and prepare_demo_accounts(accounts_path) try: bootstrap_demo(run) yield finally: - if ISOLATED_MODE: + if generated_accounts: accounts_path.unlink() diff --git a/tests/dincli/demo.py b/tests/dincli/demo.py index e388bfcc..4166dcb4 100644 --- a/tests/dincli/demo.py +++ b/tests/dincli/demo.py @@ -45,6 +45,31 @@ def generate_demo_accounts(accounts_path: Path) -> Path: return accounts_path +def prepare_demo_accounts(accounts_path: Path) -> bool: + """Reuse validated public accounts, or generate them and return ownership. + + Only a file created here may be removed by the caller. A checkout already + includes public demo accounts; preserve that file byte-for-byte. + """ + accounts_path = Path(accounts_path) + if accounts_path.is_symlink(): + raise ValueError(f"Refusing symlink demo accounts: {accounts_path}") + if not accounts_path.exists(): + generate_demo_accounts(accounts_path) + return True + try: + accounts = json.loads(accounts_path.read_text(encoding="utf-8"))["hardhat"] + for index, expected_address in enumerate(DEMO_ADDRESSES): + account = accounts[index] + if account["address"].lower() != expected_address.lower(): + raise ValueError("Unexpected demo address") + if Account.from_key(account["private_key"]).address != expected_address: + raise ValueError("Unexpected demo private key") + except (ValueError, TypeError, KeyError, IndexError) as error: + raise ValueError(f"Invalid public demo accounts: {accounts_path}") from error + return False + + def bootstrap_demo(run) -> None: """Configure local demo mode and connect the two named test role wallets.""" run(["system", "init"]) diff --git a/tests/test_integration_demo.py b/tests/test_integration_demo.py index 9b06ea7f..db8a77a2 100644 --- a/tests/test_integration_demo.py +++ b/tests/test_integration_demo.py @@ -5,7 +5,7 @@ import pytest from eth_account import Account -from tests.dincli.demo import bootstrap_demo, generate_demo_accounts +from tests.dincli.demo import bootstrap_demo, generate_demo_accounts, prepare_demo_accounts @@ -80,3 +80,46 @@ def failing_run(args): bootstrap_demo(failing_run) assert commands == [["system", "init"]] + + +def test_prepare_demo_accounts_preserves_checked_in_public_accounts(): + from pathlib import Path + + accounts_path = Path(__file__).resolve().parents[1] / "dincli/config/accounts.json" + original = accounts_path.read_bytes() + assert prepare_demo_accounts(accounts_path) is False + assert accounts_path.read_bytes() == original + + +def test_prepare_demo_accounts_reports_ownership_of_generated_file(tmp_path): + accounts_path = tmp_path / "accounts.json" + assert prepare_demo_accounts(accounts_path) is True + assert accounts_path.exists() + + +@pytest.mark.parametrize("invalid", ["malformed-json", "missing-account", "wrong-address", "wrong-key"]) +def test_prepare_demo_accounts_rejects_invalid_data_without_changing_it(tmp_path, invalid): + accounts_path = tmp_path / "accounts.json" + generate_demo_accounts(accounts_path) + data = json.loads(accounts_path.read_text()) + if invalid == "missing-account": + data["hardhat"].pop() + elif invalid == "wrong-address": + data["hardhat"][0]["address"] = data["hardhat"][1]["address"] + elif invalid == "wrong-key": + data["hardhat"][0]["private_key"] = data["hardhat"][1]["private_key"] + accounts_path.write_text("invalid" if invalid == "malformed-json" else json.dumps(data)) + original = accounts_path.read_bytes() + with pytest.raises(ValueError, match="Invalid public demo accounts"): + prepare_demo_accounts(accounts_path) + assert accounts_path.read_bytes() == original + + +def test_prepare_demo_accounts_refuses_symlink(tmp_path): + target = tmp_path / "public-accounts.json" + generate_demo_accounts(target) + accounts_path = tmp_path / "accounts.json" + accounts_path.symlink_to(target) + with pytest.raises(ValueError, match="Refusing symlink"): + prepare_demo_accounts(accounts_path) + assert accounts_path.is_symlink() diff --git a/tests/test_integration_harness.py b/tests/test_integration_harness.py index 92e300b5..00a603dd 100644 --- a/tests/test_integration_harness.py +++ b/tests/test_integration_harness.py @@ -192,3 +192,25 @@ def run(args): assert len(commands) == 5 fixture.close() assert not accounts_path.exists() + + +@pytest.mark.parametrize("fail_bootstrap", [False, True]) +def test_isolated_bootstrap_preserves_existing_public_accounts(harness, fail_bootstrap): + from tests.dincli.demo import generate_demo_accounts + + accounts_path = harness.DEVNET_ROOT / "dincli/config/accounts.json" + generate_demo_accounts(accounts_path) + original = accounts_path.read_bytes() + + def run(args): + if fail_bootstrap: + raise RuntimeError("bootstrap command failed") + + fixture = harness.bootstrap.__wrapped__(None, None, run) + if fail_bootstrap: + with pytest.raises(RuntimeError, match="bootstrap command failed"): + next(fixture) + else: + next(fixture) + fixture.close() + assert accounts_path.read_bytes() == original From 03bf1c73f6ed02e349bd1f07fa45125755888ed9 Mon Sep 17 00:00:00 2001 From: umermjd11 Date: Tue, 6 Oct 2026 21:26:18 +0500 Subject: [PATCH 03/48] refactor(task-auditor): DINTaskAuditor size review (#201 Part A item 4) - auditBatches, dinAuditors, Is_testdataCIDs_Assigned and auditorGIWeight lose their auto-getters (internal). Nothing outside the contract read them; getAuditorsBatch / getDINtaskAuditors already cover the first two. -320 B. - Fold review (required by task_061026_20 Part A), each measured on its own on top of the getter change: - F1 assignAuditTestDataset / reassignAuditTestDataset share _storeTestData (CID, per-auditor key check + store, commitment, events): -924 B - F2 shared _batchAt(gi, batchId) bounds + id check: -26 B - F3 _burnAndForward forwards its half through _forwardToTreasury: -260 B All three shrink the contract, so all three are kept. Behaviour, events and storage layout are unchanged. - DINTaskAuditor runtime 22,718 B -> 21,188 B (margin 1,858 -> 3,388 B; out of the CI warn band). - dincli/abis/DINTaskAuditor.json regenerated (dump-abi --official): only the four getters go. DINTaskAuditor.md section 3.4 + change log. forge test: 491 passed, 0 failed. Co-Authored-By: Claude Opus 5.5 --- .../technical/contracts/DINTaskAuditor.md | 11 ++- dincli/abis/DINTaskAuditor.json | 96 ------------------- foundry/src/DINTaskAuditor.sol | 59 ++++++------ 3 files changed, 35 insertions(+), 131 deletions(-) diff --git a/Documentation/technical/contracts/DINTaskAuditor.md b/Documentation/technical/contracts/DINTaskAuditor.md index 740bf81d..d44221d6 100644 --- a/Documentation/technical/contracts/DINTaskAuditor.md +++ b/Documentation/technical/contracts/DINTaskAuditor.md @@ -77,12 +77,14 @@ The reward split and the S1 fraction are explicitly provisional (MECHANISM_DESIG ### 3.4 Per-GI data -- **Registration:** `dinAuditors[gi]`, `isRegisteredAuditor[gi][addr]`. +Mappings marked *(internal)* have no auto-getter; read them through the view named next to them (issue No. 201 Part A item 4). + +- **Registration:** `dinAuditors[gi]` *(internal; read with `getDINtaskAuditors(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`. +- **Batches:** `auditBatches[gi]` *(internal; read with `getAuditorsBatch(gi, batchId)`)* (`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`. +- **Test data:** `encryptedTestDataKey[gi][batchId][auditor]`, `testDataCommitments[gi][batchId]`, `Is_testdataCIDs_Assigned[gi]` *(internal; only guards `setTestDataAssignedFlag` against a second call)*, `testDataDisputes[gi][batchId]`. +- **Rewards:** `giRewardPool[gi]`, `giRewardSnapshot[gi]`, `giTotalApprovedScore[gi]`, `giTotalAuditWeight[gi]`, `auditorGIWeight[gi][addr]` *(internal; feeds `claimReward`)*, `rewardClaimed[gi][addr]`, `claimable[addr]`, `treasuryAccrued`. --- @@ -221,3 +223,4 @@ Read alongside the [foundry/src security review](../audits/foundry-src-security- - `createAuditorsBatches` takes the coordinator's locked audit seed (issue No. 156 H-2, PR No. 191). - The audit commit hash binds the auditor and the slot: `keccak256(abi.encode(score, vote, salt, msg.sender, gi, batchId, modelIndex))` replaces `keccak256(abi.encodePacked(score, vote, salt))` (issue No. 192). Function signatures and the ABI are unchanged; in-flight commits made under the old formula can't be revealed after the switch. - Test-data disputes (issue No. 205): only an auditor of the batch can open one; `resolveTestDataDispute` is owner-only; `closeExpiredDispute` now upholds an unanswered dispute instead of forfeiting the bond; `disputeBondAmount` defaults to 100 DIN. `DisputeExpired` drops its `bondForfeited` field. Disputes can't be opened once the GI is settled (`TA_RewardsAlreadySettled`), and one upheld after settlement takes no pool penalty, so settled claims stay funded (PR No. 215 review). +- Size review (issue No. 201 Part A item 4): `auditBatches`, `dinAuditors`, `Is_testdataCIDs_Assigned` and `auditorGIWeight` lose their auto-getters (nothing outside the contract read them; `getAuditorsBatch` / `getDINtaskAuditors` cover the first two). `assignAuditTestDataset` and `reassignAuditTestDataset` share `_storeTestData` and `_batchAt`, and `_burnAndForward` forwards its half through `_forwardToTreasury`. Behaviour and events are unchanged; runtime size drops from 22,718 B to 21,188 B. diff --git a/dincli/abis/DINTaskAuditor.json b/dincli/abis/DINTaskAuditor.json index fd5512bc..9861cca9 100644 --- a/dincli/abis/DINTaskAuditor.json +++ b/dincli/abis/DINTaskAuditor.json @@ -40,25 +40,6 @@ ], "stateMutability": "view" }, - { - "type": "function", - "name": "Is_testdataCIDs_Assigned", - "inputs": [ - { - "name": "", - "type": "uint256", - "internalType": "uint256" - } - ], - "outputs": [ - { - "name": "", - "type": "bool", - "internalType": "bool" - } - ], - "stateMutability": "view" - }, { "type": "function", "name": "LMeligibleVote", @@ -158,35 +139,6 @@ "outputs": [], "stateMutability": "nonpayable" }, - { - "type": "function", - "name": "auditBatches", - "inputs": [ - { - "name": "", - "type": "uint256", - "internalType": "uint256" - }, - { - "name": "", - "type": "uint256", - "internalType": "uint256" - } - ], - "outputs": [ - { - "name": "batchId", - "type": "uint256", - "internalType": "uint256" - }, - { - "name": "testDataCID", - "type": "bytes", - "internalType": "bytes" - } - ], - "stateMutability": "view" - }, { "type": "function", "name": "auditScoreCommits", @@ -255,30 +207,6 @@ ], "stateMutability": "view" }, - { - "type": "function", - "name": "auditorGIWeight", - "inputs": [ - { - "name": "", - "type": "uint256", - "internalType": "uint256" - }, - { - "name": "", - "type": "address", - "internalType": "address" - } - ], - "outputs": [ - { - "name": "", - "type": "uint256", - "internalType": "uint256" - } - ], - "stateMutability": "view" - }, { "type": "function", "name": "claimReward", @@ -467,30 +395,6 @@ "outputs": [], "stateMutability": "nonpayable" }, - { - "type": "function", - "name": "dinAuditors", - "inputs": [ - { - "name": "", - "type": "uint256", - "internalType": "uint256" - }, - { - "name": "", - "type": "uint256", - "internalType": "uint256" - } - ], - "outputs": [ - { - "name": "", - "type": "address", - "internalType": "address" - } - ], - "stateMutability": "view" - }, { "type": "function", "name": "dinToken", diff --git a/foundry/src/DINTaskAuditor.sol b/foundry/src/DINTaskAuditor.sol index c465286f..3b404ca5 100644 --- a/foundry/src/DINTaskAuditor.sol +++ b/foundry/src/DINTaskAuditor.sol @@ -92,7 +92,7 @@ contract DINTaskAuditor is Ownable, ReentrancyGuardTransient { mapping(uint256 => uint256) public giTotalAuditWeight; /// @notice Per-auditor hasAuditedLM count for a GI, incremented in revealAuditScore. - mapping(uint256 => mapping(address => uint256)) public auditorGIWeight; + mapping(uint256 => mapping(address => uint256)) internal auditorGIWeight; /// @notice Whether a participant has already called claimReward for a GI. mapping(uint256 => mapping(address => bool)) public rewardClaimed; @@ -100,7 +100,7 @@ contract DINTaskAuditor is Ownable, ReentrancyGuardTransient { uint MAX_LM_SUBMISSIONS = 10000; uint256 public constant MAX_REGISTERED_AUDITORS = 300; - mapping(uint => address[]) public dinAuditors; + mapping(uint => address[]) internal dinAuditors; // Track if an address is registered for a given _GI mapping(uint => mapping(address => bool)) public isRegisteredAuditor; @@ -224,7 +224,7 @@ contract DINTaskAuditor is Ownable, ReentrancyGuardTransient { bytes testDataCID; // encryptedCID = AES-256-GCM(K, rawCID || Sign(ownerSK, rawCID)) } - mapping(uint256 => AuditBatch[]) public auditBatches; + mapping(uint256 => AuditBatch[]) internal auditBatches; mapping(uint => mapping(uint => mapping(address => bool))) public isBatchAuditor; @@ -259,7 +259,7 @@ contract DINTaskAuditor is Ownable, ReentrancyGuardTransient { mapping(uint256 => mapping(uint => mapping(address => mapping(uint => bool)))) // GI // batchId // auditor // modelIndex // has committed public hasCommittedLM; - mapping(uint256 => bool) public Is_testdataCIDs_Assigned; + mapping(uint256 => bool) internal Is_testdataCIDs_Assigned; // §2b: per-validator encrypted test-data key mapping. GI => batchId => // auditor => that auditor's copy of the test-data decryption key, @@ -490,18 +490,19 @@ contract DINTaskAuditor is Ownable, ReentrancyGuardTransient { /// @dev Burns 50% of `amount` and forwards 50% to the platform slash-treasury /// (`dinvalidatorStakeContract.slashTreasury()`). Burns both halves when /// the slash-treasury is unset. Increments `treasuryAccrued` for observability. + /// @dev Bounds- and id-checked batch lookup shared by the test-data paths. + function _batchAt(uint256 gi, uint256 batchId) internal view returns (AuditBatch storage batch) { + if (batchId >= auditBatches[gi].length) revert TA_BatchDoesNotExist(); + batch = auditBatches[gi][batchId]; + if (batch.batchId != batchId) revert TA_BatchIDMismatch(); + } + function _burnAndForward(uint256 amount) internal { if (amount == 0) return; uint256 burnAmt = amount / 2; - uint256 fwdAmt = amount - burnAmt; IBurnableDinToken(address(dinToken)).burn(burnAmt); - address treasury = dinvalidatorStakeContract.slashTreasury(); - if (treasury != address(0)) { - dinToken.safeTransfer(treasury, fwdAmt); - } else { - IBurnableDinToken(address(dinToken)).burn(fwdAmt); - } - treasuryAccrued += amount; + treasuryAccrued += burnAmt; + _forwardToTreasury(amount - burnAmt); } /// @dev Forwards `amount` in full to the platform slash-treasury. @@ -986,24 +987,31 @@ contract DINTaskAuditor is Ownable, ReentrancyGuardTransient { bytes[] calldata encryptedKeys, bytes32 commitment ) external onlyOwner onlyCurrentGI(gi) { - if (batchId >= auditBatches[gi].length) revert TA_BatchDoesNotExist(); - AuditBatch storage batch = auditBatches[gi][batchId]; - if (batch.batchId != batchId) revert TA_BatchIDMismatch(); + AuditBatch storage batch = _batchAt(gi, batchId); if (encryptedKeys.length != batch.auditors.length) revert TA_EncryptedKeyCountMismatch(); if (testDataDisputes[gi][batchId].pendingReassignment) revert TA_BatchPendingReassignment(); - batch.testDataCID = testDataCID; + _storeTestData(gi, batchId, batch, testDataCID, encryptedKeys, commitment); + } + /// @dev Shared tail of assignAuditTestDataset / reassignAuditTestDataset. + function _storeTestData( + uint256 gi, + uint256 batchId, + AuditBatch storage batch, + bytes calldata testDataCID, + bytes[] calldata encryptedKeys, + bytes32 commitment + ) internal { + batch.testDataCID = testDataCID; for (uint256 i = 0; i < encryptedKeys.length; i++) { if (dinvalidatorStakeContract.getEncryptionKey(batch.auditors[i]).length == 0) revert TA_AuditorEncryptionKeyNotRegistered(); encryptedTestDataKey[gi][batchId][batch.auditors[i]] = encryptedKeys[i]; } - testDataCommitments[gi][batchId] = commitment; - emit EncryptedTestDataKeysAssigned(gi, batchId, testDataCID, encryptedKeys.length); emit TestDataCommitmentStored(gi, batchId, commitment); } @@ -1605,24 +1613,13 @@ contract DINTaskAuditor is Ownable, ReentrancyGuardTransient { bytes[] calldata newEncryptedKeys, bytes32 newCommitment ) external onlyOwner onlyCurrentGI(gi) { - if (batchId >= auditBatches[gi].length) revert TA_BatchDoesNotExist(); - AuditBatch storage batch = auditBatches[gi][batchId]; - if (batch.batchId != batchId) revert TA_BatchIDMismatch(); + AuditBatch storage batch = _batchAt(gi, batchId); if (!testDataDisputes[gi][batchId].pendingReassignment) revert TA_NoActiveDispute(); if (newEncryptedKeys.length != batch.auditors.length) revert TA_EncryptedKeyCountMismatch(); testDataDisputes[gi][batchId].pendingReassignment = false; - batch.testDataCID = newTestDataCID; - for (uint256 i = 0; i < newEncryptedKeys.length; i++) { - if (dinvalidatorStakeContract.getEncryptionKey(batch.auditors[i]).length == 0) - revert TA_AuditorEncryptionKeyNotRegistered(); - encryptedTestDataKey[gi][batchId][batch.auditors[i]] = newEncryptedKeys[i]; - } - testDataCommitments[gi][batchId] = newCommitment; - - emit EncryptedTestDataKeysAssigned(gi, batchId, newTestDataCID, newEncryptedKeys.length); - emit TestDataCommitmentStored(gi, batchId, newCommitment); + _storeTestData(gi, batchId, batch, newTestDataCID, newEncryptedKeys, newCommitment); } } From c4c2b1cdfa87fa98423d1b3e234b781fcf14b787 Mon Sep 17 00:00:00 2001 From: umermjd11 Date: Tue, 6 Oct 2026 21:53:56 +0500 Subject: [PATCH 04/48] fix(task-auditor): one address can't hold both roles in a GI (#180) - registerDINAuditor reverts with the new TA_DualRoleNotAllowed when the caller is already an aggregator for the same GI, read from the coordinator's existing public isDINAggregator mapping. The check is on the auditor side because aggregator registration (states 6-7) always comes before auditor registration (8-9). IDINTaskCoordinator gains isDINAggregator. - The guard is per address (PR No. 219 Decision 2): a second address with its own stake can still take both roles. The tests document that limit. - DinValidatorStake NatSpec: activeRegistrationCount is decremented by releaseGIRegistrationSlots, not endGI; setModelStakeBounds / setMaxConcurrentRegistrationsPerStakeUnit say where the task contracts enforce them (they no longer say "not yet enforced"). - Tests (StakingEnforcement.t.sol): aggregator -> auditor in the same GI reverts (fails without the guard); GI 1's aggregator registers as a GI 2 auditor; a second funded address registers as auditor. - Docs: threat model Row 6 KNOWN GAP -> DEFENDED (single address), Gaps table and Judgment call 2; DINShared.md interface + error rows; DINTaskAuditor.md registration checks + change log; DinValidatorStake.md caveat No. 4. PR No. 218's test_knownGap_dualRoleRegistration is not flipped here (it flips when that PR rebases). - dincli/abis/DINTaskAuditor.json regenerated: only the new error. DINTaskAuditor runtime 21,188 -> 21,290 B (+102 B, margin 3,286 B). forge test: 494 passed, 0 failed. Co-Authored-By: Claude Opus 5.5 --- Developer/design/adversarial-threat-model.md | 12 +-- .../technical/contracts/DINShared.md | 3 + .../technical/contracts/DINTaskAuditor.md | 3 +- .../technical/contracts/DinValidatorStake.md | 2 +- dincli/abis/DINTaskAuditor.json | 5 ++ foundry/src/DINShared.sol | 10 +++ foundry/src/DINTaskAuditor.sol | 8 ++ foundry/src/DinValidatorStake.sol | 13 +++- foundry/test/StakingEnforcement.t.sol | 75 ++++++++++++++++++- 9 files changed, 119 insertions(+), 12 deletions(-) diff --git a/Developer/design/adversarial-threat-model.md b/Developer/design/adversarial-threat-model.md index 553ae02f..81de333c 100644 --- a/Developer/design/adversarial-threat-model.md +++ b/Developer/design/adversarial-threat-model.md @@ -80,9 +80,9 @@ entry in `foundry/test/P3Adversarial.t.sol`. |---|---| | **Attacker** | One address registered as both auditor and aggregator in the same GI; model owner tries to steer the batch-assignment draw (formerly by choosing which block to submit batch-creation calls in) | | **Capability** | One address (auditor + aggregator), staking MIN_STAKE. Before issue #156 H-2 (PR #191) the owner chose which block `autoCreateTier1AndTier2` and `createAuditorsBatches` were submitted in, picking a `blockhash(block.number - 1)` that routed the poisoned model into the colluding T1 batch. Both now read a locked, future-block-anchored `aggSeed`/`auditSeed` (see Row 2), so that lever is closed; what remains is the BL-26 re-roll (decline to lock, wait out the ~256-block window) and TA-4 | -| **Protocol response today** | Nothing prevents dual-role registration: `registerDINaggregator` (`DINTaskCoordinator.sol:386`) and `registerDINAuditor` (`DINTaskAuditor.sol:660`) have no cross-role check. Both increment `activeRegistrationCount`, so the concurrent-registration cap only raises the minimum stake required (≥ 2 concurrent registrations' worth) — it does not block. The cap is also off by default (`maxConcurrentRegistrationsPerStakeUnit == 0`, `DINTaskCoordinator.sol:406`). Note: a per-address cross-role guard is Sybil-bypassable (second address with its own stake), so the stake cost may be the only practical defence in any case. | -| **Expected test outcome** | **KNOWN GAP** ([#180](https://github.com/InfiniteZeroFoundation/DevNet/issues/180); the [#156](https://github.com/InfiniteZeroFoundation/DevNet/issues/156) block-selection half is fixed, residuals in BL-26) — the test registers one address in both roles for the same GI and asserts it succeeds. The "colluding aggregators skip outlier clipping" step is off-chain (model-dependent, not testable in Forge). | -| **Trust assumption** | None that defends this — gap pending #180 (role-separation or stake-cost documentation) and #156 (stronger batch-shuffle seed). | +| **Protocol response today** | One address can no longer hold both roles in a GI: `registerDINAuditor` (`DINTaskAuditor.sol:668`) reverts with `TA_DualRoleNotAllowed` when the caller is already an aggregator for that GI, read from the coordinator's `isDINAggregator` (#180). The check sits on the auditor side because aggregator registration (`registerDINaggregator`, `DINTaskCoordinator.sol:401`; states 6–7) always comes before auditor registration (8–9). It is per address: a second address with its own stake still gets both roles (Sybil), so the remaining defence is that the attack costs a second stake and is visible on-chain. The concurrent-registration cap (off by default, `maxConcurrentRegistrationsPerStakeUnit == 0`) can raise that cost further when it is non-zero. | +| **Expected test outcome** | **DEFENDED** for the single-address case ([#180](https://github.com/InfiniteZeroFoundation/DevNet/issues/180), `StakingEnforcement.t.sol` `test_registerDINAuditor_aggregatorSameGI_reverts`); the Sybil variant is documented by `test_registerDINAuditor_secondAddressWithOwnStake_registers`. The [#156](https://github.com/InfiniteZeroFoundation/DevNet/issues/156) block-selection half is fixed, residuals in BL-26. The "colluding aggregators skip outlier clipping" step is off-chain (model-dependent, not testable in Forge). PR #218's `test_knownGap_dualRoleRegistration` flips to `test_defended_…` when it rebases on this change. | +| **Trust assumption** | Operators don't split one stake-holder across two funded addresses to take both roles (Sybil); the cost is a second stake. Batch-shuffle residuals as in BL-26. | --- @@ -164,7 +164,7 @@ entry in `foundry/test/P3Adversarial.t.sol`. |---|---|---|---| | S3 graduating from shadow mode | [#38](https://github.com/InfiniteZeroFoundation/DevNet/issues/38) | Row 4 (auditor bloc poisoned model) | `s3SlashingEnabled` set to `true` after threshold calibration | | T1 wrong CID — finalCID not replaced, dissenter not refunded | [#194](https://github.com/InfiniteZeroFoundation/DevNet/issues/194) (#181 covers who adjudicates, not repairing the outcome) | Row 5 (T1 wrong CID) | `settleRecomputation(confirmed=true)` replaces `finalCID` and refunds the honest dissenter | -| Dual-role registration guard | [#180](https://github.com/InfiniteZeroFoundation/DevNet/issues/180) | Row 6 (cross-role + owner block-selection) | Per-address role-separation guard added, or the stake cost documented as the intended defence (with a non-zero concurrent-registration cap) | +| Dual-role registration guard | [#180](https://github.com/InfiniteZeroFoundation/DevNet/issues/180) | Row 6 (cross-role + owner block-selection) | **Flipped by task_061026_20 Part B:** per-address guard in `registerDINAuditor` (`TA_DualRoleNotAllowed`). The Sybil variant (second funded address) remains, bounded by stake cost | | Stronger batch-shuffle seed | [#156](https://github.com/InfiniteZeroFoundation/DevNet/issues/156) | Rows 2, 6 (owner block-selection) | **Flipped by PR #191:** shuffles seeded by the locked, future-block `aggSeed`/`auditSeed` rather than at owner-controlled call time. Residuals (decline-to-lock re-roll, post-lock pool reshaping) tracked in BL-26 | | Decentralized dispute adjudication | [#181](https://github.com/InfiniteZeroFoundation/DevNet/issues/181) | Row 7 (owner suppresses dispute) and bond-locked-forever variant | `resolveDispute` gated by multi-sig, DAO committee, or fraud-proof; timeout added for unresolved disputes | | Dispute timeout for unresolved disputes | [#181](https://github.com/InfiniteZeroFoundation/DevNet/issues/181) | Row 7 (owner never resolves → bond locked) | `expireDispute` (or a new function) reachable when `d.resolved == false` after a deadline | @@ -179,5 +179,5 @@ entry in `foundry/test/P3Adversarial.t.sol`. 1. **Row 5 — T1 wrong CID: KNOWN GAP.** `settleRecomputation(confirmed=true)` does not replace `finalCID` and does not refund the honest dissenter's `AGG_T1_BAD_CONSENSUS` slash, so the attack partially succeeds regardless of TA-1. COST-BOUNDED was rejected because it would pass a test while the honest dissenter is never made whole. Test: `test_knownGap_t1WrongCID`. -2. **Row 6 — Dual-role registration: KNOWN GAP.** - Not accepted as intended; [#180](https://github.com/InfiniteZeroFoundation/DevNet/issues/180) stays open. A per-address cross-role guard is Sybil-bypassable (a second address with its own stake), so #180 decides between adding the guard and documenting the stake cost as the defence. The concurrent-registration cap only bounds that cost when it is non-zero. +2. **Row 6 — Dual-role registration: DEFENDED (single address).** + Not accepted as intended. [#180](https://github.com/InfiniteZeroFoundation/DevNet/issues/180) chose the per-address guard (PR #219 Decision 2): `registerDINAuditor` refuses an address already registered as an aggregator for the GI. It is Sybil-bypassable (a second address with its own stake), which the tests document; the concurrent-registration cap bounds that cost further only when it is non-zero. diff --git a/Documentation/technical/contracts/DINShared.md b/Documentation/technical/contracts/DINShared.md index 659d465e..474712c8 100644 --- a/Documentation/technical/contracts/DINShared.md +++ b/Documentation/technical/contracts/DINShared.md @@ -158,6 +158,7 @@ Used by: `DINTaskCoordinator`, `DINTaskAuditor` interface IDINTaskCoordinator { function GI() external view returns (uint256); function GIstate() external view returns (GIstates); + function isDINAggregator(uint256 gi, address aggregator) external view returns (bool); function aggregatorWeight(uint256 gi, address aggregator) external view returns (uint256); } ``` @@ -168,6 +169,7 @@ Used by: `DINTaskAuditor` |--------|---------| | `GI()` | Current GI counter, for `onlyCurrentGI` and `depositRewards` validation | | `GIstate()` | Lifecycle gate for every auditor-side phase | +| `isDINAggregator` | Read by `registerDINAuditor` to refuse an address already registered as an aggregator for the GI (issue No. 180) | | `aggregatorWeight` | Read at `claimReward` time to compute each aggregator's weighted share | ### 3.3 `IDINTaskAuditor` @@ -227,6 +229,7 @@ Used by: `DINTaskCoordinator` | `TA_AuditorRegistrationNotOpen` | Registration attempted outside registration window | | `TA_WrongGI` | Global Iteration mismatch | | `TA_AuditorAlreadyRegistered` | Duplicate auditor registration for same GI | +| `TA_DualRoleNotAllowed` | `registerDINAuditor` by an address already registered as an aggregator for the same GI (issue No. 180) | | `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) | diff --git a/Documentation/technical/contracts/DINTaskAuditor.md b/Documentation/technical/contracts/DINTaskAuditor.md index d44221d6..5b667def 100644 --- a/Documentation/technical/contracts/DINTaskAuditor.md +++ b/Documentation/technical/contracts/DINTaskAuditor.md @@ -103,7 +103,7 @@ Mappings marked *(internal)* have no auto-getter; read them through the view nam ## 5. Registration & Submissions -- **`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`. +- **`registerDINAuditor(gi)`** — requires the coordinator state `DINauditorsRegistrationStarted`. Checks: not already registered, not already an aggregator for the same GI (`TA_DualRoleNotAllowed`, read from the coordinator's `isDINAggregator`; issue No. 180), 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. --- @@ -224,3 +224,4 @@ Read alongside the [foundry/src security review](../audits/foundry-src-security- - The audit commit hash binds the auditor and the slot: `keccak256(abi.encode(score, vote, salt, msg.sender, gi, batchId, modelIndex))` replaces `keccak256(abi.encodePacked(score, vote, salt))` (issue No. 192). Function signatures and the ABI are unchanged; in-flight commits made under the old formula can't be revealed after the switch. - Test-data disputes (issue No. 205): only an auditor of the batch can open one; `resolveTestDataDispute` is owner-only; `closeExpiredDispute` now upholds an unanswered dispute instead of forfeiting the bond; `disputeBondAmount` defaults to 100 DIN. `DisputeExpired` drops its `bondForfeited` field. Disputes can't be opened once the GI is settled (`TA_RewardsAlreadySettled`), and one upheld after settlement takes no pool penalty, so settled claims stay funded (PR No. 215 review). - Size review (issue No. 201 Part A item 4): `auditBatches`, `dinAuditors`, `Is_testdataCIDs_Assigned` and `auditorGIWeight` lose their auto-getters (nothing outside the contract read them; `getAuditorsBatch` / `getDINtaskAuditors` cover the first two). `assignAuditTestDataset` and `reassignAuditTestDataset` share `_storeTestData` and `_batchAt`, and `_burnAndForward` forwards its half through `_forwardToTreasury`. Behaviour and events are unchanged; runtime size drops from 22,718 B to 21,188 B. +- Dual-role guard (issue No. 180): `registerDINAuditor` reverts with `TA_DualRoleNotAllowed` when the caller is already an aggregator for the same GI. The check is on the auditor side because aggregators always register first; it is per address, so a second address with its own stake can still take the auditor role. `IDINTaskCoordinator` gains `isDINAggregator`. diff --git a/Documentation/technical/contracts/DinValidatorStake.md b/Documentation/technical/contracts/DinValidatorStake.md index 570d82c7..517a0e77 100644 --- a/Documentation/technical/contracts/DinValidatorStake.md +++ b/Documentation/technical/contracts/DinValidatorStake.md @@ -309,7 +309,7 @@ Until step 8, slasher management through the coordinator reverts `ValidatorStake - **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. 4 — Stale NatSpec (partly fixed, issue No. 180):** the `setModelStakeBounds` / `setMaxConcurrentRegistrationsPerStakeUnit` NatSpec now says where the task contracts enforce both (§8), and `activeRegistrationCount` names `releaseGIRegistrationSlots` as the decrement. Still open: `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 §13. diff --git a/dincli/abis/DINTaskAuditor.json b/dincli/abis/DINTaskAuditor.json index 9861cca9..2a6bb6c9 100644 --- a/dincli/abis/DINTaskAuditor.json +++ b/dincli/abis/DINTaskAuditor.json @@ -2463,6 +2463,11 @@ "name": "TA_DisputeWindowClosed", "inputs": [] }, + { + "type": "error", + "name": "TA_DualRoleNotAllowed", + "inputs": [] + }, { "type": "error", "name": "TA_EmptyCommitHash", diff --git a/foundry/src/DINShared.sol b/foundry/src/DINShared.sol index b325fe08..a27f1e81 100644 --- a/foundry/src/DINShared.sol +++ b/foundry/src/DINShared.sol @@ -112,6 +112,12 @@ interface IDINTaskCoordinator { function GIstate() external view returns (GIstates); + /// @dev Whether `aggregator` registered as an aggregator for GI `gi` + /// (the coordinator's public mapping). Read by + /// DINTaskAuditor.registerDINAuditor to refuse dual-role registration + /// (issue No. 180). + function isDINAggregator(uint256 gi, address aggregator) external view returns (bool); + /// @dev Per-(aggregator, finalized-batch) settlement weight for a GI, /// read by DINTaskAuditor.claimReward at claim time (BL-10, #125). function aggregatorWeight( @@ -364,6 +370,10 @@ error TA_RewardsNotSettled(); /// settled; claims pay from the snapshot, so a pool penalty could no /// longer be taken without underfunding them. error TA_RewardsAlreadySettled(); +/// @dev registerDINAuditor was called by an address already registered as an +/// aggregator for the same GI. One address can't hold both roles in a GI +/// (issue No. 180); a second address with its own stake still can. +error TA_DualRoleNotAllowed(); /// @dev Caller already claimed their reward for this GI. error TA_RewardAlreadyClaimed(); /// @dev Caller earned no reward for this GI (not a participant, or scored zero). diff --git a/foundry/src/DINTaskAuditor.sol b/foundry/src/DINTaskAuditor.sol index 3b404ca5..ce6c7c67 100644 --- a/foundry/src/DINTaskAuditor.sol +++ b/foundry/src/DINTaskAuditor.sol @@ -664,6 +664,12 @@ contract DINTaskAuditor is Ownable, ReentrancyGuardTransient { /// @notice Registers the caller as an auditor for the current GI. /// @dev Caller must be an active validator; duplicate registrations revert. /// The coordinator's GI state must be DINauditorsRegistrationStarted. + /// An address already registered as an aggregator for this GI is + /// refused (TA_DualRoleNotAllowed, issue No. 180). The check lives + /// here because aggregator registration (states 6-7) always comes + /// before auditor registration (8-9), so the coordinator side could + /// never see an auditor yet. It is per address: a second address with + /// its own stake can still register. /// @param _GI Current GI index. function registerDINAuditor(uint _GI) public onlyCurrentGI(_GI) { if ( @@ -672,6 +678,8 @@ contract DINTaskAuditor is Ownable, ReentrancyGuardTransient { ) revert TA_AuditorRegistrationNotOpen(); if (isRegisteredAuditor[_GI][msg.sender]) revert TA_AuditorAlreadyRegistered(); + if (dintaskcoordinatorContract.isDINAggregator(_GI, msg.sender)) + revert TA_DualRoleNotAllowed(); if (dinAuditors[_GI].length >= MAX_REGISTERED_AUDITORS) revert TA_RegistrationCapReached(); diff --git a/foundry/src/DinValidatorStake.sol b/foundry/src/DinValidatorStake.sol index ed61fba3..7435b575 100644 --- a/foundry/src/DinValidatorStake.sol +++ b/foundry/src/DinValidatorStake.sol @@ -118,7 +118,9 @@ contract DinValidatorStake is /// @notice Number of active GI registrations held by each validator across /// all task contracts. Incremented by task contracts (as slashers) - /// at registration time, decremented at endGI time. Used to enforce + /// at registration time, decremented when the model owner calls + /// DINTaskCoordinator.releaseGIRegistrationSlots for an ended GI + /// (endGI itself does not decrement). Used to enforce /// maxConcurrentRegistrationsPerStakeUnit when non-zero. mapping(address => uint256) public activeRegistrationCount; @@ -448,7 +450,10 @@ contract DinValidatorStake is emit UnbondingPeriodUpdated(newPeriod); } - /// @notice Stores per-model stake bounds (not yet enforced — follow-up task). + /// @notice Stores per-model stake bounds. The min bound is enforced as the + /// per-model stake floor in DINTaskCoordinator.registerDINaggregator + /// and DINTaskAuditor.registerDINAuditor (via getModelStakeMin); + /// the max bound is stored but not read on-chain. function setModelStakeBounds( uint256 modelId, uint256 min, @@ -459,7 +464,9 @@ contract DinValidatorStake is emit ModelStakeBoundsUpdated(modelId, min, max); } - /// @notice Stores the concurrent-registration cap per stake unit (not yet enforced). + /// @notice Stores the concurrent-registration cap per stake unit, enforced + /// in DINTaskCoordinator.registerDINaggregator and + /// DINTaskAuditor.registerDINAuditor when non-zero (0 = no cap). function setMaxConcurrentRegistrationsPerStakeUnit( uint256 value ) external onlyOwner { diff --git a/foundry/test/StakingEnforcement.t.sol b/foundry/test/StakingEnforcement.t.sol index 5cbc1c0e..38cf069d 100644 --- a/foundry/test/StakingEnforcement.t.sol +++ b/foundry/test/StakingEnforcement.t.sol @@ -23,7 +23,8 @@ import { TC_ConcurrentRegistrationCapReached, TC_WrongGI, TA_StakeBelowModelFloor, - TA_ConcurrentRegistrationCapReached + TA_ConcurrentRegistrationCapReached, + TA_DualRoleNotAllowed } from "../src/DINShared.sol"; import {auditCommitHash} from "./utils/AuditCommitHash.sol"; @@ -485,4 +486,76 @@ contract StakingEnforcementTest is Test { tc.releaseGIRegistrationSlots(1); vm.stopPrank(); } + + // ── one address can't hold both roles in a GI (issue No. 180) ──────────── + + /// @dev An aggregator for GI 1 can't also register as an auditor for GI 1. + function test_registerDINAuditor_aggregatorSameGI_reverts() public { + _stake(agg1, MIN_STAKE_AMOUNT * 2); + _advanceToAggregatorRegistration(); + vm.prank(agg1); + tc.registerDINaggregator(1); + + vm.startPrank(modelOwner); + tc.closeDINaggregatorsRegistration(1); + tc.startDINauditorsRegistration(1); + vm.stopPrank(); + + vm.prank(agg1); + vm.expectRevert(TA_DualRoleNotAllowed.selector); + ta.registerDINAuditor(1); + assertFalse(ta.isRegisteredAuditor(1, agg1)); + assertEq(stake.activeRegistrationCount(agg1), 1, "only the aggregator slot is held"); + } + + /// @dev The guard is per GI: GI 1's aggregator may audit in GI 2. + function test_registerDINAuditor_aggregatorOfEarlierGI_registers() public { + _runFullGIToEnded(); // agg1 is a GI 1 aggregator + + address funder = makeAddr("funder_gi2"); + uint256 poolEth = 0.001 ether; + vm.deal(funder, poolEth + 1 ether); + vm.prank(funder); + coordinator.depositAndMint{value: poolEth}(); + uint256 pool = token.balanceOf(funder); + vm.startPrank(funder); + token.approve(address(ta), pool); + ta.depositRewards(2, pool); + vm.stopPrank(); + + vm.startPrank(modelOwner); + tc.startGI(2); + tc.startDINaggregatorsRegistration(2); + tc.closeDINaggregatorsRegistration(2); + tc.startDINauditorsRegistration(2); + vm.stopPrank(); + + assertTrue(tc.isDINAggregator(1, agg1)); + assertFalse(tc.isDINAggregator(2, agg1)); + vm.prank(agg1); + ta.registerDINAuditor(2); // must not revert + assertTrue(ta.isRegisteredAuditor(2, agg1)); + } + + /// @dev Documents the guard's limit: it is per address, so the same + /// operator with a second address and its own stake can still take + /// the auditor role (Sybil). The defence is that it costs a second + /// stake and is visible on-chain. + function test_registerDINAuditor_secondAddressWithOwnStake_registers() public { + _stake(agg1, MIN_STAKE_AMOUNT); + _stake(aud1, MIN_STAKE_AMOUNT); // the operator's second address + _advanceToAggregatorRegistration(); + vm.prank(agg1); + tc.registerDINaggregator(1); + + vm.startPrank(modelOwner); + tc.closeDINaggregatorsRegistration(1); + tc.startDINauditorsRegistration(1); + vm.stopPrank(); + + vm.prank(aud1); + ta.registerDINAuditor(1); // must not revert + assertTrue(ta.isRegisteredAuditor(1, aud1)); + assertTrue(tc.isDINAggregator(1, agg1)); + } } From 1c65b386763538ea088ee3cb5be40de0085a5d56 Mon Sep 17 00:00:00 2001 From: Abidoyesimze Date: Tue, 6 Oct 2026 19:00:27 +0100 Subject: [PATCH 05/48] fix(dincli): reject manifest paths that escape their base directory (#227) Every dincli command that fetched a model-owner-supplied service file or artifact built its destination path as / Path(manifest[...] ["path"]), with no containment check. Path.__truediv__ discards the base entirely when the manifest's "path" is absolute, and ".." segments walk back out of a relative one -- ensure_file_exists then created whatever parent directories were needed and wrote the IPFS-fetched content there unconditionally. The manifest is fetched from IPFS by a CID the model owner controls, so a malicious model owner could have any client/auditor/aggregator who interacts with their model overwrite an arbitrary file (e.g. ~/.ssh/authorized_keys) on their own machine -- confirmed with a concrete exploit walkthrough in the issue. The one existing path-side defense (_normalize_path in dincli/services/ipfs.py) only logs a warning for four hardcoded system directories and never blocks the write. Fix: a new resolve_manifest_path(base_dir, manifest_path) helper (dincli/cli/utils.py) rejects an absolute manifest_path outright, and rejects anything whose resolve() isn't contained in base_dir.resolve() (Path.is_relative_to) -- raising ManifestPathEscapesBaseError rather than silently re-anchoring. resolve() follows symlinks, so a symlink placed inside base_dir pointing outside it is caught the same way; that's deliberate. Every manifest-driven call site now goes through it instead of a raw `/` join: dincli/cli/client.py, dincli/cli/aggregator.py (x2, T1 and T2), dincli/cli/auditor.py (x3), dincli/cli/modelownerd/{aggregation, auditor_batches,gi,model (x2)}.py, and the task-contract artifact path in dincli/cli/context.py (falls back to the bundled ABI on an escape, same as every other malformed-manifest case there, rather than raising). ensure_file_exists itself also gained a containment check as a backstop -- a new base_dir kwarg (defaulting to CACHE_DIR when the caller doesn't have a tighter, model-specific base on hand), so a future call site that forgets resolve_manifest_path still can't write outside it. Tests (tests/test_manifest_path_traversal.py): the helper itself (relative-ok, absolute-rejected, ".."-rejected, symlink-escape- rejected), ensure_file_exists's backstop (explicit base_dir and the CACHE_DIR default), one real call site per role plus the artifact path -- each invokes the actual CLI command function with a malicious manifest and asserts ManifestPathEscapesBaseError fires before ctx.obj.ensure_file_exists (the actual download/write) is ever reached. Break-then-fix shown (reverting one call site makes its test fail, restoring it passes again). Two of the per-role tests (aggregator, auditor) can't run in this sandbox's Python 3.10 -- dincli.cli.aggregator/auditor both import dincli.cli.dintoken, which uses a 3.12+ nested-quote f-string that fails to parse here (pre-existing, unrelated; same reason tests/test_dintoken.py is already excluded everywhere else). Written and logic-reviewed against the same pattern the other two (verified) per-role tests use. pytest -m "not integration" otherwise green: same 16 pre-existing environment-only failures as before (py-cid version mismatch, test_integration_marker) plus these 2, no new regressions (199 passed, up from the prior baseline by the 12 new passing tests). Documentation/public/manifest.md's `path` field description now says paths must be relative and stay inside the model's directory. Co-Authored-By: Claude Sonnet 5 --- Documentation/public/manifest.md | 2 +- dincli/cli/aggregator.py | 21 +- dincli/cli/auditor.py | 15 +- dincli/cli/client.py | 10 +- dincli/cli/context.py | 33 ++- dincli/cli/modelownerd/aggregation.py | 11 +- dincli/cli/modelownerd/auditor_batches.py | 6 +- dincli/cli/modelownerd/gi.py | 11 +- dincli/cli/modelownerd/model.py | 21 +- dincli/cli/utils.py | 31 +++ tests/test_manifest_path_traversal.py | 325 ++++++++++++++++++++++ 11 files changed, 437 insertions(+), 49 deletions(-) create mode 100644 tests/test_manifest_path_traversal.py diff --git a/Documentation/public/manifest.md b/Documentation/public/manifest.md index ffa5f675..83f62f13 100644 --- a/Documentation/public/manifest.md +++ b/Documentation/public/manifest.md @@ -60,7 +60,7 @@ Each service function is registered in the manifest as a JSON object with the fo | Key | Description | |---|---| | `type` | Service type (currently `"custom"`) | -| `path` | Local relative path to the service file | +| `path` | Local relative path to the service file. Must be relative and resolve inside the model's own cache/task directory — an absolute path or a `..` segment that would escape it is rejected (issue #227), not silently re-anchored. | | `ipfs` | IPFS CID of the uploaded and pinned service file | | `stakeholders` | List of roles that use this service (e.g., `"modelowner"`, `"clients"`, `"auditors"`, `"aggregators"`) | diff --git a/dincli/cli/aggregator.py b/dincli/cli/aggregator.py index e41851f3..7d5b72ea 100644 --- a/dincli/cli/aggregator.py +++ b/dincli/cli/aggregator.py @@ -11,7 +11,8 @@ read_dintoken_stake, stake_dintokens) from dincli.cli.utils import (CACHE_DIR, MIN_STAKE, build_and_send_tx, get_manifest_key, lock_batch_seed_if_pending, - require_custom_manifest_service) + require_custom_manifest_service, + resolve_manifest_path) from dincli.cli.worker import ( ensure_worker_image, ensure_worker_packages_installed, @@ -333,12 +334,12 @@ def aggregate_t1( model_base_dir = ctx.obj.get_model_base_dir(model_id) manifest = get_manifest_key(effective_network, "get_aggregated_cid_t1", model_id) - aggregator_service_path = model_base_dir / Path(manifest["path"]) - model_service_path = model_base_dir / Path(get_manifest_key(effective_network, "ModelArchitecture", model_id)["path"]) + aggregator_service_path = resolve_manifest_path(model_base_dir, manifest["path"], what="get_aggregated_cid_t1 path") + model_service_path = resolve_manifest_path(model_base_dir, get_manifest_key(effective_network, "ModelArchitecture", model_id)["path"], what="ModelArchitecture path") require_custom_manifest_service(manifest, "get_aggregated_cid_t1") - ctx.obj.ensure_file_exists(aggregator_service_path, manifest["ipfs"], "aggregator service") - ctx.obj.ensure_file_exists(model_service_path, get_manifest_key(effective_network,"ModelArchitecture", model_id)["ipfs"], "model service") + ctx.obj.ensure_file_exists(aggregator_service_path, manifest["ipfs"], "aggregator service", base_dir=model_base_dir) + ctx.obj.ensure_file_exists(model_service_path, get_manifest_key(effective_network,"ModelArchitecture", model_id)["ipfs"], "model service", base_dir=model_base_dir) aggregator_requirements_cid = get_manifest_key(effective_network, "requirements.txt", model_id).get("aggregators") requirements_path = get_worker_requirements_path(model_base_dir, "aggregators") @@ -554,12 +555,12 @@ def aggregate_t2( model_base_dir = ctx.obj.get_model_base_dir(model_id) manifest = get_manifest_key(effective_network, "get_aggregated_cid_t2", model_id) - aggregator_service_path = model_base_dir / Path(manifest["path"]) - model_service_path = model_base_dir / Path(get_manifest_key(effective_network, "ModelArchitecture", model_id)["path"]) - + aggregator_service_path = resolve_manifest_path(model_base_dir, manifest["path"], what="get_aggregated_cid_t2 path") + model_service_path = resolve_manifest_path(model_base_dir, get_manifest_key(effective_network, "ModelArchitecture", model_id)["path"], what="ModelArchitecture path") + require_custom_manifest_service(manifest, "get_aggregated_cid_t2") - ctx.obj.ensure_file_exists(aggregator_service_path, manifest["ipfs"], "aggregator service") - ctx.obj.ensure_file_exists(model_service_path, get_manifest_key(effective_network,"ModelArchitecture", model_id)["ipfs"], "model service") + ctx.obj.ensure_file_exists(aggregator_service_path, manifest["ipfs"], "aggregator service", base_dir=model_base_dir) + ctx.obj.ensure_file_exists(model_service_path, get_manifest_key(effective_network,"ModelArchitecture", model_id)["ipfs"], "model service", base_dir=model_base_dir) aggregator_requirements_cid = get_manifest_key(effective_network, "requirements.txt", model_id).get("aggregators") requirements_path = get_worker_requirements_path(model_base_dir, "aggregators") diff --git a/dincli/cli/auditor.py b/dincli/cli/auditor.py index 01697b83..b4f4231d 100644 --- a/dincli/cli/auditor.py +++ b/dincli/cli/auditor.py @@ -17,7 +17,8 @@ read_dintoken_stake, stake_dintokens) from dincli.cli.utils import (CACHE_DIR, CONFIG_DIR, MIN_STAKE, build_and_send_tx, get_manifest_key, lock_batch_seed_if_pending, - require_custom_manifest_service) + require_custom_manifest_service, + resolve_manifest_path) from dincli.cli.worker import ( ensure_worker_image, ensure_worker_packages_installed, @@ -447,19 +448,19 @@ def evaluate_lms( lm_cid = get_cid_from_bytes32(lms[1].hex()) manifest = get_manifest_key(effective_network, "Score_model_by_auditor", model_id) - auditor_service_path = model_base_dir / Path(manifest["path"]) - model_service_path = model_base_dir / Path(get_manifest_key(effective_network, "ModelArchitecture", model_id)["path"]) + auditor_service_path = resolve_manifest_path(model_base_dir, manifest["path"], what="Score_model_by_auditor path") + model_service_path = resolve_manifest_path(model_base_dir, get_manifest_key(effective_network, "ModelArchitecture", model_id)["path"], what="ModelArchitecture path") scoring_manifest = get_manifest_key(effective_network, "ScoringUtils", model_id) - scoring_service_path = model_base_dir / Path(scoring_manifest["path"]) + scoring_service_path = resolve_manifest_path(model_base_dir, scoring_manifest["path"], what="ScoringUtils path") require_custom_manifest_service(manifest, "Score_model_by_auditor") - ctx.obj.ensure_file_exists(auditor_service_path, manifest["ipfs"], "auditor service") - ctx.obj.ensure_file_exists(model_service_path, get_manifest_key(effective_network, "ModelArchitecture", model_id)["ipfs"], "model service") + ctx.obj.ensure_file_exists(auditor_service_path, manifest["ipfs"], "auditor service", base_dir=model_base_dir) + ctx.obj.ensure_file_exists(model_service_path, get_manifest_key(effective_network, "ModelArchitecture", model_id)["ipfs"], "model service", base_dir=model_base_dir) # auditor.py imports `scoring` as a sibling module via sys.path # rather than a package import, so it must be materialized at the # same local services/ path dincli derives from the manifest # before the worker container can import it. - ctx.obj.ensure_file_exists(scoring_service_path, scoring_manifest["ipfs"], "scoring utils") + ctx.obj.ensure_file_exists(scoring_service_path, scoring_manifest["ipfs"], "scoring utils", base_dir=model_base_dir) # Recover K and rawCID via decrypt chain: # Box(auditor_privkey, owner_pubkey).decrypt(encryptedKey) → K diff --git a/dincli/cli/client.py b/dincli/cli/client.py index 3bf95e18..46b11e82 100644 --- a/dincli/cli/client.py +++ b/dincli/cli/client.py @@ -4,7 +4,7 @@ import typer from web3 import Web3 -from dincli.cli.utils import CACHE_DIR, build_and_send_tx, require_custom_manifest_service, _confirm_or_exit +from dincli.cli.utils import CACHE_DIR, build_and_send_tx, require_custom_manifest_service, resolve_manifest_path, _confirm_or_exit from dincli.cli.worker import ( ensure_worker_image, ensure_worker_packages_installed, @@ -126,12 +126,12 @@ def train_lms( model_base_dir = ctx.obj.get_model_base_dir(model_id) manifest = runtime.require_manifest_key("train_client_model") model_manifest = runtime.require_manifest_key("ModelArchitecture") - client_service_path = model_base_dir / Path(manifest["path"]) - model_service_path = model_base_dir / Path(model_manifest["path"]) + client_service_path = resolve_manifest_path(model_base_dir, manifest["path"], what="train_client_model path") + model_service_path = resolve_manifest_path(model_base_dir, model_manifest["path"], what="ModelArchitecture path") require_custom_manifest_service(manifest, "train_client_model") - ctx.obj.ensure_file_exists(client_service_path, manifest["ipfs"], "client service") - ctx.obj.ensure_file_exists(model_service_path, model_manifest["ipfs"], "model architecture service") + ctx.obj.ensure_file_exists(client_service_path, manifest["ipfs"], "client service", base_dir=model_base_dir) + ctx.obj.ensure_file_exists(model_service_path, model_manifest["ipfs"], "model architecture service", base_dir=model_base_dir) client_requirements_cid = runtime.get_manifest_key("requirements.txt", {}).get("clients") requirements_path = get_worker_requirements_path(model_base_dir, "clients") diff --git a/dincli/cli/context.py b/dincli/cli/context.py index 7797f453..a0e1ffb3 100644 --- a/dincli/cli/context.py +++ b/dincli/cli/context.py @@ -16,9 +16,11 @@ from dincli.cli.contract_utils import get_contract_instance from dincli.cli.log import logger, logging from dincli.cli.utils import (CACHE_DIR, GIstatestrToIndex, GIstateToStr, + ManifestPathEscapesBaseError, get_config, get_manifest, get_manifest_key, get_w3, load_account, load_config, load_din_info, - resolve_network, get_active_account_name, + resolve_manifest_path, resolve_network, + get_active_account_name, validate_account_name, resolve_wallet_path) from dincli.services.ipfs import retrieve_from_ipfs from dincli.services.runtime import ServiceRuntimeContext, build_service_runtime_context @@ -313,7 +315,14 @@ def _resolve_task_contract_artifact_path( # Without it, dincli cannot know where to cache/read the artifact. return Path(str(default_artifact_path)) - artifact_path = model_base_path / artifact_rel_path + try: + artifact_path = resolve_manifest_path(model_base_path, artifact_rel_path, what="task_contracts artifact path") + except ManifestPathEscapesBaseError: + # issue #227: a manifest artifact path escaping its base + # directory is treated the same as any other malformed entry + # above -- fall back to the bundled ABI rather than crash or + # silently re-anchor. + return Path(str(default_artifact_path)) # Ensure the artifact exists locally before constructing the Web3 # contract. If "ipfs" is present, ensure_file_exists also refreshes the @@ -327,6 +336,7 @@ def _resolve_task_contract_artifact_path( artifact_path, artifact_entry.get("ipfs"), f"{contract_key} ABI", + base_dir=model_base_path, ) except FileNotFoundError: return Path(str(default_artifact_path)) @@ -490,7 +500,9 @@ def _write_local_cid_store(directory: Path, store: dict) -> None: def ensure_file_exists(self, file_path: Path, ipfs_cid: str | None, - description: str + description: str, + *, + base_dir: Path | None = None, ) -> None: """ Retrieve *file_path* from IPFS if it is missing or its stored CID @@ -501,7 +513,22 @@ def ensure_file_exists(self, files for a given model directory. If *ipfs_cid* is None, only a presence check is done (no CID comparison). + + *base_dir* is a backstop containment check (issue #227): *file_path* + must resolve inside it. Callers building *file_path* from a manifest + field should already have rejected an escaping path via + `resolve_manifest_path` before reaching here -- this is the second + check, not the first, so a call site that forgets the helper still + can't write outside *base_dir*. Defaults to `CACHE_DIR` (the + broadest boundary dincli itself ever writes application data under) + when the caller doesn't have a tighter, model-specific base on hand. """ + effective_base = (base_dir if base_dir is not None else CACHE_DIR).resolve() + if not file_path.resolve().is_relative_to(effective_base): + raise ManifestPathEscapesBaseError( + f"refusing to write {description} outside {effective_base!s}: {file_path!s}" + ) + directory = file_path.parent.parent filename = file_path.name diff --git a/dincli/cli/modelownerd/aggregation.py b/dincli/cli/modelownerd/aggregation.py index 73343297..50cade88 100644 --- a/dincli/cli/modelownerd/aggregation.py +++ b/dincli/cli/modelownerd/aggregation.py @@ -3,7 +3,8 @@ from rich.table import Table from dincli.cli.utils import (CACHE_DIR, build_and_send_tx, ensure_batch_seed_locked, - get_manifest_key, require_custom_manifest_service) + get_manifest_key, require_custom_manifest_service, + resolve_manifest_path) from dincli.services.cid_utils import get_cid_from_bytes32 aggregation_app = typer.Typer(help="Aggregation commands") @@ -396,12 +397,12 @@ def set_t2_score( manifest = get_manifest_key(effective_network, "getscoreforGM", model_id) model_base_path = Path(CACHE_DIR) / effective_network / f"model_{model_id}" - modelowner_service_path = model_base_path / Path(manifest["path"]) - model_service_path = model_base_path / Path(get_manifest_key(effective_network, "ModelArchitecture", model_id)["path"]) + modelowner_service_path = resolve_manifest_path(model_base_path, manifest["path"], what="getscoreforGM path") + model_service_path = resolve_manifest_path(model_base_path, get_manifest_key(effective_network, "ModelArchitecture", model_id)["path"], what="ModelArchitecture path") require_custom_manifest_service(manifest, "getscoreforGM") - ctx.obj.ensure_file_exists(modelowner_service_path, manifest["ipfs"], "modelowner service") - ctx.obj.ensure_file_exists(model_service_path, get_manifest_key(effective_network, "ModelArchitecture", model_id)["ipfs"], "model architecture service") + ctx.obj.ensure_file_exists(modelowner_service_path, manifest["ipfs"], "modelowner service", base_dir=model_base_path) + ctx.obj.ensure_file_exists(model_service_path, get_manifest_key(effective_network, "ModelArchitecture", model_id)["ipfs"], "model architecture service", base_dir=model_base_path) fn = ctx.obj.load_custom_fn( modelowner_service_path, diff --git a/dincli/cli/modelownerd/auditor_batches.py b/dincli/cli/modelownerd/auditor_batches.py index a772b083..5bef4c07 100644 --- a/dincli/cli/modelownerd/auditor_batches.py +++ b/dincli/cli/modelownerd/auditor_batches.py @@ -10,7 +10,7 @@ from eth_account.messages import encode_defunct from cryptography.hazmat.primitives.ciphers.aead import AESGCM -from dincli.cli.utils import CACHE_DIR, CONFIG_DIR, build_and_send_tx, ensure_batch_seed_locked, get_manifest_key, require_custom_manifest_service +from dincli.cli.utils import CACHE_DIR, CONFIG_DIR, build_and_send_tx, ensure_batch_seed_locked, get_manifest_key, require_custom_manifest_service, resolve_manifest_path from dincli.services.cid_utils import get_bytes32_from_cid, get_cid_from_bytes32 auditor_batches_app = typer.Typer(help="Auditor Batches commands") @@ -141,10 +141,10 @@ def create_testdataset( model_base_path = Path(CACHE_DIR) / effective_network / f"model_{model_id}" manifest = get_manifest_key(effective_network, "create_audit_testDataCIDs", model_id) - modelowner_service_path = model_base_path / Path(manifest["path"]) + modelowner_service_path = resolve_manifest_path(model_base_path, manifest["path"], what="create_audit_testDataCIDs path") require_custom_manifest_service(manifest, "create_audit_testDataCIDs") - ctx.obj.ensure_file_exists(modelowner_service_path, manifest["ipfs"], "model owner service") + ctx.obj.ensure_file_exists(modelowner_service_path, manifest["ipfs"], "model owner service", base_dir=model_base_path) fn = ctx.obj.load_custom_fn(modelowner_service_path, "create_audit_testDataCIDs") if test_data_path is None: diff --git a/dincli/cli/modelownerd/gi.py b/dincli/cli/modelownerd/gi.py index 5bc64657..78fc83ba 100644 --- a/dincli/cli/modelownerd/gi.py +++ b/dincli/cli/modelownerd/gi.py @@ -5,7 +5,8 @@ from dincli.cli.utils import (CACHE_DIR, GIstateToStr, build_and_send_tx, get_manifest, get_manifest_key, - require_custom_manifest_service, _confirm_or_exit) + require_custom_manifest_service, resolve_manifest_path, + _confirm_or_exit) from dincli.services.cid_utils import get_cid_from_bytes32 gi_app = typer.Typer(help="Global iteration commands") @@ -123,12 +124,12 @@ def start( require_custom_manifest_service(manifest, "getscoreforGM") # Retrieve required service files - service_path = model_base_path / manifest["path"] + service_path = resolve_manifest_path(model_base_path, manifest["path"], what="getscoreforGM path") model_arch_manifest = manifest_data["ModelArchitecture"] - model_arch_path = model_base_path / model_arch_manifest["path"] + model_arch_path = resolve_manifest_path(model_base_path, model_arch_manifest["path"], what="ModelArchitecture path") - ctx.obj.ensure_file_exists(service_path, manifest["ipfs"], "model owner service") - ctx.obj.ensure_file_exists(model_arch_path, model_arch_manifest["ipfs"], "model architecture service") + ctx.obj.ensure_file_exists(service_path, manifest["ipfs"], "model owner service", base_dir=model_base_path) + ctx.obj.ensure_file_exists(model_arch_path, model_arch_manifest["ipfs"], "model architecture service", base_dir=model_base_path) ctx.obj.ensure_file_exists(genesis_model_path, manifest_data["Genesis_Model_CID"], "genesis model") _confirm_or_exit(f"Pass Scoring is enabled. Have you placed the test dataset for GM evaluation at {test_data_path}?", "Pass Scoring is enabled. Test dataset not placed. ", console) diff --git a/dincli/cli/modelownerd/model.py b/dincli/cli/modelownerd/model.py index 8796caa4..9a655b3b 100644 --- a/dincli/cli/modelownerd/model.py +++ b/dincli/cli/modelownerd/model.py @@ -6,11 +6,12 @@ import json from web3 import Web3 -from dincli.cli.utils import (build_and_send_tx, get_env_key, +from dincli.cli.utils import (build_and_send_tx, get_env_key, get_manifest, get_manifest_key, require_custom_manifest_service, - resolve_task_coordinator_address, + resolve_manifest_path, + resolve_task_coordinator_address, set_env_key, _confirm_or_exit) from dincli.services.cid_utils import get_bytes32_from_cid @@ -100,12 +101,12 @@ def create_genesis_model( raise typer.Exit(1) manifest = get_manifest_key(effective_network, "getGenesisModelIpfs", None, task_coordinator_address) - service_path = task_dir / Path(manifest["path"]) - model_service_path = task_dir / Path(get_manifest_key(effective_network, "ModelArchitecture", None, task_coordinator_address)["path"]) + service_path = resolve_manifest_path(task_dir, manifest["path"], what="getGenesisModelIpfs path") + model_service_path = resolve_manifest_path(task_dir, get_manifest_key(effective_network, "ModelArchitecture", None, task_coordinator_address)["path"], what="ModelArchitecture path") require_custom_manifest_service(manifest, "getGenesisModelIpfs") - ctx.obj.ensure_file_exists(service_path, manifest["ipfs"], "model owner service") - ctx.obj.ensure_file_exists(model_service_path, get_manifest_key(effective_network, "ModelArchitecture", None, task_coordinator_address)["ipfs"], "model architecture service") + ctx.obj.ensure_file_exists(service_path, manifest["ipfs"], "model owner service", base_dir=task_dir) + ctx.obj.ensure_file_exists(model_service_path, get_manifest_key(effective_network, "ModelArchitecture", None, task_coordinator_address)["ipfs"], "model architecture service", base_dir=task_dir) _confirm_or_exit( f"Have you edited/modified the modelowner service at {service_path} according to your task requirements?", @@ -216,12 +217,12 @@ def submit_genesis_model( console.print("[yellow]Manifest does not require genesis model scoring with a test dataset. Skipping tier 2 score submission.[/yellow]") else: manifest = manifest_data["getscoreforGM"] - service_path = task_dir / Path(manifest["path"]) - model_service_path = task_dir / Path(get_manifest_key(effective_network, "ModelArchitecture", None, task_coordinator_address)["path"]) + service_path = resolve_manifest_path(task_dir, manifest["path"], what="getscoreforGM path") + model_service_path = resolve_manifest_path(task_dir, get_manifest_key(effective_network, "ModelArchitecture", None, task_coordinator_address)["path"], what="ModelArchitecture path") require_custom_manifest_service(manifest, "getscoreforGM") - ctx.obj.ensure_file_exists(service_path, manifest["ipfs"], "model owner service") - ctx.obj.ensure_file_exists(model_service_path, get_manifest_key(effective_network, "ModelArchitecture", None, task_coordinator_address)["ipfs"], "model architecture service") + ctx.obj.ensure_file_exists(service_path, manifest["ipfs"], "model owner service", base_dir=task_dir) + ctx.obj.ensure_file_exists(model_service_path, get_manifest_key(effective_network, "ModelArchitecture", None, task_coordinator_address)["ipfs"], "model architecture service", base_dir=task_dir) fn = ctx.obj.load_custom_fn(service_path, "getscoreforGM") diff --git a/dincli/cli/utils.py b/dincli/cli/utils.py index 5a816e60..a09d4df7 100644 --- a/dincli/cli/utils.py +++ b/dincli/cli/utils.py @@ -37,6 +37,37 @@ # access to the manifest/service/wallet cache that dincli itself manages. WORKER_CACHE_DIR = Path(user_cache_dir("dincli-worker")) + +class ManifestPathEscapesBaseError(ValueError): + """issue #227: a manifest-supplied path field would read/write outside + the model's own cache directory.""" + + +def resolve_manifest_path(base_dir: Path, manifest_path: str | Path, *, what: str = "manifest path") -> Path: + """Resolve a manifest-supplied relative path against `base_dir`, raising + rather than silently re-anchoring if it would escape it. + + A manifest is fetched from IPFS by a CID a model owner controls, so its + "path" fields are attacker-influenced in this threat model (issue #227): + an absolute path discards `base_dir` entirely when joined via `/`, and + `..` segments walk back out of it. `resolve()` also follows symlinks, so + a symlink placed inside `base_dir` that points outside is caught the + same way -- that's deliberate, not an oversight. + """ + manifest_path = Path(manifest_path) + if manifest_path.is_absolute(): + raise ManifestPathEscapesBaseError( + f"{what} must be relative, got an absolute path: {manifest_path!s}" + ) + resolved_base = base_dir.resolve() + resolved = (base_dir / manifest_path).resolve() + if not resolved.is_relative_to(resolved_base): + raise ManifestPathEscapesBaseError( + f"{what} escapes its base directory {resolved_base!s}: {manifest_path!s} -> {resolved!s}" + ) + return resolved + + CONFIG_FILE = CONFIG_DIR / "config.json" WALLET_FILE = CONFIG_DIR / "wallet.json" WALLETS_DIR = CONFIG_DIR / "wallets" diff --git a/tests/test_manifest_path_traversal.py b/tests/test_manifest_path_traversal.py new file mode 100644 index 00000000..e819f2fe --- /dev/null +++ b/tests/test_manifest_path_traversal.py @@ -0,0 +1,325 @@ +"""Tests for issue #227 (manifest path traversal). + +Every dincli command that fetches a model-owner-supplied service file or +artifact built its destination path by joining a trusted local base +directory with a raw `path` string taken directly from the model's +manifest JSON, with no containment check -- an absolute `path` discarded +the base directory entirely, and `..` segments walked out of it. The +manifest is fetched from IPFS by a CID the model owner controls, so its +`path` fields are attacker-influenced: any client/auditor/aggregator who +interacts with a malicious model owner's model could have an arbitrary +file on their own machine overwritten. + +`resolve_manifest_path` (dincli/cli/utils.py) is the fix: every call site +that used to do ` / Path(manifest[...]["path"])` now goes through +it, and it raises rather than silently re-anchoring. `ensure_file_exists` +(dincli/cli/context.py) also gained its own containment check as a +backstop, so a future call site that forgets the helper still can't +write outside its base directory (or CACHE_DIR, if no tighter base is +given). +""" +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +# dincli/cli/aggregator.py and dincli/cli/auditor.py import dincli.cli.dintoken, +# which uses a 3.12+ nested-quote f-string that fails to parse on Python +# 3.10 (a pre-existing, unrelated issue -- see tests/test_dintoken.py). Import +# those two modules lazily, inside the tests that need them, so a 3.10 +# collection environment can still run every other test in this file. +from dincli.cli import client +from dincli.cli.context import DinContext +from dincli.cli.modelownerd import auditor_batches +from dincli.cli.utils import ManifestPathEscapesBaseError, resolve_manifest_path + +ACCOUNT = "0x" + "11" * 20 + + +# ─── resolve_manifest_path (the helper itself) ────────────────────────────── + +def test_resolve_manifest_path_normal_relative_path_resolves_inside_base(tmp_path): + resolved = resolve_manifest_path(tmp_path, "services/client.py") + assert resolved == (tmp_path / "services" / "client.py").resolve() + assert resolved.is_relative_to(tmp_path.resolve()) + + +def test_resolve_manifest_path_rejects_absolute_path(tmp_path): + with pytest.raises(ManifestPathEscapesBaseError): + resolve_manifest_path(tmp_path, "/etc/passwd") + + +def test_resolve_manifest_path_rejects_absolute_path_even_inside_looking(tmp_path): + # An absolute path that happens to render as a subpath of tmp_path as a + # *string* must still be rejected on the is_absolute() check alone -- + # confirms the rejection isn't accidentally keyed off string prefixes. + fake_absolute = "/" + str(tmp_path).lstrip("/") + with pytest.raises(ManifestPathEscapesBaseError): + resolve_manifest_path(tmp_path, fake_absolute) + + +def test_resolve_manifest_path_rejects_dotdot_traversal(tmp_path): + base = tmp_path / "model_base" + base.mkdir() + with pytest.raises(ManifestPathEscapesBaseError): + resolve_manifest_path(base, "../../../../etc/passwd") + + +def test_resolve_manifest_path_rejects_symlink_escape(tmp_path): + base = tmp_path / "model_base" + base.mkdir() + outside = tmp_path / "outside" + outside.mkdir() + (outside / "secret.txt").write_text("secret") + + # A symlink *inside* the base pointing *outside* it must still be + # rejected -- resolve() follows it, and the check runs after resolve(). + escape_link = base / "escape" + escape_link.symlink_to(outside) + + with pytest.raises(ManifestPathEscapesBaseError): + resolve_manifest_path(base, "escape/secret.txt") + + +def test_resolve_manifest_path_allows_a_path_equal_to_base_itself(tmp_path): + # Edge case: manifest_path = "." should resolve to base_dir itself, not + # raise -- is_relative_to(x, x) is True. + resolved = resolve_manifest_path(tmp_path, ".") + assert resolved == tmp_path.resolve() + + +# ─── ensure_file_exists backstop ──────────────────────────────────────────── + +def _make_dincontext(): + ctx = DinContext.__new__(DinContext) # bypass __init__ (no config/log I/O needed) + ctx.console = MagicMock() + return ctx + + +def test_ensure_file_exists_backstop_rejects_outside_path_with_explicit_base(tmp_path): + ctx = _make_dincontext() + base = tmp_path / "model_base" + base.mkdir() + outside_path = tmp_path / "outside" / "evil.py" + + with patch("dincli.cli.context.retrieve_from_ipfs") as mock_retrieve: + with pytest.raises(ManifestPathEscapesBaseError): + ctx.ensure_file_exists(outside_path, "cidABC", "evil file", base_dir=base) + mock_retrieve.assert_not_called() + + +def test_ensure_file_exists_backstop_falls_back_to_cache_dir_default(tmp_path, monkeypatch): + # No base_dir passed -- falls back to CACHE_DIR. A path wildly outside + # CACHE_DIR must still be rejected. + monkeypatch.setattr("dincli.cli.context.CACHE_DIR", tmp_path / "dincli-cache") + ctx = _make_dincontext() + outside_path = tmp_path / "definitely-not-the-cache-dir" / "evil.py" + + with patch("dincli.cli.context.retrieve_from_ipfs") as mock_retrieve: + with pytest.raises(ManifestPathEscapesBaseError): + ctx.ensure_file_exists(outside_path, "cidABC", "evil file") + mock_retrieve.assert_not_called() + + +def test_ensure_file_exists_accepts_a_path_inside_the_given_base(tmp_path): + ctx = _make_dincontext() + base = tmp_path / "model_base" + base.mkdir() + inside_path = base / "services" / "client.py" + + def _fake_retrieve(cid, dest): + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text("ok") + + with patch("dincli.cli.context.retrieve_from_ipfs", side_effect=_fake_retrieve) as mock_retrieve: + ctx.ensure_file_exists(inside_path, "cidABC", "client service", base_dir=base) + mock_retrieve.assert_called_once() + assert inside_path.read_text() == "ok" + + +# ─── Per-role call sites: a malicious manifest raises before any I/O ──────── +# +# Each of these calls the real CLI command function with a heavily mocked +# `ctx`/contract surface (ctx.obj is a MagicMock -- only resolve_manifest_path +# itself is real code), a manifest whose "path" field escapes the model's +# base directory, and asserts the command raises before ctx.obj.ensure_file_exists +# (the actual download/write step) is ever reached. + +@patch("dincli.cli.client.get_cid_from_bytes32", return_value="cidGenesis") +def test_train_lms_rejects_manifest_path_escape(mock_get_cid): + ctx = MagicMock() + account = MagicMock() + account.address = ACCOUNT + ctx.obj.get_en_w3_account_console.return_value = ("local", MagicMock(), account, MagicMock()) + + coordinator = MagicMock() + coordinator.functions.genesisModelIpfsHash.return_value.call.return_value = b"\x00" * 32 + ctx.obj.get_deployed_din_task_coordinator_contract.return_value = coordinator + ctx.obj.get_deployed_din_task_auditor_contract.return_value = MagicMock() + ctx.obj.get_current_gi_and_state.return_value = (1, "LMSubmissionsStarted") # current_GI == 1: skips getTier2Batch + ctx.obj.validate_gi_ET_curr_GI.return_value = None + ctx.obj.get_model_base_dir.return_value = Path("/tmp/dincli-test-client-model") + + runtime = MagicMock() + + def require_manifest_key(key): + if key == "train_client_model": + return {"path": "../../../../etc/passwd", "ipfs": "cidABC"} + if key == "ModelArchitecture": + return {"path": "model.py", "ipfs": "cidDEF"} + raise KeyError(key) + + runtime.require_manifest_key.side_effect = require_manifest_key + ctx.obj.build_service_runtime.return_value = runtime + + with pytest.raises(ManifestPathEscapesBaseError): + client.train_lms(ctx, model_id=1, gi=None, packages_dir=None, no_cache=False) + + ctx.obj.ensure_file_exists.assert_not_called() + + +@patch("dincli.cli.aggregator.get_manifest_key") +@patch("dincli.cli.aggregator.get_cid_from_bytes32", return_value="cidGenesis") +def test_aggregate_t1_rejects_manifest_path_escape(mock_get_cid, mock_get_manifest_key): + from dincli.cli import aggregator + + ctx = MagicMock() + account = MagicMock() + account.address = ACCOUNT + ctx.obj.get_en_w3_account_console.return_value = ("local", MagicMock(), account, MagicMock()) + + coordinator = MagicMock() + coordinator.functions.tier1BatchCount.return_value.call.return_value = 1 + coordinator.functions.genesisModelIpfsHash.return_value.call.return_value = b"\x00" * 32 + # (bid, aggregators_in_batch, model_indexes, finalized, cid) + coordinator.functions.getTier1Batch.return_value.call.return_value = (0, [ACCOUNT], [], False, b"\x00" * 32) + ctx.obj.get_deployed_din_task_coordinator_contract.return_value = coordinator + ctx.obj.get_deployed_din_task_auditor_contract.return_value = MagicMock() + ctx.obj.get_current_gi_and_state.return_value = (1, "T1AggregationStarted") + ctx.obj.validate_gi_ET_curr_GI.return_value = 1 + ctx.obj.validate_GIstate_ET_given_GIstate.return_value = None + ctx.obj.get_model_base_dir.return_value = Path("/tmp/dincli-test-agg-model") + + def manifest_side_effect(network, key, model_id): + if key == "get_aggregated_cid_t1": + return {"path": "/etc/passwd", "ipfs": "cidABC"} + if key == "ModelArchitecture": + return {"path": "model.py", "ipfs": "cidDEF"} + raise KeyError(key) + + mock_get_manifest_key.side_effect = manifest_side_effect + + with pytest.raises(ManifestPathEscapesBaseError): + aggregator.aggregate_t1(ctx, model_id=1, gi=None, submit=False, batch_id=None, packages_dir=None, no_cache=False) + + ctx.obj.ensure_file_exists.assert_not_called() + + +@patch("dincli.cli.auditor.get_manifest_key") +@patch("dincli.cli.auditor.get_cid_from_bytes32", return_value="cidLM") +def test_evaluate_lms_rejects_manifest_path_escape(mock_get_cid, mock_get_manifest_key): + from dincli.cli import auditor + + ctx = MagicMock() + account = MagicMock() + account.address = ACCOUNT + ctx.obj.get_en_w3_account_console.return_value = ("local", MagicMock(), account, MagicMock()) + + coordinator = MagicMock() + coordinator.functions.genesisModelIpfsHash.return_value.call.return_value = b"\x00" * 32 + task_auditor = MagicMock() + task_auditor.functions.AuditorsBatchCount.return_value.call.return_value = 1 + # (batchId, auditors_in_batch, model_indexes, encrypted_cid_blob) + task_auditor.functions.getAuditorsBatch.return_value.call.return_value = (0, [ACCOUNT], [0], b"\x00" * 16) + task_auditor.functions.lmSubmissions.return_value.call.return_value = (ACCOUNT, b"\x00" * 32, 0, True, False, False, 0) + ctx.obj.get_deployed_din_task_coordinator_contract.return_value = coordinator + ctx.obj.get_deployed_din_task_auditor_contract.return_value = task_auditor + ctx.obj.get_current_gi_and_state.return_value = (1, "LMSevaluationStarted") + ctx.obj.validate_gi_ET_curr_GI.return_value = 1 + ctx.obj.validate_GIstate_ET_given_GIstate.return_value = None + ctx.obj.get_model_base_dir.return_value = Path("/tmp/dincli-test-auditor-model") + + def manifest_side_effect(network, key, model_id): + if key == "requirements.txt": + return {} + if key == "Score_model_by_auditor": + return {"path": "../../../../etc/passwd", "ipfs": "cidABC"} + if key == "ModelArchitecture": + return {"path": "model.py", "ipfs": "cidDEF"} + raise KeyError(key) + + mock_get_manifest_key.side_effect = manifest_side_effect + + with pytest.raises(ManifestPathEscapesBaseError): + auditor.evaluate_lms(ctx, model_id=1, lmi=None, batch=None, submit=False, gi=None, packages_dir=None, no_cache=False) + + # evaluate_lms legitimately calls ensure_file_exists once up front, to + # refresh the (non-manifest-path-driven, hardcoded-subpath) genesis + # model file -- that call is unrelated to this fix and must still + # happen. What must NOT happen is a second call for the malicious + # auditor/model/scoring service path, which would only be reached after + # the resolve_manifest_path call this test is actually checking. + ctx.obj.ensure_file_exists.assert_called_once() + genesis_call_path = ctx.obj.ensure_file_exists.call_args[0][0] + assert genesis_call_path == Path("/tmp/dincli-test-auditor-model") / "models" / "genesis_model.pth" + + +@patch("dincli.cli.modelownerd.auditor_batches.get_manifest_key") +def test_create_testdataset_rejects_manifest_path_escape(mock_get_manifest_key): + ctx = MagicMock() + account = MagicMock() + account.address = ACCOUNT + ctx.obj.get_en_w3_account_console.return_value = ("local", MagicMock(), account, MagicMock()) + + coordinator = MagicMock() + task_auditor = MagicMock() + task_auditor.functions.AuditorsBatchCount.return_value.call.return_value = 1 + ctx.obj.get_deployed_din_task_coordinator_contract.return_value = coordinator + ctx.obj.get_deployed_din_task_auditor_contract.return_value = task_auditor + ctx.obj.get_current_gi_and_state.return_value = (1, "AuditorsBatchesCreated") + ctx.obj.validate_gi_ET_curr_GI.return_value = 1 + ctx.obj.validate_GIstate_ET_given_GIstate.return_value = None + + mock_get_manifest_key.return_value = {"path": "/etc/passwd", "ipfs": "cidABC"} + + with pytest.raises(ManifestPathEscapesBaseError): + auditor_batches.create_testdataset(ctx, model_id=1, gi=None, submit=False, test_data_path=None) + + ctx.obj.ensure_file_exists.assert_not_called() + + +# ─── Task-contract artifact path (context.py) ─────────────────────────────── + +def test_resolve_task_contract_artifact_path_falls_back_on_escape(monkeypatch, tmp_path): + ctx = _make_dincontext() + ctx._resolved_network = "local" + + model_id = 1 + model_base_path = tmp_path / "local" / f"model_{model_id}" + model_base_path.mkdir(parents=True) + + malicious_manifest = { + "task_contracts": { + "DINTaskCoordinator_Contract": { + "artifact": {"path": "../../../../etc/passwd", "ipfs": "cidABC"}, + }, + }, + } + + monkeypatch.setattr("dincli.cli.context.get_manifest", lambda network, model_id=None: malicious_manifest) + monkeypatch.setattr("dincli.cli.context.CACHE_DIR", tmp_path) + + with patch.object(ctx, "ensure_file_exists") as mock_ensure, \ + patch("dincli.cli.context.retrieve_from_ipfs") as mock_retrieve: + result = ctx._resolve_task_contract_artifact_path( + "DINTaskCoordinator_Contract", + "DINTaskCoordinator.json", + model_id, + None, + ) + # Falls back to the bundled default ABI rather than raising or + # writing anywhere -- same graceful-degradation shape this function + # already uses for every other malformed-manifest case. + assert result.name == "DINTaskCoordinator.json" + mock_ensure.assert_not_called() + mock_retrieve.assert_not_called() From 5022604566fc0647f648061b4e95f9a3bb39fa09 Mon Sep 17 00:00:00 2001 From: umermjd11 Date: Thu, 8 Oct 2026 02:11:49 +0500 Subject: [PATCH 06/48] fix(task-auditor): restore _burnAndForward NatSpec, refresh refs (PR 235 review) - DINTaskAuditor: move _batchAt above _burnAndForward's @dev block, so the burn/forward doc is attached to _burnAndForward again and _batchAt carries only its own (review No. 1). No bytecode change. - adversarial-threat-model Row 6: registerDINAuditor is at DINTaskAuditor.sol:674, not :668 (review No. 2). - DinValidatorStake.getModelStakeMin NatSpec names the onlyOwner (DIN-Representative) setter instead of "the model owner"; caveat No. 4 in DinValidatorStake.md now lists only the unread max bound as open (review No. 3). Co-Authored-By: Claude Opus 5.5 --- Developer/design/adversarial-threat-model.md | 2 +- Documentation/technical/contracts/DinValidatorStake.md | 2 +- foundry/src/DINTaskAuditor.sol | 6 +++--- foundry/src/DinValidatorStake.sol | 3 ++- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/Developer/design/adversarial-threat-model.md b/Developer/design/adversarial-threat-model.md index 81de333c..1b441003 100644 --- a/Developer/design/adversarial-threat-model.md +++ b/Developer/design/adversarial-threat-model.md @@ -80,7 +80,7 @@ entry in `foundry/test/P3Adversarial.t.sol`. |---|---| | **Attacker** | One address registered as both auditor and aggregator in the same GI; model owner tries to steer the batch-assignment draw (formerly by choosing which block to submit batch-creation calls in) | | **Capability** | One address (auditor + aggregator), staking MIN_STAKE. Before issue #156 H-2 (PR #191) the owner chose which block `autoCreateTier1AndTier2` and `createAuditorsBatches` were submitted in, picking a `blockhash(block.number - 1)` that routed the poisoned model into the colluding T1 batch. Both now read a locked, future-block-anchored `aggSeed`/`auditSeed` (see Row 2), so that lever is closed; what remains is the BL-26 re-roll (decline to lock, wait out the ~256-block window) and TA-4 | -| **Protocol response today** | One address can no longer hold both roles in a GI: `registerDINAuditor` (`DINTaskAuditor.sol:668`) reverts with `TA_DualRoleNotAllowed` when the caller is already an aggregator for that GI, read from the coordinator's `isDINAggregator` (#180). The check sits on the auditor side because aggregator registration (`registerDINaggregator`, `DINTaskCoordinator.sol:401`; states 6–7) always comes before auditor registration (8–9). It is per address: a second address with its own stake still gets both roles (Sybil), so the remaining defence is that the attack costs a second stake and is visible on-chain. The concurrent-registration cap (off by default, `maxConcurrentRegistrationsPerStakeUnit == 0`) can raise that cost further when it is non-zero. | +| **Protocol response today** | One address can no longer hold both roles in a GI: `registerDINAuditor` (`DINTaskAuditor.sol:674`) reverts with `TA_DualRoleNotAllowed` when the caller is already an aggregator for that GI, read from the coordinator's `isDINAggregator` (#180). The check sits on the auditor side because aggregator registration (`registerDINaggregator`, `DINTaskCoordinator.sol:401`; states 6–7) always comes before auditor registration (8–9). It is per address: a second address with its own stake still gets both roles (Sybil), so the remaining defence is that the attack costs a second stake and is visible on-chain. The concurrent-registration cap (off by default, `maxConcurrentRegistrationsPerStakeUnit == 0`) can raise that cost further when it is non-zero. | | **Expected test outcome** | **DEFENDED** for the single-address case ([#180](https://github.com/InfiniteZeroFoundation/DevNet/issues/180), `StakingEnforcement.t.sol` `test_registerDINAuditor_aggregatorSameGI_reverts`); the Sybil variant is documented by `test_registerDINAuditor_secondAddressWithOwnStake_registers`. The [#156](https://github.com/InfiniteZeroFoundation/DevNet/issues/156) block-selection half is fixed, residuals in BL-26. The "colluding aggregators skip outlier clipping" step is off-chain (model-dependent, not testable in Forge). PR #218's `test_knownGap_dualRoleRegistration` flips to `test_defended_…` when it rebases on this change. | | **Trust assumption** | Operators don't split one stake-holder across two funded addresses to take both roles (Sybil); the cost is a second stake. Batch-shuffle residuals as in BL-26. | diff --git a/Documentation/technical/contracts/DinValidatorStake.md b/Documentation/technical/contracts/DinValidatorStake.md index 517a0e77..71028deb 100644 --- a/Documentation/technical/contracts/DinValidatorStake.md +++ b/Documentation/technical/contracts/DinValidatorStake.md @@ -309,7 +309,7 @@ Until step 8, slasher management through the coordinator reverts `ValidatorStake - **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 (partly fixed, issue No. 180):** the `setModelStakeBounds` / `setMaxConcurrentRegistrationsPerStakeUnit` NatSpec now says where the task contracts enforce both (§8), and `activeRegistrationCount` names `releaseGIRegistrationSlots` as the decrement. Still open: `getModelStakeMin` says "set by the model owner", but the setter is `onlyOwner` (DIN-Representative). `modelMinStakeBounds[].max` is never read. +- **No. 4 — Stale NatSpec (partly fixed, issue No. 180):** the `setModelStakeBounds` / `setMaxConcurrentRegistrationsPerStakeUnit` NatSpec now says where the task contracts enforce both (§8), `activeRegistrationCount` names `releaseGIRegistrationSlots` as the decrement, and `getModelStakeMin` names the `onlyOwner` (DIN-Representative) setter instead of "the model owner". Still open: `modelMinStakeBounds[].max` is never read. - **No. 5 — Recidivism is per model:** the S5 ring is keyed by calling contract, so a validator faulting across many models never escalates unless it hits the threshold within one model. - **No. 6 — Blacklisted funds are frozen:** blacklisted validators cannot unstake or claim; there is no recovery path other than unblacklisting. - **No. 7 — Custody meets upgradeability:** see §13. diff --git a/foundry/src/DINTaskAuditor.sol b/foundry/src/DINTaskAuditor.sol index ce6c7c67..342222a0 100644 --- a/foundry/src/DINTaskAuditor.sol +++ b/foundry/src/DINTaskAuditor.sol @@ -487,9 +487,6 @@ contract DINTaskAuditor is Ownable, ReentrancyGuardTransient { emit RewardDeposited(gi, msg.sender, amount); } - /// @dev Burns 50% of `amount` and forwards 50% to the platform slash-treasury - /// (`dinvalidatorStakeContract.slashTreasury()`). Burns both halves when - /// the slash-treasury is unset. Increments `treasuryAccrued` for observability. /// @dev Bounds- and id-checked batch lookup shared by the test-data paths. function _batchAt(uint256 gi, uint256 batchId) internal view returns (AuditBatch storage batch) { if (batchId >= auditBatches[gi].length) revert TA_BatchDoesNotExist(); @@ -497,6 +494,9 @@ contract DINTaskAuditor is Ownable, ReentrancyGuardTransient { if (batch.batchId != batchId) revert TA_BatchIDMismatch(); } + /// @dev Burns 50% of `amount` and forwards 50% to the platform slash-treasury + /// (`dinvalidatorStakeContract.slashTreasury()`). Burns both halves when + /// the slash-treasury is unset. Increments `treasuryAccrued` for observability. function _burnAndForward(uint256 amount) internal { if (amount == 0) return; uint256 burnAmt = amount / 2; diff --git a/foundry/src/DinValidatorStake.sol b/foundry/src/DinValidatorStake.sol index 7435b575..12ce88bf 100644 --- a/foundry/src/DinValidatorStake.sol +++ b/foundry/src/DinValidatorStake.sol @@ -474,7 +474,8 @@ contract DinValidatorStake is emit MaxConcurrentRegistrationsPerStakeUnitUpdated(value); } - /// @notice Returns the per-model minimum stake floor set by the model owner. + /// @notice Returns the per-model minimum stake floor set by the owner + /// (DIN-Representative) via setModelStakeBounds. /// @param modelId Model registry ID to query. function getModelStakeMin(uint256 modelId) external view returns (uint256) { return modelMinStakeBounds[modelId].min; From 920c93ea2969697f9b21e0c9bba81978986e5213 Mon Sep 17 00:00:00 2001 From: umeradl Date: Thu, 8 Oct 2026 14:28:50 +0500 Subject: [PATCH 07/48] fix(dincli): require role-scoped base_dir in ensure_file_exists/load_custom_fn (PR 237 review) PR No. 237's backstop fell back to CACHE_DIR when no base_dir was given. load_custom_fn never passed one, so the model owner's local task flow (services under cwd/tasks//, outside the cache) raised ManifestPathEscapesBaseError in create-genesis-model, submit-genesis-model and distribute-mnist without --model-id. The fallback was also too wide for participants, since it allowed writes into any model's cache dir. base_dir is now required (keyword-only) on both ensure_file_exists and load_custom_fn, and is always the root of the workflow the file belongs to: - get_model_base_dir(model_id) = CACHE_DIR//model_ for clients, auditors, aggregators, and the model owner once the model is registered - the new get_task_dir(coordinator) = cwd/tasks// for the model owner while preparing the task Every caller passes one of the two. The inline cwd/tasks and CACHE_DIR joins in modelownerd/{model,aggregation,auditor_batches} and system.py now use these helpers. Tests: - a missing base_dir is a TypeError - a participant can't write into another model's dir - regression: the real load_custom_fn on a cwd task dir outside CACHE_DIR dincli-testing-guide's sibling-module example now uses resolve_manifest_path and passes base_dir. Co-Authored-By: umeradl Co-Authored-By: Claude Opus 5.5 --- .../technical/testing/dincli-testing-guide.md | 10 ++- dincli/cli/aggregator.py | 12 ++-- dincli/cli/auditor.py | 4 +- dincli/cli/client.py | 4 +- dincli/cli/context.py | 42 +++++++++---- dincli/cli/modelownerd/aggregation.py | 9 +-- dincli/cli/modelownerd/auditor_batches.py | 6 +- dincli/cli/modelownerd/gi.py | 4 +- dincli/cli/modelownerd/model.py | 13 ++-- dincli/cli/system.py | 10 +-- tests/test_manifest_path_traversal.py | 63 ++++++++++++++++--- 11 files changed, 123 insertions(+), 54 deletions(-) diff --git a/Documentation/technical/testing/dincli-testing-guide.md b/Documentation/technical/testing/dincli-testing-guide.md index f717f2e4..7c44c03d 100644 --- a/Documentation/technical/testing/dincli-testing-guide.md +++ b/Documentation/technical/testing/dincli-testing-guide.md @@ -334,13 +334,17 @@ used for `ModelArchitecture`. This is handled for `scoring.py` via the ```python scoring_manifest = get_manifest_key(effective_network, "ScoringUtils", model_id) -scoring_service_path = model_base_dir / Path(scoring_manifest["path"]) -ctx.obj.ensure_file_exists(scoring_service_path, scoring_manifest["ipfs"], "scoring utils") +scoring_service_path = resolve_manifest_path(model_base_dir, scoring_manifest["path"], what="ScoringUtils path") +ctx.obj.ensure_file_exists(scoring_service_path, scoring_manifest["ipfs"], "scoring utils", base_dir=model_base_dir) ``` When adding a new reference service with sibling-module imports, follow this pattern: add a manifest entry with its own IPFS CID, and fetch it explicitly -before the worker job runs. +before the worker job runs. Always build the path with +`resolve_manifest_path` (never `base / Path(manifest["path"])`) and pass the +workflow root as `base_dir`. That root is `get_model_base_dir(model_id)` for +participants, or `get_task_dir(coordinator)` for the model owner before +registration (issue #227). --- diff --git a/dincli/cli/aggregator.py b/dincli/cli/aggregator.py index 7d5b72ea..269bbe4e 100644 --- a/dincli/cli/aggregator.py +++ b/dincli/cli/aggregator.py @@ -345,7 +345,7 @@ def aggregate_t1( requirements_path = get_worker_requirements_path(model_base_dir, "aggregators") packages_dir = Path(packages_dir) if packages_dir else None if aggregator_requirements_cid: - ctx.obj.ensure_file_exists(requirements_path, aggregator_requirements_cid, "aggregator requirements") + ctx.obj.ensure_file_exists(requirements_path, aggregator_requirements_cid, "aggregator requirements", base_dir=model_base_dir) try: ensure_worker_image(console) if not no_cache and packages_dir is None: @@ -363,8 +363,8 @@ def aggregate_t1( # access happens inside the container. aggregator_models_path = model_base_dir / "aggregator" / account.address / str(curr_GI) / "T1" / str(bid) / "models" for model_cid in model_cids: - ctx.obj.ensure_file_exists(aggregator_models_path / f"{model_cid}.pth", model_cid, "T1 local model submission") - ctx.obj.ensure_file_exists(model_base_dir / "models" / "genesis_model.pth", genesis_model_ipfs_hash, "genesis model") + ctx.obj.ensure_file_exists(aggregator_models_path / f"{model_cid}.pth", model_cid, "T1 local model submission", base_dir=model_base_dir) + ctx.obj.ensure_file_exists(model_base_dir / "models" / "genesis_model.pth", genesis_model_ipfs_hash, "genesis model", base_dir=model_base_dir) jobs_dir = model_base_dir / "jobs" / "aggregators" / account.address job_path, output_dir = write_worker_job( @@ -566,7 +566,7 @@ def aggregate_t2( requirements_path = get_worker_requirements_path(model_base_dir, "aggregators") packages_dir = Path(packages_dir) if packages_dir else None if aggregator_requirements_cid: - ctx.obj.ensure_file_exists(requirements_path, aggregator_requirements_cid, "aggregator requirements") + ctx.obj.ensure_file_exists(requirements_path, aggregator_requirements_cid, "aggregator requirements", base_dir=model_base_dir) try: ensure_worker_image(console) if not no_cache and packages_dir is None: @@ -581,8 +581,8 @@ def aggregate_t2( aggregator_models_path = model_base_dir / "aggregator" / account.address / str(curr_GI) / "T2" / str(bid) / "models" for model_cid in model_cids: - ctx.obj.ensure_file_exists(aggregator_models_path / f"{model_cid}.pth", model_cid, "T1 final model") - ctx.obj.ensure_file_exists(model_base_dir / "models" / "genesis_model.pth", genesis_model_ipfs_hash, "genesis model") + ctx.obj.ensure_file_exists(aggregator_models_path / f"{model_cid}.pth", model_cid, "T1 final model", base_dir=model_base_dir) + ctx.obj.ensure_file_exists(model_base_dir / "models" / "genesis_model.pth", genesis_model_ipfs_hash, "genesis model", base_dir=model_base_dir) jobs_dir = model_base_dir / "jobs" / "aggregators" / account.address job_path, output_dir = write_worker_job( diff --git a/dincli/cli/auditor.py b/dincli/cli/auditor.py index b4f4231d..280c7c9a 100644 --- a/dincli/cli/auditor.py +++ b/dincli/cli/auditor.py @@ -386,7 +386,7 @@ def evaluate_lms( requirements_path = get_worker_requirements_path(model_base_dir, "auditors") packages_dir = Path(packages_dir) if packages_dir else None if auditor_requirements_cid: - ctx.obj.ensure_file_exists(requirements_path, auditor_requirements_cid, "auditor requirements") + ctx.obj.ensure_file_exists(requirements_path, auditor_requirements_cid, "auditor requirements", base_dir=model_base_dir) try: ensure_worker_image(console) if not no_cache and packages_dir is None: @@ -403,6 +403,7 @@ def evaluate_lms( model_base_dir / "models" / "genesis_model.pth", genesis_model_cid, "genesis model", + base_dir=model_base_dir, ) found_any = False @@ -502,6 +503,7 @@ def evaluate_lms( model_base_dir / "models" / "auditor" / f"lm_{curr_GI}_{model_index}.pth", lm_cid, "local model submission", + base_dir=model_base_dir, ) metric_bundle_dir = model_base_dir / "audits" / "metric_bundles" / account.address diff --git a/dincli/cli/client.py b/dincli/cli/client.py index 46b11e82..a7e26763 100644 --- a/dincli/cli/client.py +++ b/dincli/cli/client.py @@ -137,7 +137,7 @@ def train_lms( requirements_path = get_worker_requirements_path(model_base_dir, "clients") packages_dir = Path(packages_dir) if packages_dir else None if client_requirements_cid: - ctx.obj.ensure_file_exists(requirements_path, client_requirements_cid, "client requirements") + ctx.obj.ensure_file_exists(requirements_path, client_requirements_cid, "client requirements", base_dir=model_base_dir) try: ensure_worker_image(console) @@ -157,6 +157,7 @@ def train_lms( model_base_dir / "models" / "genesis_model.pth", genesis_model_ipfs_hash, "genesis model", + base_dir=model_base_dir, ) if initial_model_ipfs_hash: @@ -164,6 +165,7 @@ def train_lms( model_base_dir / "models" / f"gm_{current_GI-1}.pt", initial_model_ipfs_hash, "latest global model", + base_dir=model_base_dir, ) client_dataset_path = model_base_dir / "dataset" / "clients" / account.address / "data.pt" diff --git a/dincli/cli/context.py b/dincli/cli/context.py index a0e1ffb3..40b3da24 100644 --- a/dincli/cli/context.py +++ b/dincli/cli/context.py @@ -250,7 +250,7 @@ def _get_task_manifest_context( return None, None return ( manifest_data, - Path.cwd() / "tasks" / self.network.lower() / taskCoordinator_address, + self.get_task_dir(taskCoordinator_address), ) # Some callers still pass only a raw contract address, especially for @@ -402,19 +402,25 @@ def load_custom_fn( fn_name: str, ipfs_hash: str = None, runtime: Optional[ServiceRuntimeContext] = None, + *, + base_dir: Path, ) -> Callable: """ Dynamically load a function from a project-local service file. + *base_dir* is the workflow root *module_path* must resolve inside -- + see `ensure_file_exists`. + Example: load_custom_fn( - Path.cwd() / "services" / "modelowner.py", + ctx.get_task_dir(task_coordinator_address) / "services" / "modelowner.py", "getGenesisModelIpfs", - "bafybeifnsvq2stiisi2xv3ocpqjmmmbccrvykq5v5ti62fyzz56vyyhl6y" + "bafybeifnsvq2stiisi2xv3ocpqjmmmbccrvykq5v5ti62fyzz56vyyhl6y", + base_dir=ctx.get_task_dir(task_coordinator_address), ) """ - self.ensure_file_exists(module_path, ipfs_hash, "Custom service file") + self.ensure_file_exists(module_path, ipfs_hash, "Custom service file", base_dir=base_dir) spec = importlib.util.spec_from_file_location( module_path.stem, @@ -502,7 +508,7 @@ def ensure_file_exists(self, ipfs_cid: str | None, description: str, *, - base_dir: Path | None = None, + base_dir: Path, ) -> None: """ Retrieve *file_path* from IPFS if it is missing or its stored CID @@ -515,15 +521,22 @@ def ensure_file_exists(self, If *ipfs_cid* is None, only a presence check is done (no CID comparison). *base_dir* is a backstop containment check (issue #227): *file_path* - must resolve inside it. Callers building *file_path* from a manifest - field should already have rejected an escaping path via - `resolve_manifest_path` before reaching here -- this is the second - check, not the first, so a call site that forgets the helper still - can't write outside *base_dir*. Defaults to `CACHE_DIR` (the - broadest boundary dincli itself ever writes application data under) - when the caller doesn't have a tighter, model-specific base on hand. + must resolve inside it. It is required and is always the root of the + workflow the file belongs to, never the whole cache: + + - ``get_model_base_dir(model_id)`` (``CACHE_DIR//model_``) + for clients, auditors, aggregators, and the model owner once the + model is registered; + - ``get_task_dir(task_coordinator_address)`` + (``cwd/tasks//``) for the model owner while + preparing the task, before it has a model_id. + + Callers building *file_path* from a manifest field should already + have rejected an escaping path via `resolve_manifest_path` before + reaching here -- this is the second check, not the first, so a call + site that forgets the helper still can't write outside *base_dir*. """ - effective_base = (base_dir if base_dir is not None else CACHE_DIR).resolve() + effective_base = base_dir.resolve() if not file_path.resolve().is_relative_to(effective_base): raise ManifestPathEscapesBaseError( f"refusing to write {description} outside {effective_base!s}: {file_path!s}" @@ -601,3 +614,6 @@ def validate_GIstate_LTE_given_GIstate(self, target_gi: int, curr_gi: int, curr_ def get_model_base_dir(self, model_id: int) -> Path: return Path(CACHE_DIR) / self.network / f"model_{model_id}" + + def get_task_dir(self, task_coordinator_address: str) -> Path: + return Path.cwd() / "tasks" / self.network.lower() / task_coordinator_address diff --git a/dincli/cli/modelownerd/aggregation.py b/dincli/cli/modelownerd/aggregation.py index 50cade88..5ffc0c95 100644 --- a/dincli/cli/modelownerd/aggregation.py +++ b/dincli/cli/modelownerd/aggregation.py @@ -2,7 +2,7 @@ import typer from rich.table import Table -from dincli.cli.utils import (CACHE_DIR, build_and_send_tx, ensure_batch_seed_locked, +from dincli.cli.utils import (build_and_send_tx, ensure_batch_seed_locked, get_manifest_key, require_custom_manifest_service, resolve_manifest_path) from dincli.services.cid_utils import get_cid_from_bytes32 @@ -396,7 +396,7 @@ def set_t2_score( console.print("[cyan]Calculating score for final GM model...[/cyan]") manifest = get_manifest_key(effective_network, "getscoreforGM", model_id) - model_base_path = Path(CACHE_DIR) / effective_network / f"model_{model_id}" + model_base_path = ctx.obj.get_model_base_dir(model_id) modelowner_service_path = resolve_manifest_path(model_base_path, manifest["path"], what="getscoreforGM path") model_service_path = resolve_manifest_path(model_base_path, get_manifest_key(effective_network, "ModelArchitecture", model_id)["path"], what="ModelArchitecture path") @@ -406,9 +406,10 @@ def set_t2_score( fn = ctx.obj.load_custom_fn( modelowner_service_path, - "getscoreforGM") + "getscoreforGM", + base_dir=model_base_path) - ctx.obj.ensure_file_exists(Path(model_base_path)/"models"/"genesis_model.pth", get_manifest_key(effective_network,"Genesis_Model_CID", model_id), "genesis model") + ctx.obj.ensure_file_exists(Path(model_base_path)/"models"/"genesis_model.pth", get_manifest_key(effective_network,"Genesis_Model_CID", model_id), "genesis model", base_dir=model_base_path) if not (Path(model_base_path)/"dataset"/"test"/"test_dataset.pt").exists(): console.print("[red]Error:[/red] Test dataset not found at ", str(Path(model_base_path)/"dataset"/"test"/"test_dataset.pt")) console.print("[yellow]Warning:[/yellow] please ensure the test dataset is present at ", str(Path(model_base_path)/"dataset"/"test"/"test_dataset.pt")) diff --git a/dincli/cli/modelownerd/auditor_batches.py b/dincli/cli/modelownerd/auditor_batches.py index 5bef4c07..7babe820 100644 --- a/dincli/cli/modelownerd/auditor_batches.py +++ b/dincli/cli/modelownerd/auditor_batches.py @@ -10,7 +10,7 @@ from eth_account.messages import encode_defunct from cryptography.hazmat.primitives.ciphers.aead import AESGCM -from dincli.cli.utils import CACHE_DIR, CONFIG_DIR, build_and_send_tx, ensure_batch_seed_locked, get_manifest_key, require_custom_manifest_service, resolve_manifest_path +from dincli.cli.utils import CONFIG_DIR, build_and_send_tx, ensure_batch_seed_locked, get_manifest_key, require_custom_manifest_service, resolve_manifest_path from dincli.services.cid_utils import get_bytes32_from_cid, get_cid_from_bytes32 auditor_batches_app = typer.Typer(help="Auditor Batches commands") @@ -139,13 +139,13 @@ def create_testdataset( audtor_batch_count = taskauditor_contract.functions.AuditorsBatchCount(ref_gi).call() - model_base_path = Path(CACHE_DIR) / effective_network / f"model_{model_id}" + model_base_path = ctx.obj.get_model_base_dir(model_id) manifest = get_manifest_key(effective_network, "create_audit_testDataCIDs", model_id) modelowner_service_path = resolve_manifest_path(model_base_path, manifest["path"], what="create_audit_testDataCIDs path") require_custom_manifest_service(manifest, "create_audit_testDataCIDs") ctx.obj.ensure_file_exists(modelowner_service_path, manifest["ipfs"], "model owner service", base_dir=model_base_path) - fn = ctx.obj.load_custom_fn(modelowner_service_path, "create_audit_testDataCIDs") + fn = ctx.obj.load_custom_fn(modelowner_service_path, "create_audit_testDataCIDs", base_dir=model_base_path) if test_data_path is None: test_data_path = model_base_path / "dataset" / "test" / "test_dataset.pt" diff --git a/dincli/cli/modelownerd/gi.py b/dincli/cli/modelownerd/gi.py index 78fc83ba..89f42e8f 100644 --- a/dincli/cli/modelownerd/gi.py +++ b/dincli/cli/modelownerd/gi.py @@ -130,7 +130,7 @@ def start( ctx.obj.ensure_file_exists(service_path, manifest["ipfs"], "model owner service", base_dir=model_base_path) ctx.obj.ensure_file_exists(model_arch_path, model_arch_manifest["ipfs"], "model architecture service", base_dir=model_base_path) - ctx.obj.ensure_file_exists(genesis_model_path, manifest_data["Genesis_Model_CID"], "genesis model") + ctx.obj.ensure_file_exists(genesis_model_path, manifest_data["Genesis_Model_CID"], "genesis model", base_dir=model_base_path) _confirm_or_exit(f"Pass Scoring is enabled. Have you placed the test dataset for GM evaluation at {test_data_path}?", "Pass Scoring is enabled. Test dataset not placed. ", console) @@ -139,7 +139,7 @@ def start( console.print(f"[red]X Test dataset missing:[/red] {test_data_path}") raise typer.Exit(1) - fn = ctx.obj.load_custom_fn(service_path, "getscoreforGM") + fn = ctx.obj.load_custom_fn(service_path, "getscoreforGM", base_dir=model_base_path) accuracy = fn(curr_gi, gmcid, model_base_path) diff --git a/dincli/cli/modelownerd/model.py b/dincli/cli/modelownerd/model.py index 9a655b3b..d62a7802 100644 --- a/dincli/cli/modelownerd/model.py +++ b/dincli/cli/modelownerd/model.py @@ -62,7 +62,7 @@ def create_genesis_model( console.print(f"The genesis model hash will be set in {os.getcwd()}/.env under {effective_network.upper() + '_' + task_coordinator_address}_GENESIS_MODEL_IPFS_HASH") raise typer.Exit(0) - task_dir = Path(os.getcwd()) / 'tasks' / effective_network.lower() / task_coordinator_address + task_dir = ctx.obj.get_task_dir(task_coordinator_address) _confirm_or_exit( "Have you created task directory using: dincli model-owner task create-task-dir ?", @@ -121,7 +121,8 @@ def create_genesis_model( console.print("[bold green]Creating genesis model... [/bold green]") fn = ctx.obj.load_custom_fn( service_path, - "getGenesisModelIpfs" + "getGenesisModelIpfs", + base_dir=task_dir, ) model_hash = fn(task_dir) @@ -149,7 +150,7 @@ def add_default_test_data(ctx: typer.Context, task_coordinator_address: str if model_id is not None: task_dir = ctx.obj.get_model_base_dir(model_id) elif task_coordinator_address is not None: - task_dir = Path.cwd() / 'tasks' / effective_network.lower()/ task_coordinator_address + task_dir = ctx.obj.get_task_dir(task_coordinator_address) test_dataset_path = task_dir.joinpath("dataset","test","test_dataset.pt") if default_test_data: default_test_dataset_ipfs_hash = "bafybeigjtcu2nzsffoy5pjmui25bnc43yduzn6aopi4wnrbtxfleqmw46y" @@ -206,7 +207,7 @@ def submit_genesis_model( accuracy = score else: - task_dir = Path.cwd() / 'tasks' / effective_network.lower()/ task_coordinator_address + task_dir = ctx.obj.get_task_dir(task_coordinator_address) manifest_data = get_manifest( effective_network, @@ -224,7 +225,7 @@ def submit_genesis_model( ctx.obj.ensure_file_exists(service_path, manifest["ipfs"], "model owner service", base_dir=task_dir) ctx.obj.ensure_file_exists(model_service_path, get_manifest_key(effective_network, "ModelArchitecture", None, task_coordinator_address)["ipfs"], "model architecture service", base_dir=task_dir) - fn = ctx.obj.load_custom_fn(service_path, "getscoreforGM") + fn = ctx.obj.load_custom_fn(service_path, "getscoreforGM", base_dir=task_dir) test_dataset_path = task_dir.joinpath("dataset", "test", "test_dataset.pt") _confirm_or_exit( @@ -294,7 +295,7 @@ def validate_update_manifest( effective_network, task_coordinator_address, console ) - task_dir = Path.cwd() / 'tasks' / effective_network.lower()/ task_coordinator_address + task_dir = ctx.obj.get_task_dir(task_coordinator_address) manifestpath = task_dir / "manifest.json" console.print(f"[bold green] Updating Manifest file at {manifestpath} ...[/bold green]") diff --git a/dincli/cli/system.py b/dincli/cli/system.py index 8847a51a..1f779b81 100644 --- a/dincli/cli/system.py +++ b/dincli/cli/system.py @@ -1376,7 +1376,7 @@ def distribute_mnist( if model_id: base_dir = ctx.obj.get_model_base_dir(model_id) elif task_coordinator_address: - base_dir = Path(os.getcwd()) / "tasks" / effective_network.lower() / task_coordinator_address + base_dir = ctx.obj.get_task_dir(task_coordinator_address) else: console.print("[red]❌ Model ID or task coordinator address is required.[/red]") raise typer.Exit(1) @@ -1418,8 +1418,8 @@ def distribute_mnist( service_path = base_dir / service_entry["path"].lstrip("/") requirements_path = base_dir / "requirements" / "distribute_mnist.txt" - ctx.obj.ensure_file_exists(service_path, service_entry["ipfs"], "distribute_mnist service") - ctx.obj.ensure_file_exists(requirements_path, service_entry["requirements"], "distribute_mnist requirements") + ctx.obj.ensure_file_exists(service_path, service_entry["ipfs"], "distribute_mnist service", base_dir=base_dir) + ctx.obj.ensure_file_exists(requirements_path, service_entry["requirements"], "distribute_mnist requirements", base_dir=base_dir) _confirm_or_exit( f"Have you installed the required dependencies via `pip install -r {requirements_path}`?", @@ -1433,7 +1433,7 @@ def distribute_mnist( if test_train: console.print("[cyan]Processing Train/Test Datasets...[/cyan]") try: - save_test_train_fn = ctx.obj.load_custom_fn(service_path, "save_test_train_datasets") + save_test_train_fn = ctx.obj.load_custom_fn(service_path, "save_test_train_datasets", base_dir=base_dir) save_test_train_fn(base_dir=base_dir) except Exception as e: console.print(f"[bold red]Error saving train/test datasets: {e}[/bold red]") @@ -1446,7 +1446,7 @@ def distribute_mnist( if clients: console.print("[cyan]Processing Client Datasets... This may take a while.[/cyan]") try: - distribute_to_clients_fn = ctx.obj.load_custom_fn(service_path, "distribute_to_clients") + distribute_to_clients_fn = ctx.obj.load_custom_fn(service_path, "distribute_to_clients", base_dir=base_dir) result = distribute_to_clients_fn( base_dir=base_dir, accounts_list=accounts_list, diff --git a/tests/test_manifest_path_traversal.py b/tests/test_manifest_path_traversal.py index e819f2fe..233257a8 100644 --- a/tests/test_manifest_path_traversal.py +++ b/tests/test_manifest_path_traversal.py @@ -13,10 +13,12 @@ `resolve_manifest_path` (dincli/cli/utils.py) is the fix: every call site that used to do ` / Path(manifest[...]["path"])` now goes through it, and it raises rather than silently re-anchoring. `ensure_file_exists` -(dincli/cli/context.py) also gained its own containment check as a -backstop, so a future call site that forgets the helper still can't -write outside its base directory (or CACHE_DIR, if no tighter base is -given). +and `load_custom_fn` (dincli/cli/context.py) also take a required +`base_dir` and check containment as a backstop, so a future call site that +forgets the helper still can't write outside its workflow root: +`get_model_base_dir(model_id)` for participants (and the model owner once +the model is registered), `get_task_dir(coordinator)` for the model owner +while preparing the task. """ from pathlib import Path from unittest.mock import MagicMock, patch @@ -93,6 +95,7 @@ def test_resolve_manifest_path_allows_a_path_equal_to_base_itself(tmp_path): def _make_dincontext(): ctx = DinContext.__new__(DinContext) # bypass __init__ (no config/log I/O needed) ctx.console = MagicMock() + ctx._resolved_network = "local" return ctx @@ -108,19 +111,58 @@ def test_ensure_file_exists_backstop_rejects_outside_path_with_explicit_base(tmp mock_retrieve.assert_not_called() -def test_ensure_file_exists_backstop_falls_back_to_cache_dir_default(tmp_path, monkeypatch): - # No base_dir passed -- falls back to CACHE_DIR. A path wildly outside - # CACHE_DIR must still be rejected. - monkeypatch.setattr("dincli.cli.context.CACHE_DIR", tmp_path / "dincli-cache") +def test_ensure_file_exists_and_load_custom_fn_require_base_dir(tmp_path): + # There is no CACHE_DIR fallback: a caller that forgets base_dir fails + # loudly instead of being checked against a boundary that's either too + # wide (other models' dirs) or wrong (the model owner's cwd task dir). + ctx = _make_dincontext() + path = tmp_path / "services" / "client.py" + + with patch("dincli.cli.context.retrieve_from_ipfs") as mock_retrieve: + with pytest.raises(TypeError): + ctx.ensure_file_exists(path, "cidABC", "client service") + with pytest.raises(TypeError): + ctx.load_custom_fn(path, "fn") + mock_retrieve.assert_not_called() + + +def test_ensure_file_exists_participant_cannot_write_into_another_model_dir(tmp_path, monkeypatch): + # A participant's root is its own model dir, not the whole cache: a path + # under a sibling model's dir is rejected even though it's inside CACHE_DIR. + monkeypatch.setattr("dincli.cli.context.CACHE_DIR", tmp_path / "cache") ctx = _make_dincontext() - outside_path = tmp_path / "definitely-not-the-cache-dir" / "evil.py" + other_model_path = ctx.get_model_base_dir(2) / "services" / "client.py" with patch("dincli.cli.context.retrieve_from_ipfs") as mock_retrieve: with pytest.raises(ManifestPathEscapesBaseError): - ctx.ensure_file_exists(outside_path, "cidABC", "evil file") + ctx.ensure_file_exists(other_model_path, "cidABC", "client service", base_dir=ctx.get_model_base_dir(1)) mock_retrieve.assert_not_called() +def test_load_custom_fn_model_owner_task_dir_outside_cache_dir(tmp_path, monkeypatch): + # Regression for create-genesis-model / submit-genesis-model / + # distribute-mnist (task flow): the model owner's services live under + # cwd/tasks//, outside CACHE_DIR, and must load. + monkeypatch.setattr("dincli.cli.context.CACHE_DIR", tmp_path / "cache") + project = tmp_path / "project" + project.mkdir() + monkeypatch.chdir(project) + ctx = _make_dincontext() + + task_dir = ctx.get_task_dir("0xabc") + assert task_dir == project / "tasks" / "local" / "0xabc" + service_path = resolve_manifest_path(task_dir, "services/modelowner.py") + service_path.parent.mkdir(parents=True) + service_path.write_text("def getGenesisModelIpfs(task_dir):\n return 'ok'\n") + + fn = ctx.load_custom_fn(service_path, "getGenesisModelIpfs", base_dir=task_dir) + assert fn(task_dir) == "ok" + + # The same file is outside a participant's model dir. + with pytest.raises(ManifestPathEscapesBaseError): + ctx.load_custom_fn(service_path, "getGenesisModelIpfs", base_dir=ctx.get_model_base_dir(1)) + + def test_ensure_file_exists_accepts_a_path_inside_the_given_base(tmp_path): ctx = _make_dincontext() base = tmp_path / "model_base" @@ -279,6 +321,7 @@ def test_create_testdataset_rejects_manifest_path_escape(mock_get_manifest_key): ctx.obj.get_current_gi_and_state.return_value = (1, "AuditorsBatchesCreated") ctx.obj.validate_gi_ET_curr_GI.return_value = 1 ctx.obj.validate_GIstate_ET_given_GIstate.return_value = None + ctx.obj.get_model_base_dir.return_value = Path("/tmp/dincli-test-modelowner-model") mock_get_manifest_key.return_value = {"path": "/etc/passwd", "ipfs": "cidABC"} From 6e361cb0b4b631f73d3c5ef86fa03469f9f14ae2 Mon Sep 17 00:00:00 2001 From: umeradl Date: Thu, 8 Oct 2026 18:37:24 +0500 Subject: [PATCH 08/48] docs(tasks): tick task_061026_22 deliverables (Discussion No. 233 closed) Both Parts merged into develop: PR No. 234 (2fa8ce8 + 07fd597) and PR No. 237 (0be3d4a + 920c93e). Verified on origin/develop @ 920c93e and recorded in the Discussion No. 233 closing comment. Co-Authored-By: Claude Opus 5.5 --- Developer/tasks/task_061026_22.md | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/Developer/tasks/task_061026_22.md b/Developer/tasks/task_061026_22.md index 9c572a21..a14d8047 100644 --- a/Developer/tasks/task_061026_22.md +++ b/Developer/tasks/task_061026_22.md @@ -77,12 +77,12 @@ Whether `fundGI` also refuses a model with `modelDisabled[modelId] == true`. **R ## Deliverables (Part A) -- [ ] `fundGI` reverts for any `taskAuditor` the registry doesn't map to an approved model (plus the disabled-model decision from A2, stated in the PR) -- [ ] Registry reference set in `initialize` and through an owner setter, appended before `__gap` (kept at 50, see the A1 amendment); `storage_layout.md` updated -- [ ] `DeployPlatform.s.sol` wires it; deploy tests updated -- [ ] Both PoC tests committed as regression tests; existing emission tests pass; break-then-fix shown in the PR -- [ ] `cd foundry && npm ci && forge build && forge test` green across the full suite, including `UpgradeValidation.t.sol` -- [ ] Bundled ABIs refreshed where they changed; security-review doc notes #226 as fixed +- [x] `fundGI` reverts for any `taskAuditor` the registry doesn't map to an approved model (plus the disabled-model decision from A2, stated in the PR) +- [x] Registry reference set in `initialize` and through an owner setter, appended before `__gap` (kept at 50, see the A1 amendment); `storage_layout.md` updated +- [x] `DeployPlatform.s.sol` wires it; deploy tests updated +- [x] Both PoC tests committed as regression tests; existing emission tests pass; break-then-fix shown in the PR +- [x] `cd foundry && npm ci && forge build && forge test` green across the full suite, including `UpgradeValidation.t.sol` +- [x] Bundled ABIs refreshed where they changed; security-review doc notes #226 as fixed --- @@ -127,11 +127,11 @@ pytest, in the existing `monkeypatch` style (see `tests/test_ipfs_config.py`, `t ## Deliverables (Part B) -- [ ] One shared `resolve_manifest_path` helper rejecting absolute paths, `..` escapes and symlink escapes -- [ ] Every manifest-driven call site uses it (list them in the PR); `ensure_file_exists` has the backstop check -- [ ] Tests as in B4, with break-then-fix shown in the PR -- [ ] `pytest -m "not integration"` green -- [ ] [`Documentation/public/manifest.md`](../../Documentation/public/manifest.md): one sentence that `path` fields must be relative and stay inside the model's directory +- [x] One shared `resolve_manifest_path` helper rejecting absolute paths, `..` escapes and symlink escapes +- [x] Every manifest-driven call site uses it (list them in the PR); `ensure_file_exists` has the backstop check +- [x] Tests as in B4, with break-then-fix shown in the PR +- [x] `pytest -m "not integration"` green +- [x] [`Documentation/public/manifest.md`](../../Documentation/public/manifest.md): one sentence that `path` fields must be relative and stay inside the model's directory --- From d664bb4cd39f7fa52cdd89f9961194db8c7d0251 Mon Sep 17 00:00:00 2001 From: Abidoyesimze Date: Thu, 15 Oct 2026 09:50:31 +0100 Subject: [PATCH 09/48] docs: document per-participant reward rounding dust as accepted (#250) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the issue #250 approval review: the gap is real (claimReward floor-divides each role's pool proportionally per participant, with no counter tracking distributed-vs-total so a sweep isn't even possible without re-deriving it from events), but fixing it needs a per-bucket participant count BL-10 deliberately removed the settlement-time loop that could have supplied, and a deadline-based sweep conflicts with MECHANISM_DESIGN.md's existing "claimable indefinitely, no expiry" decision for unclaimed shares. Documenting as accepted, permanent, economically-negligible dust (bounded by floor-division error) rather than fixing in a non-upgradeable contract. - DINTaskAuditor.md §13 No. 4: extended to cover this second, per-participant rounding layer alongside the existing zero-participant case, explicit that BL-14 never touched it. - BACK_LOG.md: corrected BL-14's "per-recipient" wording -- the fix that landed only ever covered the top-level 4-way bps split's remainder (one recipient: treasury), not this per-participant layer. Points the real per-participant gap at #250. - MECHANISM_DESIGN.md: noted alongside the existing "unclaimed rewards" entry, since they're two distinct kinds of stranded value (never-claimed shares vs. rounding dust) worth documenting together. Re-labeled from a code fix to documentation per the review -- no contract change. Co-Authored-By: Claude Sonnet 5 --- Developer/BACK_LOG.md | 2 +- Developer/design/MECHANISM_DESIGN.md | 2 +- Documentation/technical/contracts/DINTaskAuditor.md | 4 +++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/Developer/BACK_LOG.md b/Developer/BACK_LOG.md index 71a9144c..bed459ea 100644 --- a/Developer/BACK_LOG.md +++ b/Developer/BACK_LOG.md @@ -20,7 +20,7 @@ | BL-11 | Solidity/foundry (slashing, dispute resolution) | Dispute-redo aggregator selection reuses the grindable blockhash shuffle (H-2) | `DINTaskCoordinator._assignFreshSubgroup` (task_210726_6 §4c dispute scaffold) shuffles via the same `blockhash(block.number - 1)`-based entropy flagged as finding H-2 in the 2026-07 security review. Materially worse here: the grinding party (model owner, via `resolveDispute`) is also the sole adjudicator and faces no deadline, so they can wait indefinitely for a favorable hash — undermining the one mechanism meant to catch owner/aggregator collusion. Should be resolved together with H-2 (commit-reveal or VRF-based randomness) before dispute resolution graduates past scaffold. | P3/P4 | PR #66 review, linked to H-2 (2026-07 security review), Jul 30, 2026 | ✅ Resolved — [PR #171](https://github.com/InfiniteZeroFoundation/DevNet/pull/171) (merged 2026-09-26, `develop`; task_240926_16 Part A, issue #151): future-block seed. `openDispute` anchors `seedBlock = block.number + disputeSeedDelay` (default 7, owner-settable 1–256); permissionless `lockDisputeSeed` stores `keccak256(blockhash(seedBlock), GI, tierKind, batchId)`; `resolveDispute(upheld=true)` requires a locked seed, so the draw no longer depends on when the owner resolves. `_assignFreshSubgroup` shuffles the GI's fixed registered aggregator pool and takes the first still-active, non-accused entries, so an aggregator exiting after the seed is public can only drop itself from the draw, not re-roll it. Residuals: (1) sequencer-produced blockhash is trusted — acceptable for testnet, VRF / multi-party commit-reveal is the mainnet-grade follow-up, tracked in [issue #178](https://github.com/InfiniteZeroFoundation/DevNet/issues/178); (2) if nobody calls `lockDisputeSeed` within ~256 blocks (~8.5 min on OP) of `seedBlock`, the lock re-anchors to a fresh future block, giving a second draw — nothing locks automatically today and dincli has no dispute commands yet. | | BL-12 | Protocol / Mechanism design | Dispute bond denomination (DIN) vs. MECHANISM_DESIGN's ETH-denominated validator-fee bonds | `DINTaskCoordinator`'s dispute bond (task_210726_6 §4c) is DIN-denominated, while `MECHANISM_DESIGN.md`'s resolved decision (2026-07-21) places validator-network-fee bonds in ETH. Not necessarily wrong — dispute bonds and network-fee bonds may reasonably use different denominations — but wasn't flagged as a deliberate choice anywhere, including the slashing taxonomy doc. Needs an explicit decision recorded either way. | P3 | PR #66 review, Jul 30, 2026 | ✅ Resolved — [PR #133](https://github.com/InfiniteZeroFoundation/DevNet/pull/133) (merged 2026-09-10, `develop`); decision recorded as DIN-denominated | | BL-13 | dincli / Auditing | Encrypted test-data key correspondence enforced only by array length, not identity | `DINTaskAuditor.assignAuditTestDataset` checks `encryptedKeys.length == batch.auditors.length` but has no way to verify `encryptedKeys[i]` actually corresponds to `auditors[i]` — a misordered off-chain script could silently hand an auditor the wrong encrypted key. Fix likely belongs on the dincli side (keying the off-chain encryption step explicitly by auditor address rather than positional order) once real per-validator encryption is implemented — PR #63 only wired the on-chain plumbing, per task scope. | P3/P4 | PR #63 review, Jul 30, 2026 | ✅ Resolved — [PR #110](https://github.com/InfiniteZeroFoundation/DevNet/pull/110) (merged 2026-09-09, `develop`); `encryptedKeys` now built via explicit per-auditor address lookup, not array position | -| BL-14 | Solidity/foundry (rewards) | Per-recipient bps-split rounding dust never swept | `DINTaskAuditor.settleRewards`'s integer-division bps split leaves small per-recipient rounding remainders uncollected — no fund-safety impact (funds stay in the contract, just uncredited to any specific claimant), but nothing sweeps it to treasury either. Minor cleanup once the treasury-forwarding TODO from Part 3 (task_210726_6 §3) is resolved. | P3/P4 | PR #64 review, Jul 30, 2026 | ✅ Resolved — folded into [PR #134](https://github.com/InfiniteZeroFoundation/DevNet/pull/134)/[PR #131](https://github.com/InfiniteZeroFoundation/DevNet/pull/131)'s reward-engine rework: bps-split remainder now explicitly swept to `treasuryAccrued` (merged 2026-09-10, `develop`) | +| BL-14 | Solidity/foundry (rewards) | Top-level bps-split rounding dust never swept | `DINTaskAuditor.settleRewards`'s integer-division 4-way bps split (client/auditor/aggregator/treasury) leaves a small rounding remainder uncollected — no fund-safety impact (funds stay in the contract), but nothing swept it to treasury either. Minor cleanup once the treasury-forwarding TODO from Part 3 (task_210726_6 §3) is resolved. | P3/P4 | PR #64 review, Jul 30, 2026 | ✅ Resolved, for the top-level split only — folded into [PR #134](https://github.com/InfiniteZeroFoundation/DevNet/pull/134)/[PR #131](https://github.com/InfiniteZeroFoundation/DevNet/pull/131)'s reward-engine rework: the 4-way split's remainder now explicitly sweeps to `treasuryAccrued` (merged 2026-09-10, `develop`). The original title said "per-recipient," but the fix only ever covered this one top-level split; it was never about the second, per-participant rounding layer inside `claimReward` (each role's pool divided again per claimant) — that layer is untouched by this fix and is tracked separately as accepted-by-design in [issue #250](https://github.com/InfiniteZeroFoundation/DevNet/issues/250) / `DINTaskAuditor.md` §13 No. 4 | | BL-15 | Solidity/foundry (slashing, dispute resolution) | A resolved dispute can never be reopened against the same batch — undocumented | `DINTaskCoordinator.resolveDispute` has no path back to `disputes[gi][tierKind][batchId].resolved == false` once set, so exactly one dispute per batch is possible, ever (upheld or rejected). Reasonable as a scaffold-stage design choice, but wasn't called out in code comments or `Developer/design/slashing-taxonomy.md`. Cheap doc fix; no behavior change needed unless multi-dispute-per-batch is later desired. | P3/P4 | PR #66 review, Jul 30, 2026 | 🆕 New — unaddressed; no doc note added to `slashing-taxonomy.md` or the code comments about one-shot dispute resolution | | BL-16 | Governance / din-dao | DIN-DAO (Stages A–D) deferred to post-mainnet | Abraham's decision: no `DinMultisig` for Stage A/Devnet 2.0–3.0 — DevNet governs off-chain (team coordination + public discussion, Ethereum All-Core-Devs-style) until well past testing; any near-term multisig is a plain Gnosis Safe scoped to treasury/funds only, never protocol roles. This pulls all of `feat/din-dao` (PR #27, issue #23) out of active P4 scope, not just the Stage A multisig — Stage C (`DinGovernor`/`DinGovernanceStaking`) goes with it. [DD-1](design/DESIGN_DECISIONS.md#dd-1--validator-governance-voting-power-double-locking-vs-stake-bridge), [DD-2](design/DESIGN_DECISIONS.md#dd-2--dao-voting-power-model-coin-weighted-vs-quadratic-vs-non-coin-based), and [DD-3](design/DESIGN_DECISIONS.md#dd-3--initial-dinmultisig-signer-composition-stage-a) are the specific design questions that move with it — DD-3 is resolved (no Stage A multisig at all), DD-1/DD-2 (Stage C voting power/model) stay open and unresolved, to revisit once on-chain governance work actually resumes. In the interim, governance discussion moves to forums. | Post-mainnet | discuss-040826.md (Abraham, Aug 4, 2026); [discussion #70](https://github.com/InfiniteZeroFoundation/DevNet/discussions/70) comment (robertocarlous, Aug 4, 2026) confirming the team-call decision | 🆕 New — unchanged, still deferred per Abraham's decision | | BL-17 | Cryptoeconomics / Tokenomics | Curated per-model staking & fee attribution | Robert's follow-up to the a16z tokenomics piece Abraham shared: today validator staking is one shared network-wide pool and `DinFeeRouter` distributes revenue without attribution to individual models, so incentives track network-wide averages rather than any one model's performance. Proposal: let validators stake toward individual models/model owners via `DINModelRegistry` instead, using the `modelMinStakeBounds` storage already added to `DinValidatorStake` during P3 staking work, making fee flows traceable per model (closer to an Aave Safety-Module framing than a passive-holder dividend). Most of the wiring already exists; remaining work is mainly task-contract-level enforcement, already flagged as a `modelMinStakeBounds` follow-up. Robert explicitly leaves P4-vs-P5 placement open. Article summary + context: [`discussion/application-token-cash-flows-a16z.md`](discussion/application-token-cash-flows-a16z.md). | P4/P5 (undecided) | [Discussion #73](https://github.com/InfiniteZeroFoundation/DevNet/discussions/73), @robertocarlous, Aug 5, 2026 | 🆕 New — still undecided, no PR | diff --git a/Developer/design/MECHANISM_DESIGN.md b/Developer/design/MECHANISM_DESIGN.md index 17958af9..ece31133 100644 --- a/Developer/design/MECHANISM_DESIGN.md +++ b/Developer/design/MECHANISM_DESIGN.md @@ -137,7 +137,7 @@ Settlement is **per-GI** and split into two on-chain steps so that neither is a - **Incremental totals:** the denominators `claimReward` divides by are accumulated *during* the GI, on loops that already run for other reasons — `giTotalApprovedScore` in `finalizeEvaluation`, `giTotalAuditWeight` / `auditorGIWeight` in `revealAuditScore`, `aggregatorWeight` / `totalAggregatorWeight` in `finalizeT1Aggregation` / `finalizeT2Aggregation`. `settleRewards` therefore needs no scan to derive them. - **Aggregator weighting:** one weight unit per (aggregator, finalized T1/T2 batch) pair — an aggregator credited for both a T1 batch and the T2 batch earns a proportionally larger share, not a flat per-address amount. - **What's on-chain vs off-chain:** scores land on-chain (auditor submissions, already implemented); the proportional split is computed on-chain — at claim time, per participant — so payouts are verifiable. Heavy scoring math stays off-chain in the Python services; the chain only sees submitted scores. -- **Unclaimed rewards:** claimable indefinitely; no expiry for 2.0. A participant who never calls `claimReward(gi)` simply leaves their share unaccrued in the contract — it is not swept or reassigned. +- **Unclaimed rewards:** claimable indefinitely; no expiry for 2.0. A participant who never calls `claimReward(gi)` simply leaves their share unaccrued in the contract — it is not swept or reassigned. Distinct from this: the per-participant proportional division itself (`pool * share / total`) floor-rounds, so up to `participants − 1` wei per role pool per GI is stranded regardless of who claims — accepted as permanent, economically negligible dust rather than fixed, since a sweep would need the kind of per-bucket participant-count tracking the "no scan needed" design above deliberately avoids (issue #250, `DINTaskAuditor.md` §13 No. 4). - **Anti-gaming:** duplicate-update discounting is inherent in BlockFLow sequential fold-in (P3-SCR verifies); score inflation by a single auditor is bounded by the median; a client scoring ≤ pass-score threshold earns nothing, so spam costs compute for zero return. ### Open decisions diff --git a/Documentation/technical/contracts/DINTaskAuditor.md b/Documentation/technical/contracts/DINTaskAuditor.md index 5b667def..15945f30 100644 --- a/Documentation/technical/contracts/DINTaskAuditor.md +++ b/Documentation/technical/contracts/DINTaskAuditor.md @@ -201,7 +201,9 @@ Read alongside the [foundry/src security review](../audits/foundry-src-security- - **No. 1 — Fixed: a test-data dispute can no longer be won by the challenger alone.** `resolveTestDataDispute` used to be callable by anyone, and any commitment mismatch upheld the dispute. So any address could pass a junk `K` and win: bond back, the model owner's GI pool cut by `disputePenaltyBps`, and the batch blocked until reassignment. With a 0 default bond this was free and repeatable. Now only an auditor of the batch can open a dispute, the bond defaults to 100 DIN, and only the owner can resolve. An owner who doesn't answer within the window loses through `closeExpiredDispute` (issue No. 205). Remaining trust assumption: the owner reveals evidence about their own data, and a bad plaintext behind a correct `K` can't be proven on-chain. Decentralised adjudication is tracked in issue No. 181. - **No. 2 — Fixed: commit hashes are now bound to the auditor.** The old hash, `keccak256(abi.encodePacked(score, vote, salt))`, carried no address, GI, batch or model. An assigned auditor could copy a peer's commit hash, wait for the peer's reveal, and replay it for a free vote. The hash now binds `msg.sender`, `gi`, `batchId` and `modelIndex` (§7), as the aggregation commits on the coordinator do (issue No. 192). - **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. 4 — Unclaimable remainders.** Two distinct cases, both accepted by design (issue No. 250): + - *Zero-participant case:* if no model is approved (`giTotalApprovedScore == 0`), or nobody reveals, or no aggregator weight exists, that role's entire pool share stays in the contract with no reclaim path. + - *Per-participant rounding case:* `claimReward` floor-divides each role's pool proportionally per participant (`clientPool * finalMedianScore / totalApproved`, and the equivalent for `auditorPool`/`aggregatorPool`). Each participant's share is rounded down independently, so up to `participants − 1` wei per role bucket per GI is left behind, untracked (no "distributed so far" counter exists to even sweep it later without re-deriving the total from events) and unrecoverable. Distinct from, and not fixed by, BL-14 (`Developer/BACK_LOG.md`) — BL-14's fix only swept the *top-level* 4-way bps split's remainder to treasury in `settleRewards`; it never touched this second, per-participant layer. Economically negligible at 18 decimals (bounded by floor-division error), but worth naming explicitly: a sweep would need a per-bucket participant-count tracked incrementally (the BL-10 refactor deliberately removed a settlement-time loop that could have supplied one), and `Developer/design/MECHANISM_DESIGN.md`'s "Unclaimed rewards: claimable indefinitely; no expiry for 2.0" rules out a deadline-based sweep anyway — so this is documented as permanent, accepted dust rather than fixed in code. - **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)). From cd35dff8c499e44ee06afebb7c6e1fb748066c12 Mon Sep 17 00:00:00 2001 From: Abidoyesimze Date: Thu, 15 Oct 2026 10:00:34 +0100 Subject: [PATCH 10/48] fix(dincli): make submit-genesis-model safe to re-run (issue #251) Checks GI/GIstate before any scoring work or confirmation prompts. Re-running after the genesis hash is already on-chain (GI == 0 && GenesisModelCreated) now skips setGenesisModelIpfsHash and submits only the tier 2 score, after verifying the on-chain hash still matches the CID being scored. GI > 0 exits with a clear error instead of attempting a tx that would revert on-chain. Co-Authored-By: Claude Sonnet 5 --- .../public/workflows/model-workflow.md | 2 + dincli/cli/modelownerd/model.py | 72 +++++-- tests/test_submit_genesis_model.py | 200 ++++++++++++++++++ 3 files changed, 255 insertions(+), 19 deletions(-) create mode 100644 tests/test_submit_genesis_model.py diff --git a/Documentation/public/workflows/model-workflow.md b/Documentation/public/workflows/model-workflow.md index 3916949c..5d92d9c6 100644 --- a/Documentation/public/workflows/model-workflow.md +++ b/Documentation/public/workflows/model-workflow.md @@ -188,6 +188,8 @@ dincli model-owner model submit-genesis > make sure that the appropriate test dataset for the model is available at `/tasks/sepolia_op_devnet//dataset/test/test_dataset.pt` before running the above command. The test dataset will be used to evaluate the genesis model and calculate the score. This score will set the threshold range for the final eligibility of local models. +> this command is safe to re-run. If the genesis model hash was already submitted on-chain (e.g. a prior run submitted the hash but failed before or during the score submission), re-running it detects that the hash is already set, verifies it still matches the CID you're scoring, and (re-)submits only the tier 2 score. If the on-chain hash doesn't match the CID you're submitting, it exits with an error instead of attaching a score to the wrong genesis model. + ## 8. Register Model - Model Owner Model Owner updates the manisfest file at`/tasks/sepolia_op_devnet//manifest.json`, specially the `Genesis_Model_CID`, `DINTaskCoordinator_Contract`, and `DINTaskAuditor_Contract` fields with the IPFS CID of the genesis model, DINTaskCoordinator contract address and DINTaskAuditor contract address respectively. diff --git a/dincli/cli/modelownerd/model.py b/dincli/cli/modelownerd/model.py index d62a7802..7810e874 100644 --- a/dincli/cli/modelownerd/model.py +++ b/dincli/cli/modelownerd/model.py @@ -9,6 +9,7 @@ from dincli.cli.utils import (build_and_send_tx, get_env_key, get_manifest, get_manifest_key, + GIstateToStr, require_custom_manifest_service, resolve_manifest_path, resolve_task_coordinator_address, @@ -166,12 +167,18 @@ def submit_genesis_model( score: int = typer.Option(None, "--score", help="Score of the model"), help: bool = typer.Option(False, "--help","-h", help="Show help"), ): + """Submit the genesis model's IPFS hash and/or tier 2 score for GI 0. + + Safe to re-run: if the genesis model hash was already submitted + (GIstate == GenesisModelCreated) this re-sends only the tier 2 score, + after checking the on-chain hash still matches the CID being scored. + """ effective_network, w3, account, console = ctx.obj.get_en_w3_account_console() task_coordinator_address = resolve_task_coordinator_address( effective_network, task_coordinator_address, console ) - + if help: console.print("[bold green]Usage:[/bold green]") console.print(" dincli model-owner model submit-genesis-model --network ") @@ -182,9 +189,25 @@ def submit_genesis_model( console.print("\n [yellow]Warning:[/yellow] the genesis model must be available at: ") console.print(f" {Path(os.getcwd()) / 'tasks' / effective_network.lower() / task_coordinator_address / 'models' / 'genesis_model.pth'}") console.print(f"\n [yellow]Warning:[/yellow] If --ipfs-hash is not specified, the genesis model IPFS hash will be read from {os.getcwd()}/.env under {effective_network.upper() + '_' + task_coordinator_address + '_GENESIS_MODEL_IPFS_HASH'}") + console.print("\n [yellow]Note:[/yellow] safe to re-run — if the genesis model hash is already on-chain, this only (re-)submits the tier 2 score.") raise typer.Exit(0) - + deployed_DINTaskCoordinatorContract = ctx.obj.get_deployed_din_task_coordinator_contract(True, None, task_coordinator_address) + curr_GI, curr_GIstate = ctx.obj.get_current_gi_and_state(deployed_DINTaskCoordinatorContract, verbose_gi=False) + curr_GIstate_str = GIstateToStr(curr_GIstate) + + score_only_rerun = False + if curr_GIstate_str == "AwaitingGenesisModel": + pass + elif curr_GI == 0 and curr_GIstate_str == "GenesisModelCreated": + score_only_rerun = True + elif curr_GI > 0: + console.print(f"[bold red]✗ Global iteration 0's genesis model and score can no longer be set (current GI: {curr_GI}).[/bold red]") + raise typer.Exit(1) + else: + console.print(f"[bold red]✗ Cannot submit the genesis model at this time. Current state: {curr_GIstate_str}[/bold red]") + raise typer.Exit(1) + _confirm_or_exit( f"Have you generated the genesis model using dincli model-owner model create-genesis-model?", f"Please generate the genesis model using dincli model-owner model create-genesis-model.", @@ -198,9 +221,21 @@ def submit_genesis_model( console.print("[bold red]Genesis model IPFS hash not found![/bold red]") console.print(f"[yellow]Please set {effective_network.upper() + '_' + task_coordinator_address + '_GENESIS_MODEL_IPFS_HASH'} in {os.getcwd()}/.env[/yellow]") raise typer.Exit(1) - - console.print(f"[bold green]Submitting genesis model to DIN Task Coordinator![/bold green]") - console.print(f"[cyan]Genesis model IPFS hash:[/cyan] {ipfs_hash}") + + genesis_ipfs_hash_bytes32 = Web3.to_bytes(hexstr=get_bytes32_from_cid(ipfs_hash)) + + if score_only_rerun: + onchain_genesis_hash = deployed_DINTaskCoordinatorContract.functions.genesisModelIpfsHash().call() + if onchain_genesis_hash != genesis_ipfs_hash_bytes32: + console.print("[bold red]✗ The on-chain genesis model hash does not match the CID you're submitting a score for.[/bold red]") + console.print(f"[yellow]On-chain hash:[/yellow] 0x{onchain_genesis_hash.hex()}") + console.print(f"[yellow]Submitted CID hash:[/yellow] 0x{genesis_ipfs_hash_bytes32.hex()}") + console.print("[yellow]Refusing to attach this score to a different on-chain genesis model.[/yellow]") + raise typer.Exit(1) + console.print("[yellow]Genesis model already submitted on-chain and matches this CID — submitting score only.[/yellow]") + else: + console.print(f"[bold green]Submitting genesis model to DIN Task Coordinator![/bold green]") + console.print(f"[cyan]Genesis model IPFS hash:[/cyan] {ipfs_hash}") accuracy = None if score is not None: @@ -253,22 +288,21 @@ def submit_genesis_model( raise typer.Exit(1) - deployed_DINTaskCoordinatorContract = ctx.obj.get_deployed_din_task_coordinator_contract(True, None, task_coordinator_address) - - genesis_ipfs_hash_bytes32 = Web3.to_bytes(hexstr=get_bytes32_from_cid(ipfs_hash)) + if score_only_rerun: + _confirm_or_exit("\n Do you want to submit this tier 2 score to the blockchain? \n ", "You chose not to submit the tier 2 score to the blockchain.", console) + else: + _confirm_or_exit("\n Do you want to submit this genesis model to the blockchain? \n ", "You chose not to submit the genesis model to the blockchain.", console) + + build_and_send_tx( + ctx, + deployed_DINTaskCoordinatorContract.functions.setGenesisModelIpfsHash(genesis_ipfs_hash_bytes32), + "Submitting genesis model", + "Genesis model submitted!", + "Failed to submit genesis model!" + ) - _confirm_or_exit("\n Do you want to submit this genesis model to the blockchain? \n ", "You chose not to submit the genesis model to the blockchain.", console) - - tx_receipt = build_and_send_tx( - ctx, - deployed_DINTaskCoordinatorContract.functions.setGenesisModelIpfsHash(genesis_ipfs_hash_bytes32), - "Submitting genesis model", - "Genesis model submitted!", - "Failed to submit genesis model!" - ) + time.sleep(10) - time.sleep(10) - if accuracy is None: return diff --git a/tests/test_submit_genesis_model.py b/tests/test_submit_genesis_model.py new file mode 100644 index 00000000..72385d0b --- /dev/null +++ b/tests/test_submit_genesis_model.py @@ -0,0 +1,200 @@ +import hashlib +from types import SimpleNamespace + +import pytest +import typer +from web3 import Web3 + +from dincli.cli.modelownerd import model +from dincli.cli.utils import GIstatestrToIndex + + +CID_A = "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi" +CID_B = "bafybeiarceirceirceirceirceirceirceirceirceirceirceirceirce" + + +def _fake_get_bytes32_from_cid(cid_str: str) -> str: + """Deterministic stand-in for the real CID->digest conversion (no 'cid' package dependency).""" + return hashlib.sha256(cid_str.encode()).hexdigest() + + +def hash_bytes32(cid_str: str) -> bytes: + return Web3.to_bytes(hexstr=_fake_get_bytes32_from_cid(cid_str)) + + +class DummyConsole: + def __init__(self): + self.messages = [] + + def print(self, *args, **kwargs): + self.messages.append(args) + + def input(self, *args, **kwargs): + raise AssertionError("console.input should never be reached; _confirm_or_exit is bypassed in tests") + + +class DummyCall: + def __init__(self, value): + self.value = value + + def call(self): + return self.value + + +class DummyTaskCoordinatorFunctions: + def __init__(self, contract): + self.contract = contract + + def GI(self): + return DummyCall(self.contract.gi) + + def GIstate(self): + return DummyCall(self.contract.gi_state) + + def genesisModelIpfsHash(self): + return DummyCall(self.contract.onchain_hash) + + def setGenesisModelIpfsHash(self, genesis_hash_bytes32): + self.contract.set_genesis_calls.append(genesis_hash_bytes32) + return ("setGenesisModelIpfsHash", genesis_hash_bytes32) + + def setTier2Score(self, gi, score): + self.contract.set_score_calls.append((gi, score)) + return ("setTier2Score", gi, score) + + +class DummyTaskCoordinatorContract: + def __init__(self, gi, gi_state, onchain_hash=b"\x00" * 32): + self.gi = gi + self.gi_state = gi_state + self.onchain_hash = onchain_hash + self.set_genesis_calls = [] + self.set_score_calls = [] + self.functions = DummyTaskCoordinatorFunctions(self) + + +class DummyContextObj: + def __init__(self, contract): + self.console = DummyConsole() + self.account = SimpleNamespace(address="0xAccount") + self.w3 = object() + self.contract = contract + + def get_en_w3_account_console(self, *args, **kwargs): + return "local", self.w3, self.account, self.console + + def get_deployed_din_task_coordinator_contract(self, verbose=True, model_id=None, taskCoordinator_address=None): + return self.contract + + def get_current_gi_and_state(self, contract, verbose_gi=True, verbose_state=False, verbose_state_name=False): + return contract.gi, contract.gi_state + + +def make_ctx(contract): + return SimpleNamespace(obj=DummyContextObj(contract)) + + +def _fake_build_and_send_tx(ctx, contract_function, action_msg, success_msg, error_msg, *args, **kwargs): + return SimpleNamespace(transactionHash=bytes.fromhex("ab" * 32)) + + +@pytest.fixture(autouse=True) +def bypass_confirmations_sleep_and_cid(monkeypatch): + monkeypatch.setattr(model, "_confirm_or_exit", lambda *args, **kwargs: None) + monkeypatch.setattr(model.time, "sleep", lambda seconds: None) + monkeypatch.setattr(model, "get_bytes32_from_cid", _fake_get_bytes32_from_cid) + monkeypatch.setattr(model, "build_and_send_tx", _fake_build_and_send_tx) + + +def test_fresh_submission_sends_both_transactions(): + contract = DummyTaskCoordinatorContract(gi=0, gi_state=GIstatestrToIndex("AwaitingGenesisModel")) + ctx = make_ctx(contract) + + model.submit_genesis_model( + ctx, + ipfs_hash=CID_A, + task_coordinator_address="0xTaskCoordinator", + score=90, + help=False, + ) + + assert contract.set_genesis_calls == [hash_bytes32(CID_A)] + assert contract.set_score_calls == [(0, 90)] + + +def test_rerun_in_genesis_model_created_submits_score_only(): + contract = DummyTaskCoordinatorContract( + gi=0, + gi_state=GIstatestrToIndex("GenesisModelCreated"), + onchain_hash=hash_bytes32(CID_A), + ) + ctx = make_ctx(contract) + + model.submit_genesis_model( + ctx, + ipfs_hash=CID_A, + task_coordinator_address="0xTaskCoordinator", + score=77, + help=False, + ) + + assert contract.set_genesis_calls == [] + assert contract.set_score_calls == [(0, 77)] + + +def test_rerun_with_mismatched_cid_exits_without_sending_any_tx(): + contract = DummyTaskCoordinatorContract( + gi=0, + gi_state=GIstatestrToIndex("GenesisModelCreated"), + onchain_hash=hash_bytes32(CID_A), + ) + ctx = make_ctx(contract) + + with pytest.raises(typer.Exit): + model.submit_genesis_model( + ctx, + ipfs_hash=CID_B, + task_coordinator_address="0xTaskCoordinator", + score=77, + help=False, + ) + + assert contract.set_genesis_calls == [] + assert contract.set_score_calls == [] + + +def test_gi_greater_than_zero_exits_without_sending_any_tx(): + contract = DummyTaskCoordinatorContract(gi=1, gi_state=GIstatestrToIndex("GIstarted")) + ctx = make_ctx(contract) + + with pytest.raises(typer.Exit): + model.submit_genesis_model( + ctx, + ipfs_hash=CID_A, + task_coordinator_address="0xTaskCoordinator", + score=90, + help=False, + ) + + assert contract.set_genesis_calls == [] + assert contract.set_score_calls == [] + + +def test_unrelated_gi_zero_state_exits_without_sending_any_tx(): + """E.g. still waiting on slasher authorization -- neither the fresh path nor the re-run path applies.""" + contract = DummyTaskCoordinatorContract( + gi=0, gi_state=GIstatestrToIndex("AwaitingDINTaskAuditorAsSlasher") + ) + ctx = make_ctx(contract) + + with pytest.raises(typer.Exit): + model.submit_genesis_model( + ctx, + ipfs_hash=CID_A, + task_coordinator_address="0xTaskCoordinator", + score=90, + help=False, + ) + + assert contract.set_genesis_calls == [] + assert contract.set_score_calls == [] From 23e1eb76754bde81cb51983c10577c94743d7a3c Mon Sep 17 00:00:00 2001 From: umeradl Date: Fri, 16 Oct 2026 01:12:14 +0500 Subject: [PATCH 11/48] fix(skills): din-push-develop watcher no longer reports a running CI run as failed watch-push-run.sh relied on `gh run watch ... || true`. A transient network error (TLS handshake timeout) made it exit early, the script then read the `conclusion` of a still-in-progress run (empty) and printed `RESULT ` with exit 1 -- which the skill treats as red CI. Hit twice on the PR No. 256 push (run 38771547513, which went on to pass). Now polls `gh run view --json status,conclusion` until status == completed, retrying transient gh errors. If GitHub stays unreachable (~10 min) or the run isn't done after ~90 min, it prints a distinct `WATCH_ERROR ` (exit 4), documented in SKILL.md/README.md as "not a CI verdict". RESULT/NO_RUN outputs are unchanged. Co-Authored-By: Claude Opus 5.5 --- .claude/skills/din-push-develop/README.md | 2 +- .claude/skills/din-push-develop/SKILL.md | 1 + .../scripts/watch-push-run.sh | 36 ++++++++++++++++--- 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/.claude/skills/din-push-develop/README.md b/.claude/skills/din-push-develop/README.md index b1fccb4c..bf0c9f88 100644 --- a/.claude/skills/din-push-develop/README.md +++ b/.claude/skills/din-push-develop/README.md @@ -12,7 +12,7 @@ Background: [PR No. 168](https://github.com/InfiniteZeroFoundation/DevNet/pull/1 |---|---| | [`SKILL.md`](SKILL.md) | The skill: gate → local precheck → push → background watch → alert | | [`scripts/precheck.sh`](scripts/precheck.sh) | Mirrors `.github/workflows/ci.yml` on a given commit in a separate worktree, with an empty `HOME` | -| [`scripts/watch-push-run.sh`](scripts/watch-push-run.sh) | Waits for the GitHub `push` run of a SHA; last line is `RESULT ` or `NO_RUN ` | +| [`scripts/watch-push-run.sh`](scripts/watch-push-run.sh) | Waits for the GitHub `push` run of a SHA; last line is `RESULT `, `NO_RUN `, or `WATCH_ERROR ` (couldn't read a final result — not a CI verdict) | | [`scripts/guard.py`](scripts/guard.py) | `PreToolUse` hook that blocks Claude from a raw `git push` to `develop` | ## Setup diff --git a/.claude/skills/din-push-develop/SKILL.md b/.claude/skills/din-push-develop/SKILL.md index fb586fe7..037b5d93 100644 --- a/.claude/skills/din-push-develop/SKILL.md +++ b/.claude/skills/din-push-develop/SKILL.md @@ -72,6 +72,7 @@ Tell Umer in one line that the push landed and the run is being watched, then ca |---|---|---| | `RESULT success ` | green | One-line confirmation with the SHA and run URL. No notification. | | `RESULT failure ` (or `cancelled`, `timed_out`) | red | **`PushNotification`**, e.g. `develop CI red on : Python test_x failed`. Then pull the failing job's log (`gh run view --log-failed`, or `gh api .../actions/jobs//logs` if that's empty), name the failing step/test, and propose a fix commit. | +| `WATCH_ERROR ` | the watcher couldn't read a final result (GitHub unreachable for ~10 min, or the run is still going after ~90 min). **Not a CI verdict.** | No notification. Check the run yourself (`gh run view --json status,conclusion,jobs`), and re-run the watcher if it's still in progress. Then handle the real result as in the rows above. | | `NO_RUN ` | no push run started within ~5 min | **`PushNotification`**: `develop push has no CI run`. Check the Actions tab for a disabled workflow, a YAML error, or a runner outage. | For red or missing runs, remind Umer of the clock: if it's not fixed within ~6h and more commits land on top, the backup maintainers will open a thread in discussion No. 170. diff --git a/.claude/skills/din-push-develop/scripts/watch-push-run.sh b/.claude/skills/din-push-develop/scripts/watch-push-run.sh index 37264ef2..ddaf5a5a 100644 --- a/.claude/skills/din-push-develop/scripts/watch-push-run.sh +++ b/.claude/skills/din-push-develop/scripts/watch-push-run.sh @@ -4,9 +4,20 @@ # exit 0 RESULT success # exit 1 RESULT # exit 3 NO_RUN (no push run appeared within ~5 minutes) +# exit 4 WATCH_ERROR +# (couldn't read a final conclusion: GitHub unreachable for ~10 min, +# or the run still not completed after ~90 min -- NOT a CI verdict) +# +# Polls `gh run view` rather than relying on `gh run watch`: a transient +# network error (e.g. TLS handshake timeout) makes `gh run watch` exit early, +# and reading `conclusion` of a still-running run returns "", which used to be +# reported as a failure. set -uo pipefail SHA=$1 REPO=InfiniteZeroFoundation/DevNet +POLL=${WATCH_POLL:-30} # seconds between polls +MAX_ERRORS=${WATCH_MAX_ERRORS:-20} # consecutive failed gh calls before giving up (~10 min) +DEADLINE=$((SECONDS + 90 * 60)) id="" for _ in $(seq 1 20); do @@ -16,8 +27,25 @@ for _ in $(seq 1 20); do sleep 15 done [ -z "$id" ] && { echo "NO_RUN $SHA"; exit 3; } +url="https://github.com/$REPO/actions/runs/$id" -gh run watch -R "$REPO" "$id" --interval 30 >/dev/null 2>&1 || true -read -r concl url < <(gh run view -R "$REPO" "$id" --json conclusion,url --jq '"\(.conclusion) \(.url)"') -echo "RESULT $concl $url" -[ "$concl" = "success" ] +errors=0 +status=unreachable +while [ "$SECONDS" -lt "$DEADLINE" ]; do + if out=$(gh run view -R "$REPO" "$id" --json status,conclusion \ + --jq '"\(.status) \(.conclusion)"' 2>/dev/null); then + errors=0 + read -r status concl <<<"$out" + if [ "$status" = "completed" ]; then + echo "RESULT ${concl:-unknown} $url" + [ "$concl" = "success" ]; exit $? + fi + else + errors=$((errors + 1)) + if [ "$errors" -ge "$MAX_ERRORS" ]; then + echo "WATCH_ERROR $url unreachable"; exit 4 + fi + fi + sleep "$POLL" +done +echo "WATCH_ERROR $url $status"; exit 4 From 583e45edf479f8f9c3330f5dbea42e9bd56399f7 Mon Sep 17 00:00:00 2001 From: santiagocetran Date: Fri, 16 Oct 2026 12:13:30 -0300 Subject: [PATCH 12/48] docs: add partner outreach checklist --- Developer/README.md | 1 + Developer/partner-outreach-checklist.md | 42 +++++++++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 Developer/partner-outreach-checklist.md diff --git a/Developer/README.md b/Developer/README.md index 23c6701a..ed0c9895 100644 --- a/Developer/README.md +++ b/Developer/README.md @@ -19,6 +19,7 @@ Forward-looking material for people building the DIN Protocol: plans, designs, p | [`discussion/`](discussion/) | Open discussions (Filecoin support, Foundry migration) | | [`rejected-ideas/`](rejected-ideas/) | Ideas evaluated and rejected, with rationale (e.g. TKNN-Shapley) | | [CONTRIBUTING.md](CONTRIBUTING.md) | Contribution process and code standards pointers | +| [partner-outreach-checklist.md](partner-outreach-checklist.md) | Conversation checklist for potential DIN partners, with an open question on shared outreach tracking | | [CODE_STANDARDS.md](CODE_STANDARDS.md) | Code style and quality expectations | | [DEVELOPMENT_SETUP.md](DEVELOPMENT_SETUP.md) | Setting up a development environment | | [GOOD_FIRST_ISSUES.md](GOOD_FIRST_ISSUES.md) | Curated starter tasks for new contributors | diff --git a/Developer/partner-outreach-checklist.md b/Developer/partner-outreach-checklist.md new file mode 100644 index 00000000..811e95a5 --- /dev/null +++ b/Developer/partner-outreach-checklist.md @@ -0,0 +1,42 @@ +# Partner outreach checklist + +This checklist helps us start useful conversations with potential DIN partners and make sure everyone knows what happens next. It supports OPS-1’s goal of finding 2–3 validator partners, while also helping us follow up with people interested in training models or contributing in other ways. + +## Before the conversation + +- Read the [partner introduction](../Documentation/public/partner-introduction.md), including what is settled and what is still being decided, so we share a consistent explanation of DIN. +- Check with the technical onboarding lead what the partner can try today, which network and version to use, and which guide to share. +- Start with a trial small enough for the team to support. + +Be clear about what is still being decided. Participation does not guarantee income or a future token allocation. Validators can lose staked test tokens for certain missed duties; confirm the rules for their trial before they stake. Testnet balances, stakes, models, and participation records should be treated as temporary. + +If a question needs an answer we do not have yet, note it and arrange a follow-up. + +## Get to know the partner + +Use these as conversation prompts: + +- **What interests you about DIN?** Would you like to help run the network as a validator, train a model, or contribute in another way? +- **What would you like to try?** What model or task do you have in mind, what kind and approximate amount of data would you use, and what would make the trial worthwhile? +- **Who would take part?** Roughly how many people are involved, and who would coordinate on your side? +- **What resources do you have?** Discuss available computers, storage, internet access, time, and any setup help needed. +- **What needs to stay private?** Understand what can be shared and bring unresolved questions to the technical team. Keeping raw data on someone’s computer does not by itself guarantee privacy. + +For partners bringing a training project, share [What can I train on DIN?](../Documentation/public/what-can-be-trained.md). + +The first conversation should give us enough information to suggest a useful next step. Detailed technical questions can follow during onboarding. + +## Agree on the next step + +Before wrapping up, confirm: + +- Who follows up with the partner. +- Who leads the onboarding call. +- Who helps with setup, including a wallet, test ETH, staking where needed, and IPFS storage. +- What happens next, who will do it, and by when. + +One person may cover several responsibilities. OPS-1 identifies Umer for technical onboarding calls and Operations/Interns for outreach; confirm availability for each partner. + +## Question for discussion + +How could we keep one shared record of partner conversations so we know who is following up and what happens next? One option could be a GitHub Discussion with a short entry for each partner. Would that work for the team, or would another approach be easier to maintain? From 4dc4210e4b2eb48db436cc85beeeaa1e6bfcb0b4 Mon Sep 17 00:00:00 2001 From: robertocarlous Date: Sat, 17 Oct 2026 20:01:55 +0100 Subject: [PATCH 13/48] docs(contracts): document one-shot dispute resolution rule and add regression tests (BL-15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add comment block in DINTaskCoordinator.openDispute above the TC_DisputeAlreadyOpen check explaining that challenger is never cleared, so a resolved dispute also blocks reopening and documents the squatting-attack consequence for future reviewers - Add one-liner above resolveDispute clarifying the once-per-batch-per-GI invariant - Add two Forge regression tests (test_openDispute_revertsAfterRejectedResolution and test_openDispute_revertsAfterUpheldResolution) that cover post-resolution reopening attempts using Shared.TC_DisputeAlreadyOpen.selector matching - Extend DINTaskCoordinator.md §7 with one-shot constraint note and squatting consequence - Update DINShared.md TC_DisputeAlreadyOpen description to cover post-resolution case - Add one-shot constraint bullet with squatting analysis to slashing-taxonomy.md §S4 --- Developer/BACK_LOG.md | 2 +- Developer/design/slashing-taxonomy.md | 1 + .../technical/contracts/DINShared.md | 2 +- .../technical/contracts/DINTaskCoordinator.md | 2 + foundry/src/DINTaskCoordinator.sol | 7 +++ foundry/test/DisputeResolution.t.sol | 46 +++++++++++++++++++ 6 files changed, 58 insertions(+), 2 deletions(-) diff --git a/Developer/BACK_LOG.md b/Developer/BACK_LOG.md index 0a0a482f..a6a92deb 100644 --- a/Developer/BACK_LOG.md +++ b/Developer/BACK_LOG.md @@ -21,7 +21,7 @@ | BL-12 | Protocol / Mechanism design | Dispute bond denomination (DIN) vs. MECHANISM_DESIGN's ETH-denominated validator-fee bonds | `DINTaskCoordinator`'s dispute bond (task_210726_6 §4c) is DIN-denominated, while `MECHANISM_DESIGN.md`'s resolved decision (2026-07-21) places validator-network-fee bonds in ETH. Not necessarily wrong — dispute bonds and network-fee bonds may reasonably use different denominations — but wasn't flagged as a deliberate choice anywhere, including the slashing taxonomy doc. Needs an explicit decision recorded either way. | P3 | PR #66 review, Jul 30, 2026 | ✅ Resolved — [PR #133](https://github.com/InfiniteZeroFoundation/DevNet/pull/133) (merged 2026-09-10, `develop`); decision recorded as DIN-denominated | | BL-13 | dincli / Auditing | Encrypted test-data key correspondence enforced only by array length, not identity | `DINTaskAuditor.assignAuditTestDataset` checks `encryptedKeys.length == batch.auditors.length` but has no way to verify `encryptedKeys[i]` actually corresponds to `auditors[i]` — a misordered off-chain script could silently hand an auditor the wrong encrypted key. Fix likely belongs on the dincli side (keying the off-chain encryption step explicitly by auditor address rather than positional order) once real per-validator encryption is implemented — PR #63 only wired the on-chain plumbing, per task scope. | P3/P4 | PR #63 review, Jul 30, 2026 | ✅ Resolved — [PR #110](https://github.com/InfiniteZeroFoundation/DevNet/pull/110) (merged 2026-09-09, `develop`); `encryptedKeys` now built via explicit per-auditor address lookup, not array position | | BL-14 | Solidity/foundry (rewards) | Top-level bps-split rounding dust never swept | `DINTaskAuditor.settleRewards`'s integer-division 4-way bps split (client/auditor/aggregator/treasury) leaves a small rounding remainder uncollected — no fund-safety impact (funds stay in the contract), but nothing swept it to treasury either. Minor cleanup once the treasury-forwarding TODO from Part 3 (task_210726_6 §3) is resolved. | P3/P4 | PR #64 review, Jul 30, 2026 | ✅ Resolved, for the top-level split only — folded into [PR #134](https://github.com/InfiniteZeroFoundation/DevNet/pull/134)/[PR #131](https://github.com/InfiniteZeroFoundation/DevNet/pull/131)'s reward-engine rework: the 4-way split's remainder now forwards to the slash-treasury via `_forwardToTreasury` (burned if unset; counted in `treasuryAccrued`) (merged 2026-09-10, `develop`). The original title said "per-recipient," but the fix only ever covered this one top-level split; it was never about the second, per-participant rounding layer inside `claimReward` (each role's pool divided again per claimant) — that layer is untouched by this fix and is tracked separately as accepted-by-design in [issue #250](https://github.com/InfiniteZeroFoundation/DevNet/issues/250) / [DINTaskAuditor.md §13 No. 4](../Documentation/technical/contracts/DINTaskAuditor.md#13-review-notes--open-caveats) | -| BL-15 | Solidity/foundry (slashing, dispute resolution) | A resolved dispute can never be reopened against the same batch — undocumented | `DINTaskCoordinator.resolveDispute` has no path back to `disputes[gi][tierKind][batchId].resolved == false` once set, so exactly one dispute per batch is possible, ever (upheld or rejected). Reasonable as a scaffold-stage design choice, but wasn't called out in code comments or `Developer/design/slashing-taxonomy.md`. Cheap doc fix; no behavior change needed unless multi-dispute-per-batch is later desired. | P3/P4 | PR #66 review, Jul 30, 2026 | 🆕 New — unaddressed; no doc note added to `slashing-taxonomy.md` or the code comments about one-shot dispute resolution | +| BL-15 | Solidity/foundry (slashing, dispute resolution) | A resolved dispute can never be reopened against the same batch — undocumented | `DINTaskCoordinator.resolveDispute` has no path back to `disputes[gi][tierKind][batchId].resolved == false` once set, so exactly one dispute per batch is possible, ever (upheld or rejected). Reasonable as a scaffold-stage design choice, but wasn't called out in code comments or `Developer/design/slashing-taxonomy.md`. Cheap doc fix; no behavior change needed unless multi-dispute-per-batch is later desired. | P3/P4 | PR #66 review, Jul 30, 2026 | ✅ Fixed — PR #264 | | BL-16 | Governance / din-dao | DIN-DAO (Stages A–D) deferred to post-mainnet | Abraham's decision: no `DinMultisig` for Stage A/Devnet 2.0–3.0 — DevNet governs off-chain (team coordination + public discussion, Ethereum All-Core-Devs-style) until well past testing; any near-term multisig is a plain Gnosis Safe scoped to treasury/funds only, never protocol roles. This pulls all of `feat/din-dao` (PR #27, issue #23) out of active P4 scope, not just the Stage A multisig — Stage C (`DinGovernor`/`DinGovernanceStaking`) goes with it. [DD-1](design/DESIGN_DECISIONS.md#dd-1--validator-governance-voting-power-double-locking-vs-stake-bridge), [DD-2](design/DESIGN_DECISIONS.md#dd-2--dao-voting-power-model-coin-weighted-vs-quadratic-vs-non-coin-based), and [DD-3](design/DESIGN_DECISIONS.md#dd-3--initial-dinmultisig-signer-composition-stage-a) are the specific design questions that move with it — DD-3 is resolved (no Stage A multisig at all), DD-1/DD-2 (Stage C voting power/model) stay open and unresolved, to revisit once on-chain governance work actually resumes. In the interim, governance discussion moves to forums. | Post-mainnet | discuss-040826.md (Abraham, Aug 4, 2026); [discussion #70](https://github.com/InfiniteZeroFoundation/DevNet/discussions/70) comment (robertocarlous, Aug 4, 2026) confirming the team-call decision | 🆕 New — unchanged, still deferred per Abraham's decision | | BL-17 | Cryptoeconomics / Tokenomics | Curated per-model staking & fee attribution | Robert's follow-up to the a16z tokenomics piece Abraham shared: today validator staking is one shared network-wide pool and `DinFeeRouter` distributes revenue without attribution to individual models, so incentives track network-wide averages rather than any one model's performance. Proposal: let validators stake toward individual models/model owners via `DINModelRegistry` instead, using the `modelMinStakeBounds` storage already added to `DinValidatorStake` during P3 staking work, making fee flows traceable per model (closer to an Aave Safety-Module framing than a passive-holder dividend). Most of the wiring already exists; remaining work is mainly task-contract-level enforcement, already flagged as a `modelMinStakeBounds` follow-up. Robert explicitly leaves P4-vs-P5 placement open. Article summary + context: [`discussion/application-token-cash-flows-a16z.md`](discussion/application-token-cash-flows-a16z.md). | P4/P5 (undecided) | [Discussion #73](https://github.com/InfiniteZeroFoundation/DevNet/discussions/73), @robertocarlous, Aug 5, 2026 | 🆕 New — still undecided, no PR | | BL-18 | Cryptoeconomics / Tokenomics | Fair-launch validator token distribution | Abraham's vision (Slack, Jul 31, 2026): no ICO, pre-sale, or VC allocation — DIN distributed exclusively to validators/active network participants, programmatically, as a fair launch. Technical blueprint sent Aug 5, 2026: a new Merkle-drop claim contract (Transparent Proxy, treasury-Safe admin per [DD-3](design/DESIGN_DECISIONS.md#dd-3--initial-dinmultisig-signer-composition-stage-a)) funded once from treasury, cliff + linear vesting on claim. Resolves the open ICO-vs-airdrop fork of [`MECHANISM_DESIGN.md` §9 item 14](design/MECHANISM_DESIGN.md#9-consolidated-open-decision-list) in favor of the fair-launch validator/active-participant distribution now recorded in §9-8. Abraham confirmed Aug 5, 2026 (Slack, 5:12 PM): testnet dry-run, move fast — local anvil → devnet → testnet → mainnet path. Phase set to P4 (Umer, Aug 5, 2026): eligibility computed off the on-chain indexer (P4-IDX1/P4-IDX2, not a raw RPC-log script) rather than directly off `DinValidatorStake`/`DINTaskAuditor`/`DINTaskCoordinator` events, run by the treasury-admin authority once the indexer exists. Contract-only slice assigned to Robbert: [`task_050826_9`](tasks/task_050826_9.md) (`feat/fair-launch-distributor`, off `develop` HEAD, no other-PR contract dependency identified) — tracked via [issue #75](https://github.com/InfiniteZeroFoundation/DevNet/issues/75) and [discussion #76](https://github.com/InfiniteZeroFoundation/DevNet/discussions/76). Eligibility-computation slice deferred pending P4-IDX1/IDX2. | P4 | Abraham Slack message, Jul 31, 2026 (`HR/Abraham/questions-310726.md`); reply drafted, Aug 5, 2026 (`HR/Umer/reply-abraham-050826-310726.md`); Abraham's phase/network answer + Umer's P4/indexer scoping, Aug 5, 2026 (`HR/Abraham/discuss-040826.md`) | ⚡ In Progress — contract-only slice merged as [PR #77](https://github.com/InfiniteZeroFoundation/DevNet/pull/77) (2026-09-10, `develop`); eligibility-computation slice still blocked on the indexer (issue #24 / PR #29, unmerged) | diff --git a/Developer/design/slashing-taxonomy.md b/Developer/design/slashing-taxonomy.md index 64b79c3e..cddd51cb 100644 --- a/Developer/design/slashing-taxonomy.md +++ b/Developer/design/slashing-taxonomy.md @@ -37,6 +37,7 @@ Also confirmed: both existing slash call sites (`DINTaskAuditor.slashAuditors`, - **Hook point:** built this round. `DINTaskCoordinator`'s new dispute-resolution scaffold (Part 4c) — a validator opens a dispute against a specific finalized T1 or T2 batch within a dispute window, posting a bond; if upheld (by owner/DAO adjudication for now — no on-chain recomputation of model weights is feasible in this contract, see note below), the batch is marked disputed and a fresh aggregator subgroup (excluding the accused and the original batch's aggregators) is assigned to redo it. Bond forfeiture/bounty payout is stubbed behind a treasury-shaped interface, matching Part 3's `treasuryAccrued` pattern — no real fund movement, since `DinTreasury` doesn't exist on `develop` yet. - **Why "off-chain challenge + on-chain dispute" rather than on-chain recomputation:** the CID being disputed points to aggregated model weights on IPFS; verifying "does this CID actually equal the correct aggregation of these inputs" requires re-running the aggregation function over potentially large tensors, which is not something a Solidity contract can or should do. The chain's job is bond custody, window enforcement, and re-assignment bookkeeping — the actual dispute adjudication (was the CID wrong?) happens off-chain today (by the owner, in this scaffold) and is a natural target for a future on-chain fraud-proof or committee-vote mechanism. Flagged, not solved, here. - **Gap:** adjudication is `onlyOwner` in the scaffold, i.e. centralized. That's explicit and intentional for this stretch scope — MECHANISM_DESIGN's dispute section doesn't specify the adjudicator, and building a fair on-chain jury mechanism is well beyond a stretch-scope scaffold. +- **One-shot constraint:** exactly one dispute per batch is possible, ever. `openDispute` checks `challenger != address(0)` and reverts `TC_DisputeAlreadyOpen`; the challenger field is never cleared, so a resolved dispute also blocks reopening with the same error. Documented because the consequence goes beyond developer surprise: a colluder can open a weak dispute on their own batch early, accept the forfeited bond, and leave the batch immune to a better-evidenced challenge for the rest of the window. If multi-dispute-per-batch is ever needed, the fix is to replace the boolean `resolved` flag with a counter or re-openable state enum. Revisit as part of [issue #181](https://github.com/InfiniteZeroFoundation/DevNet/issues/181) (decentralised adjudication). ## S5 — Repeated liveness failures (recidivism) diff --git a/Documentation/technical/contracts/DINShared.md b/Documentation/technical/contracts/DINShared.md index 474712c8..55e74c15 100644 --- a/Documentation/technical/contracts/DINShared.md +++ b/Documentation/technical/contracts/DINShared.md @@ -346,7 +346,7 @@ Used by: `DINTaskCoordinator` | `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_DisputeAlreadyOpen` | A dispute is open or has already been resolved for the batch (the challenger field is never cleared, so a resolved dispute also fires this error on a second `openDispute` attempt) | | `TC_DisputeNotOpen` | `resolveDispute` or `lockDisputeSeed` with no dispute | | `TC_DisputeAlreadyResolved` | Second `resolveDispute`, or `lockDisputeSeed` after resolution | | `TC_NoBondClaimable` | `claimDisputeBond` with nothing to claim | diff --git a/Documentation/technical/contracts/DINTaskCoordinator.md b/Documentation/technical/contracts/DINTaskCoordinator.md index 1b05b7ca..65487cf4 100644 --- a/Documentation/technical/contracts/DINTaskCoordinator.md +++ b/Documentation/technical/contracts/DINTaskCoordinator.md @@ -231,6 +231,8 @@ A forfeited bond is split 50% burned / 50% forwarded to `dinvalidatorStakeContra The fresh subgroup is bookkeeping only: nothing re-opens the state machine for it to submit a recomputation on-chain (§10 No. 3). +**One dispute per batch, ever.** `openDispute` checks `disputes[gi][tierKind][batchId].challenger != address(0)` and reverts `TC_DisputeAlreadyOpen`. The challenger field is set when the dispute opens and never cleared, so this check blocks reopening after resolution just as it blocks a second open while the first is pending — both produce `TC_DisputeAlreadyOpen`. Consequence: a colluder can open a weak dispute on their own batch early, accept the forfeited bond, and leave the batch immune to a better-evidenced challenge for the rest of the window. Revisit if multi-dispute-per-batch is ever desired; see [issue #181](https://github.com/InfiniteZeroFoundation/DevNet/issues/181) (decentralised adjudication). + --- ## 8. Events diff --git a/foundry/src/DINTaskCoordinator.sol b/foundry/src/DINTaskCoordinator.sol index d3675535..64ef34b6 100644 --- a/foundry/src/DINTaskCoordinator.sol +++ b/foundry/src/DINTaskCoordinator.sol @@ -1390,6 +1390,12 @@ contract DINTaskCoordinator is Ownable, ReentrancyGuardTransient { if (block.timestamp > finalizedAt + disputeWindow) { revert TC_DisputeWindowClosed(); } + // One dispute per batch, ever. The challenger field is set on open and + // never cleared, so this check also blocks reopening after resolution. + // Consequence: a colluder can squat a batch early, accept bond forfeiture + // when the owner rejects the dispute, and leave the batch immune to a + // better-evidenced challenge for the rest of the window. Revisit if + // multi-dispute-per-batch is needed — see issue #181 (decentralised adjudication). if (disputes[_GI][tierKind][batchId].challenger != address(0)) { revert TC_DisputeAlreadyOpen(); } @@ -1532,6 +1538,7 @@ contract DINTaskCoordinator is Ownable, ReentrancyGuardTransient { /// @param tierKind Whether the batch is a Tier-1 or Tier-2 batch. /// @param batchId Index of the disputed batch within its tier. /// @param upheld True if the dispute is found valid. + // One dispute per batch per GI: once resolved (upheld or rejected) the batch cannot be re-disputed. function resolveDispute( uint _GI, TierKind tierKind, diff --git a/foundry/test/DisputeResolution.t.sol b/foundry/test/DisputeResolution.t.sol index 7af9d894..97efb264 100644 --- a/foundry/test/DisputeResolution.t.sol +++ b/foundry/test/DisputeResolution.t.sol @@ -529,6 +529,52 @@ contract DisputeResolutionTest is Test { tc.openDispute(1, DINTaskCoordinator.TierKind.Tier1, 0); } + // Regression: the one-shot rule applies after resolution, not only while a + // dispute is open. test_openDispute_revertsIfAlreadyOpen covers the + // still-open case; these two cover both upheld=false and upheld=true paths. + + function test_openDispute_revertsAfterRejectedResolution() public { + _runToT1Finalized(3); + _fundAndStake(challenger); + _fundDinBalance(challenger, 1_000 ether); + + vm.prank(challenger); + tc.openDispute(1, DINTaskCoordinator.TierKind.Tier1, 0); + + vm.prank(modelOwner); + tc.resolveDispute(1, DINTaskCoordinator.TierKind.Tier1, 0, false); + + address secondChallenger = makeAddr("secondChallenger"); + _fundAndStake(secondChallenger); + _fundDinBalance(secondChallenger, 1_000 ether); + + vm.prank(secondChallenger); + vm.expectRevert(Shared.TC_DisputeAlreadyOpen.selector); + tc.openDispute(1, DINTaskCoordinator.TierKind.Tier1, 0); + } + + function test_openDispute_revertsAfterUpheldResolution() public { + _runToT1Finalized(6); // 3 in the disputed batch + 3 for the fresh subgroup + _fundAndStake(challenger); + _fundDinBalance(challenger, 1_000 ether); + + vm.prank(challenger); + tc.openDispute(1, DINTaskCoordinator.TierKind.Tier1, 0); + + _lockSeed(1, DINTaskCoordinator.TierKind.Tier1, 0); + + vm.prank(modelOwner); + tc.resolveDispute(1, DINTaskCoordinator.TierKind.Tier1, 0, true); + + address secondChallenger = makeAddr("secondChallenger"); + _fundAndStake(secondChallenger); + _fundDinBalance(secondChallenger, 1_000 ether); + + vm.prank(secondChallenger); + vm.expectRevert(Shared.TC_DisputeAlreadyOpen.selector); + tc.openDispute(1, DINTaskCoordinator.TierKind.Tier1, 0); + } + // ───────────────────────────────────────────────────────────────────── // resolveDispute // ───────────────────────────────────────────────────────────────────── From a09e0d8ea0ce8e9237ce2dfefdac3c062c8e9aa6 Mon Sep 17 00:00:00 2001 From: umeradl Date: Sun, 18 Oct 2026 09:37:18 +0500 Subject: [PATCH 14/48] fix(dincli): submit-genesis-model score-only re-run exits early when there is no score (PR 257 review) A re-run in GenesisModelCreated with no --score, on a manifest that does not require test-dataset scoring, used to ask "submit this tier 2 score?" and then exit 0 without sending anything. It now says there is no score to submit (pass --score to set one) and exits before the prompt. Adds a test for that path. Co-Authored-By: umeradl Co-Authored-By: Claude Opus 5.5 --- dincli/cli/modelownerd/model.py | 3 +++ tests/test_submit_genesis_model.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/dincli/cli/modelownerd/model.py b/dincli/cli/modelownerd/model.py index 7810e874..acf86e70 100644 --- a/dincli/cli/modelownerd/model.py +++ b/dincli/cli/modelownerd/model.py @@ -289,6 +289,9 @@ def submit_genesis_model( if score_only_rerun: + if accuracy is None: + console.print("[yellow]Genesis model hash is already on-chain and there is no tier 2 score to submit (manifest doesn't require test-dataset scoring). Pass --score to set one.[/yellow]") + raise typer.Exit(0) _confirm_or_exit("\n Do you want to submit this tier 2 score to the blockchain? \n ", "You chose not to submit the tier 2 score to the blockchain.", console) else: _confirm_or_exit("\n Do you want to submit this genesis model to the blockchain? \n ", "You chose not to submit the genesis model to the blockchain.", console) diff --git a/tests/test_submit_genesis_model.py b/tests/test_submit_genesis_model.py index 72385d0b..5bf7dfd5 100644 --- a/tests/test_submit_genesis_model.py +++ b/tests/test_submit_genesis_model.py @@ -1,4 +1,5 @@ import hashlib +from pathlib import Path from types import SimpleNamespace import pytest @@ -89,6 +90,9 @@ def get_deployed_din_task_coordinator_contract(self, verbose=True, model_id=None def get_current_gi_and_state(self, contract, verbose_gi=True, verbose_state=False, verbose_state_name=False): return contract.gi, contract.gi_state + def get_task_dir(self, taskCoordinator_address): + return Path("/nonexistent-task-dir") + def make_ctx(contract): return SimpleNamespace(obj=DummyContextObj(contract)) @@ -198,3 +202,27 @@ def test_unrelated_gi_zero_state_exits_without_sending_any_tx(): assert contract.set_genesis_calls == [] assert contract.set_score_calls == [] + + +def test_rerun_without_score_exits_cleanly_without_sending_any_tx(monkeypatch): + """Score-only re-run where the manifest doesn't require test-dataset scoring and no --score is given: nothing to submit.""" + monkeypatch.setattr(model, "get_manifest", lambda *args, **kwargs: {}) + contract = DummyTaskCoordinatorContract( + gi=0, + gi_state=GIstatestrToIndex("GenesisModelCreated"), + onchain_hash=hash_bytes32(CID_A), + ) + ctx = make_ctx(contract) + + with pytest.raises(typer.Exit) as exc_info: + model.submit_genesis_model( + ctx, + ipfs_hash=CID_A, + task_coordinator_address="0xTaskCoordinator", + score=None, + help=False, + ) + + assert exc_info.value.exit_code == 0 + assert contract.set_genesis_calls == [] + assert contract.set_score_calls == [] From 5f610c79063eef622acbe3a0997930a5d5204c14 Mon Sep 17 00:00:00 2001 From: umeradl Date: Mon, 19 Oct 2026 17:04:08 +0500 Subject: [PATCH 15/48] docs: widen partner-checklist slashing wording to cover consensus/deviation slashes (PR 262 review) "for certain missed duties" left out the full-minStake slashes for results that disagree with the agreed outcome (AGG_T1/T2_BAD_CONSENSUS, and AUD_SCORE_DEVIATION when S3 is enabled), understating validator risk. Co-Authored-By: umeradl Co-Authored-By: Claude Opus 5.5 --- Developer/partner-outreach-checklist.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Developer/partner-outreach-checklist.md b/Developer/partner-outreach-checklist.md index 811e95a5..4f5d16d7 100644 --- a/Developer/partner-outreach-checklist.md +++ b/Developer/partner-outreach-checklist.md @@ -8,7 +8,7 @@ This checklist helps us start useful conversations with potential DIN partners a - Check with the technical onboarding lead what the partner can try today, which network and version to use, and which guide to share. - Start with a trial small enough for the team to support. -Be clear about what is still being decided. Participation does not guarantee income or a future token allocation. Validators can lose staked test tokens for certain missed duties; confirm the rules for their trial before they stake. Testnet balances, stakes, models, and participation records should be treated as temporary. +Be clear about what is still being decided. Participation does not guarantee income or a future token allocation. Validators can lose staked test tokens for certain missed duties or for results that differ from the agreed outcome; confirm the rules for their trial before they stake. Testnet balances, stakes, models, and participation records should be treated as temporary. If a question needs an answer we do not have yet, note it and arrange a follow-up. From d7ec370cc617daabd34e29a1b205989f47e6f11c Mon Sep 17 00:00:00 2001 From: umermjd11 Date: Wed, 21 Oct 2026 20:30:19 +0500 Subject: [PATCH 16/48] docs: add CODEOWNERS and Developer/MAINTAINING.md (Discussion 208, S2) Records the maintainer process agreed in Discussion 208: the peer rule, what the develop/main rulesets enforce, the reviewer pool and recusal (A1), review depth and CI-as-evidence (A2), red-develop handling (A3), CI backbone (A4), issue/task approval and closure, and what stays with the admin. CODEOWNERS only requests reviewers; code-owner review is not required by the ruleset. Co-Authored-By: Claude Opus 5.5 --- .github/CODEOWNERS | 37 ++++++++ Developer/CONTRIBUTING.md | 2 + Developer/MAINTAINING.md | 173 ++++++++++++++++++++++++++++++++++++++ Developer/README.md | 1 + 4 files changed, 213 insertions(+) create mode 100644 .github/CODEOWNERS create mode 100644 Developer/MAINTAINING.md diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..75b337e1 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,37 @@ +# Code owners for InfiniteZeroFoundation/DevNet. +# +# These entries only REQUEST reviewers automatically. They don't block merges: +# the develop ruleset does not require code-owner review, because Robbert and +# Similoluwa couldn't get each other's contract PRs merged otherwise. The real +# gate is 1 approval from someone other than the author. See +# Developer/MAINTAINING.md for the full process, including the second sign-off +# on foundry/src/** and when a reviewer should recuse. +# +# GitHub uses the LAST matching pattern for a path, so the catch-all comes first +# and the more specific areas come after it. GitHub ignores owners who don't +# have Write access to the repo. + +# Fallback for anything not matched below. +* @umeradl + +# Contracts: logic, storage layout, deploy and upgrade scripts, tests. +/foundry/ @robertocarlous @Abidoyesimze @umeradl +/hardhat/ @robertocarlous @Abidoyesimze @umeradl + +# Python CLI, tests, example model services, docker/IPFS ops, CI. +/dincli/ @Santiagocetran @umeradl +/tests/ @Santiagocetran @umeradl +/cache_model_0/ @Santiagocetran @umeradl +/cid_services/ @Santiagocetran @umeradl +/pyproject.toml @Santiagocetran @umeradl +/.github/ @Santiagocetran @umeradl + +# Docs and process. +/Documentation/ @Santiagocetran @robertocarlous +/Developer/ @Santiagocetran @robertocarlous + +# The maintainer process itself and the Claude skills that implement it. +/.github/CODEOWNERS @umeradl @Santiagocetran @robertocarlous +/Developer/MAINTAINING.md @umeradl @Santiagocetran @robertocarlous +/.claude/ @umeradl +/CLAUDE.md @umeradl diff --git a/Developer/CONTRIBUTING.md b/Developer/CONTRIBUTING.md index 72103436..8af2e691 100644 --- a/Developer/CONTRIBUTING.md +++ b/Developer/CONTRIBUTING.md @@ -17,6 +17,8 @@ pip install -e . Create a feature branch, commit your changes, and open a pull request to `develop`. +Every PR is reviewed and merged by someone other than its author. [MAINTAINING.md](MAINTAINING.md) describes how review, merging, and issue/task closure work. + ## A Note On The Developer Docs The materials in `/Developer` are working design input, not fixed specifications. They represent current thinking, not final decisions. diff --git a/Developer/MAINTAINING.md b/Developer/MAINTAINING.md new file mode 100644 index 00000000..e9fc2ef5 --- /dev/null +++ b/Developer/MAINTAINING.md @@ -0,0 +1,173 @@ +# Maintaining DevNet + +How maintainers review, merge, approve and close work on this repo. This file is the agreed outcome of [Discussion 208](https://github.com/InfiniteZeroFoundation/DevNet/discussions/208). If you want to change the process, propose it there (or in a new Discussion) and update this file through a PR. + +**Maintainers:** [@umeradl](https://github.com/umeradl) (admin), [@Santiagocetran](https://github.com/Santiagocetran), [@robertocarlous](https://github.com/robertocarlous) and [@Abidoyesimze](https://github.com/Abidoyesimze) (Similoluwa), all with Write access. [@abrahamnash](https://github.com/abrahamnash) signs off on mechanism and product decisions. + +## 1. The rule + +> **Nobody approves, merges or closes their own work. A peer does it.** + +| Activity | Author / assignee may do it? | Who does it | +|---|---|---| +| Review a PR (verification and merge-proposal comments) | ❌ | A peer who didn't write it | +| Approve a PR (GitHub "Approve") | ❌ (GitHub blocks this) | A peer | +| Merge a PR into `develop` | ❌ | The peer who reviewed it, or Umer | +| Approve an issue's plan (`Approved` label) | ❌ if you opened it or will implement it | A peer | +| Post a status update on an issue or task | ✅ progress notes are fine | Anyone. The *authoritative* done-vs-remaining check is the peer's | +| Close an issue as completed | ❌ if you implemented it | A peer | +| Close a task Discussion as Resolved | ❌ if it's assigned to you | A peer | +| Write a new task file | ✅ | Anyone, but **assigning** and **closing** it are done by someone else | +| Wiki edits | ✅ for small fixes | A peer reviews anything substantive ([No. 5.5](#55-wiki)) | + +Umer's own PRs (opened from the `umermjd11` fork) follow the same rule: one of the other maintainers approves and merges them. + +## 2. What GitHub enforces + +**`develop` ruleset:** + +- Changes land through a pull request. +- 1 approving review from someone other than the author. +- **The most recent push must be approved by someone other than the person who pushed it.** If a reviewer pushes fixes onto your PR, they can't approve it themselves any more. Someone else has to. +- Stale approvals are dismissed when new commits are pushed. +- The `CI OK` check must pass, and the branch must be **up to date with `develop`** before merging. +- Merge commits only. Squash and rebase merges are turned off, so authorship and history survive. +- No force-pushes, no deleting the branch. +- Only the repository admin role can bypass these rules ([No. 4.4](#44-merge-paths)). + +**`main` ruleset:** changes go through a PR; no force-pushes, no deleting the branch. `CI OK` isn't required on `main` yet, because [`ci.yml`](../.github/workflows/ci.yml) only runs on `develop`. It becomes required once CI runs on `main`. + +**[`.github/CODEOWNERS`](../.github/CODEOWNERS)** requests reviewers automatically by area. It only *requests*: code-owner review is not required, because that would deadlock (two contract owners couldn't merge each other's work if each was the other's only owner). The real gate is the 1-approval rule plus the peer rule. + +Everything else in this file is convention, not enforcement. + +## 3. Reviewer pool + +Tag reviewers by skillset: + +| Area | First choice | Also | +|---|---|---| +| `foundry/src/**`, `foundry/script/**`, contract tests | @robertocarlous and @Abidoyesimze review each other | Second sign-off: @umeradl or @abrahamnash ([No. 4.2](#42-review-depth)) | +| `dincli/**`, `tests/**`, docker/IPFS ops, CI (`.github/`) | @Santiagocetran | @umeradl | +| `Documentation/**`, `Developer/**` | @Santiagocetran, @robertocarlous | anyone in the pool | + +**Recusal is a valid outcome.** If you're tagged on something outside your depth, reply "out of my depth — @X" and don't approve. Nobody approves just to unblock a PR. A recusal or an escalation is better than a rubber stamp. + +## 4. PR review and merge + +### 4.1 Picking the reviewer + +CODEOWNERS requests reviewers when you open the PR. Check that at least one requested reviewer isn't you or a co-author, and add one by hand if not. The reviewer either takes the PR or replies within **2 working days** with "can't take this — @umeradl". Silence past 2 working days escalates to Umer. There's no fixed rotation. We'll revisit that if third-party PRs stall. + +### 4.2 Review depth + +- **Any PR touching `foundry/src/**`** (contract logic, storage layout, upgrade paths) needs the peer's review **plus a second sign-off** from @umeradl or @abrahamnash, or from a security reviewer they route it to. Mechanism changes (slashing, tokenomics, fees, scoring) also need @abrahamnash's sign-off. +- Docs-only, test-only, Python and tooling PRs need one peer. + +**What counts as evidence.** Required checks that are green against an up-to-date `develop` count as evidence for anything CI covers. The reviewer cites the CI run and doesn't re-run it. **Local reproduction is required only for what CI doesn't cover:** + +- storage layout and upgrade safety in `foundry/src/**` +- deploy and upgrade scripts in `foundry/script/**` +- `dincli` flows against a live chain or IPFS, until the integration job is a required check ([No. 4.9](#49-ci-as-the-backbone)) +- any claimed behaviour that no test exercises + +The author's part: a clear description of what changed and why, and tests for the change. The reviewer's part: implementation, design, edge cases and coverage, with a concise write-up of findings and any requested fixes. + +### 4.3 The review record + +The reviewer writes three comments on the PR: + +1. **Verification comment.** "Claimed / Verified" pairs for every checkable claim in the PR. A claim is verified by linking the CI job that covers it, or by running it locally where [No. 4.2](#42-review-depth) requires that. Never accept "done" or "landed" in a comment as proof. +2. **Merge-proposal table.** Per file: change, lines, diff, functionality (how and why), diff vs current `develop`, recommended merge proposal, actual merge proposal, pending proposal, local/GitHub conflict. "Actual merge proposal" is filled in by whoever performs the merge, not at review time. +3. **Outcome comment.** Posted after the merge lands, with the real merge SHA(s), the files that deviated from the PR, and final build/test numbers. + +Templates: [`.claude/skills/din-pr-review/templates/`](../.claude/skills/din-pr-review/templates/). Use them with or without Claude. + +**Trivial changes** (typo, dead link, comment fix) still need a PR and 1 approval, but a quick approval noting "trivial" replaces the three comments. + +### 4.4 Merge paths + +- **(A) GitHub-side merge commit — the default.** The reviewer approves, CI is green, the branch is up to date, and the reviewer merges with a merge commit. If the reviewer wants something changed first, they push the fix onto the PR branch (or open a small follow-up PR). The last-push rule then sends the final approval to someone else. +- **(B) Local merge plus follow-up fix commit — exceptional, Umer only.** For PRs that need adapting against a `develop` that has moved (renames, ABI regeneration, docs drift). It needs the admin bypass. + +**The admin bypass** is for emergencies and path B only. Each use gets a one-line reason in [Discussion 170](https://github.com/InfiniteZeroFoundation/DevNet/discussions/170), followed by a normal reviewed PR where possible. + +### 4.5 Attribution + +Merges keep the PR author as the commit author. Any deviation commit the merger writes credits the PR author with a `Co-Authored-By:` trailer. Never squash away someone's authorship. + +### 4.6 A red `develop` after a merge + +Whoever merged owns that push's CI run and makes sure it's fixed **the same working day**. The author is pinged first and fixes it if available. If they aren't, the merger fixes it on top or reverts it. [Discussion 170](https://github.com/InfiniteZeroFoundation/DevNet/discussions/170) stays the incident log, and its "~6h red plus commits on top" escalation rule applies to everyone. + +### 4.7 Reverts + +Any maintainer may revert a merge that breaks `develop` without waiting for the author. The revert goes through a PR and peer approval like anything else, unless Umer uses the emergency bypass. The author re-lands the change in a new PR. + +### 4.8 Labels + +`Reviewed` once comments 1–2 are posted. `Merged Locally`, `merged` or `Partially Merged` after the merge. `Hurry to Merge` / `No Hurry To Merge` for priority. `Draft`, `Deferred`. + +### 4.9 CI as the backbone + +The aim is for CI to carry repeatable verification so reviewers can focus on judgment. The isolated integration harness has landed ([PR 213](https://github.com/InfiniteZeroFoundation/DevNet/pull/213)); a non-blocking full-GI job is in review ([PR 242](https://github.com/InfiniteZeroFoundation/DevNet/pull/242)). The integration job becomes a required check once it has run a full GI green on `develop` for a week. Further CI stages (post-merge checks, a shared develop/testnet environment, validation on `develop` → `main` PRs) are tracked in their own issue. + +## 5. Other administrative work + +### 5.1 Issues: approval before implementation + +A new non-trivial issue gets a verdict comment (*Approved / Approved with amendments / Changes requested / Not approved*) before anyone writes code. The verdict checks every factual claim (`file:line` references, "function X doesn't exist", "the ABI already has Y") against `origin/develop`. On approval, add the `Approved` label. **The approver can't be the issue's author or its intended implementer.** Mechanism-design issues (slashing, tokenomics, fees, scoring) also need @umeradl's or @abrahamnash's sign-off, because they're product decisions. + +### 5.2 Issues: status updates and closing + +Anyone may post progress. Closing as **completed** needs a peer to check every proposed change, every approval amendment, every verification item and every linked PR against `origin/develop`, then post a closing comment listing what landed and where (SHAs). If anything remains, post a "done vs. remaining" status comment and leave the issue open. Closing as **not planned** or **duplicate**: anyone may do it, with a one-line reason and a link to the duplicate. + +### 5.3 Tasks + +Tasks are `Developer/tasks/task_DDMMYY_N.md` files, each with a Tasks-category Discussion. + +- **Writing:** anyone may draft one, following the existing header format (ID / Assigned to / Created / Status / Roadmap ref / Dates). Task files land on `develop` through a PR, so they get peer review too. +- **Assigning:** nobody assigns themselves without Umer or the peer agreeing in the Discussion. Anything involving compensation or a new contributor stays with Umer and Abraham. +- **Status updates:** the assignee posts progress in the task Discussion. The peer posts the authoritative "verified done vs. remaining" check. +- **Closing:** a peer verifies every deliverable in the task file's checklist and every PR it names against `develop`, closes the Discussion as Resolved with a closing comment, and updates the task file's `Status:` line in a follow-up PR. A task can't close while a PR it depends on is still open or in draft. + +### 5.4 Docs syncing on merge + +Whoever merges a PR checks that: + +- `Documentation/` still describes `develop`; +- bundled ABIs in `dincli/abis/` were regenerated if a contract interface changed (see [CONTRIBUTING.md](CONTRIBUTING.md#submitting-prs)); +- `Developer/BACK_LOG.md` and `Developer/ROADMAP.md` entries the PR resolves are updated, or a follow-up issue is filed. + +### 5.5 Wiki + +The wiki has no PR mechanism, so: + +- Typos, broken links, and syncing a page to something already merged on `develop`: just edit it. +- New pages, restructuring `_Sidebar.md`, or anything describing a mechanism: post the draft (or a diff) as a comment in a Discussion and get a peer 👍 before publishing. +- Conventions: links into the repo use `blob/develop/...`; planned components carry the 📋 Planned banner; no assignee names or week-level dates on public pages; pages end with "Further reading" links into `Documentation/`. + +### 5.6 Discussions + +Anyone may answer, label, or lock stale threads. Closing a design or Ideas Discussion as decided needs a comment that records the decision and where it was implemented. Mechanism decisions are recorded by Umer or Abraham. + +## 6. What stays with Umer + +- Pushing to or releasing on `main`. +- Anything on Sepolia or other live networks: platform deploys, upgrades, DIN-Representative keys and actions, slasher authorisations. +- Repo settings, rulesets, secrets and access changes. +- Contributor onboarding, compensation, and HR-side task approval (with Abraham). +- Roadmap priority calls and final mechanism-design decisions (with Abraham). +- The emergency bypass on `develop` ([No. 4.4](#44-merge-paths)). + +## 7. Edge cases + +- **Co-authored PRs** (two maintainers both committed to it): neither of them may approve it. It goes to a third maintainer or Umer. +- **The reviewer pushes substantial fixes onto a PR:** they've become a co-author. The original author, another maintainer or Umer gives the final approval, and the reviewer doesn't merge their own fixes unreviewed. +- **Disagreement between reviewer and author:** discuss it on the PR. If you're not converging after a round or two, tag Umer, who decides. Nobody merges over an unresolved "Changes requested". +- **Urgent fix while the peer is offline** (for example a red `develop` blocking everyone): open the PR and tag both the peer and Umer. Whoever answers first approves it. Never merge unreviewed. +- **PRs from other contributors** (external forks, new contributors): any maintainer who didn't co-author them may review and merge them. The `foundry/src/**` second sign-off still applies. + +## 8. Tooling + +The `din-*` Claude Code skills under [`.claude/skills/`](../.claude/skills/) implement this workflow (`din-pr-review`, `merge-fix-comment-update`, `din-merge-build-test`, `din-pr-merge`, `din-push-develop`, `din-approve-issue`, `din-close-issue`, `din-task-close`). Some of them still assume they run as `umeradl`. A follow-up PR generalises them: trailers taken from the active `gh` user, and a self-check that refuses to approve, merge or close when that user is the author, assignee or a co-author. Until it lands, follow this file and the templates by hand. diff --git a/Developer/README.md b/Developer/README.md index ed0c9895..f86f7869 100644 --- a/Developer/README.md +++ b/Developer/README.md @@ -19,6 +19,7 @@ Forward-looking material for people building the DIN Protocol: plans, designs, p | [`discussion/`](discussion/) | Open discussions (Filecoin support, Foundry migration) | | [`rejected-ideas/`](rejected-ideas/) | Ideas evaluated and rejected, with rationale (e.g. TKNN-Shapley) | | [CONTRIBUTING.md](CONTRIBUTING.md) | Contribution process and code standards pointers | +| [MAINTAINING.md](MAINTAINING.md) | Maintainer process: peer review, merging, issue/task approval and closure, what GitHub enforces | | [partner-outreach-checklist.md](partner-outreach-checklist.md) | Conversation checklist for potential DIN partners, with an open question on shared outreach tracking | | [CODE_STANDARDS.md](CODE_STANDARDS.md) | Code style and quality expectations | | [DEVELOPMENT_SETUP.md](DEVELOPMENT_SETUP.md) | Setting up a development environment | From 04963c915e7a4bdfb01e4b9fc3a486f5bb69140e Mon Sep 17 00:00:00 2001 From: umermjd11 Date: Wed, 21 Oct 2026 22:34:11 +0500 Subject: [PATCH 17/48] docs(MAINTAINING): final approval after reviewer fixes excludes all fix contributors; clarify mechanism sign-off (PR 268 review) - No. 7: after a reviewer pushes substantial fixes, the final approval comes from an eligible reviewer who is neither the PR author nor a contributor to those fixes. - No. 4.2 / No. 5.1: mechanism-design issues and mechanism-changing PRs need @umeradl's approval on GitHub. Abraham may not reply on GitHub; off-GitHub outcomes are summarised in a comment cc'ing @abrahamnash and @umeradl. Co-Authored-By: Claude Opus 5.5 --- Developer/MAINTAINING.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/Developer/MAINTAINING.md b/Developer/MAINTAINING.md index e9fc2ef5..67dc3721 100644 --- a/Developer/MAINTAINING.md +++ b/Developer/MAINTAINING.md @@ -2,7 +2,7 @@ How maintainers review, merge, approve and close work on this repo. This file is the agreed outcome of [Discussion 208](https://github.com/InfiniteZeroFoundation/DevNet/discussions/208). If you want to change the process, propose it there (or in a new Discussion) and update this file through a PR. -**Maintainers:** [@umeradl](https://github.com/umeradl) (admin), [@Santiagocetran](https://github.com/Santiagocetran), [@robertocarlous](https://github.com/robertocarlous) and [@Abidoyesimze](https://github.com/Abidoyesimze) (Similoluwa), all with Write access. [@abrahamnash](https://github.com/abrahamnash) signs off on mechanism and product decisions. +**Maintainers:** [@umeradl](https://github.com/umeradl) (admin), [@Santiagocetran](https://github.com/Santiagocetran), [@robertocarlous](https://github.com/robertocarlous) and [@Abidoyesimze](https://github.com/Abidoyesimze) (Similoluwa), all with Write access. [@abrahamnash](https://github.com/abrahamnash) decides mechanism and product questions with Umer, often off GitHub ([mechanism sign-off](#mechanism-sign-off)). ## 1. The rule @@ -61,9 +61,13 @@ CODEOWNERS requests reviewers when you open the PR. Check that at least one requ ### 4.2 Review depth -- **Any PR touching `foundry/src/**`** (contract logic, storage layout, upgrade paths) needs the peer's review **plus a second sign-off** from @umeradl or @abrahamnash, or from a security reviewer they route it to. Mechanism changes (slashing, tokenomics, fees, scoring) also need @abrahamnash's sign-off. +- **Any PR touching `foundry/src/**`** (contract logic, storage layout, upgrade paths) needs the peer's review **plus a second sign-off** from @umeradl or @abrahamnash, or from a security reviewer they route it to. Mechanism changes also need the [mechanism sign-off](#mechanism-sign-off) below. - Docs-only, test-only, Python and tooling PRs need one peer. +#### Mechanism sign-off + +Mechanism-design work (slashing, tokenomics, fees, scoring) needs **@umeradl's approval on GitHub**. That applies to the issue's plan ([No. 5.1](#51-issues-approval-before-implementation)) and to every PR that changes a mechanism. @abrahamnash may not reply on GitHub directly, so his sign-off isn't something a PR waits for there. Mechanisms are often discussed with Abraham off GitHub. If you (author or contributor) had such a conversation, summarise its outcome in a comment on the issue or PR and cc @abrahamnash and @umeradl. + **What counts as evidence.** Required checks that are green against an up-to-date `develop` count as evidence for anything CI covers. The reviewer cites the CI run and doesn't re-run it. **Local reproduction is required only for what CI doesn't cover:** - storage layout and upgrade safety in `foundry/src/**` @@ -116,7 +120,7 @@ The aim is for CI to carry repeatable verification so reviewers can focus on jud ### 5.1 Issues: approval before implementation -A new non-trivial issue gets a verdict comment (*Approved / Approved with amendments / Changes requested / Not approved*) before anyone writes code. The verdict checks every factual claim (`file:line` references, "function X doesn't exist", "the ABI already has Y") against `origin/develop`. On approval, add the `Approved` label. **The approver can't be the issue's author or its intended implementer.** Mechanism-design issues (slashing, tokenomics, fees, scoring) also need @umeradl's or @abrahamnash's sign-off, because they're product decisions. +A new non-trivial issue gets a verdict comment (*Approved / Approved with amendments / Changes requested / Not approved*) before anyone writes code. The verdict checks every factual claim (`file:line` references, "function X doesn't exist", "the ABI already has Y") against `origin/develop`. On approval, add the `Approved` label. **The approver can't be the issue's author or its intended implementer.** Mechanism-design issues (slashing, tokenomics, fees, scoring) also need @umeradl's approval, because they're product decisions ([mechanism sign-off](#mechanism-sign-off)). ### 5.2 Issues: status updates and closing @@ -163,7 +167,7 @@ Anyone may answer, label, or lock stale threads. Closing a design or Ideas Discu ## 7. Edge cases - **Co-authored PRs** (two maintainers both committed to it): neither of them may approve it. It goes to a third maintainer or Umer. -- **The reviewer pushes substantial fixes onto a PR:** they've become a co-author. The original author, another maintainer or Umer gives the final approval, and the reviewer doesn't merge their own fixes unreviewed. +- **The reviewer pushes substantial fixes onto a PR:** they've become a co-author. The final approval comes from an eligible reviewer who is neither the PR author nor anyone who contributed to those fixes: another maintainer, or Umer. The reviewer doesn't merge their own fixes unreviewed. - **Disagreement between reviewer and author:** discuss it on the PR. If you're not converging after a round or two, tag Umer, who decides. Nobody merges over an unresolved "Changes requested". - **Urgent fix while the peer is offline** (for example a red `develop` blocking everyone): open the PR and tag both the peer and Umer. Whoever answers first approves it. Never merge unreviewed. - **PRs from other contributors** (external forks, new contributors): any maintainer who didn't co-author them may review and merge them. The `foundry/src/**` second sign-off still applies. From 20a52a9340960f3a3d074131c2fcf91b91aaacb5 Mon Sep 17 00:00:00 2001 From: umermjd11 Date: Wed, 21 Oct 2026 22:35:57 +0500 Subject: [PATCH 18/48] docs(MAINTAINING): Abraham's recorded sign-off covers Umer's own mechanism PRs umeradl approving a umermjd11 PR would be self-approval (No. 1), so for Umer's own mechanism PRs the sign-off comes from @abrahamnash, on or off GitHub, recorded in a PR comment. Peer approval is still required. Also move the "Mechanism sign-off" subsection to the end of No. 4.2 so the "What counts as evidence" paragraph stays under review depth. Co-Authored-By: Claude Opus 5.5 --- Developer/MAINTAINING.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/Developer/MAINTAINING.md b/Developer/MAINTAINING.md index 67dc3721..86327a0f 100644 --- a/Developer/MAINTAINING.md +++ b/Developer/MAINTAINING.md @@ -64,10 +64,6 @@ CODEOWNERS requests reviewers when you open the PR. Check that at least one requ - **Any PR touching `foundry/src/**`** (contract logic, storage layout, upgrade paths) needs the peer's review **plus a second sign-off** from @umeradl or @abrahamnash, or from a security reviewer they route it to. Mechanism changes also need the [mechanism sign-off](#mechanism-sign-off) below. - Docs-only, test-only, Python and tooling PRs need one peer. -#### Mechanism sign-off - -Mechanism-design work (slashing, tokenomics, fees, scoring) needs **@umeradl's approval on GitHub**. That applies to the issue's plan ([No. 5.1](#51-issues-approval-before-implementation)) and to every PR that changes a mechanism. @abrahamnash may not reply on GitHub directly, so his sign-off isn't something a PR waits for there. Mechanisms are often discussed with Abraham off GitHub. If you (author or contributor) had such a conversation, summarise its outcome in a comment on the issue or PR and cc @abrahamnash and @umeradl. - **What counts as evidence.** Required checks that are green against an up-to-date `develop` count as evidence for anything CI covers. The reviewer cites the CI run and doesn't re-run it. **Local reproduction is required only for what CI doesn't cover:** - storage layout and upgrade safety in `foundry/src/**` @@ -77,6 +73,12 @@ Mechanism-design work (slashing, tokenomics, fees, scoring) needs **@umeradl's a The author's part: a clear description of what changed and why, and tests for the change. The reviewer's part: implementation, design, edge cases and coverage, with a concise write-up of findings and any requested fixes. +#### Mechanism sign-off + +Mechanism-design work (slashing, tokenomics, fees, scoring) needs **@umeradl's approval on GitHub**. That applies to the issue's plan ([No. 5.1](#51-issues-approval-before-implementation)) and to every PR that changes a mechanism. @abrahamnash may not reply on GitHub directly, so his sign-off isn't something a PR waits for there. Mechanisms are often discussed with Abraham off GitHub. If you (author or contributor) had such a conversation, summarise its outcome in a comment on the issue or PR and cc @abrahamnash and @umeradl. + +**Umer's own mechanism PRs** (from `umermjd11`): `umeradl` can't approve them, because that would be approving his own work ([No. 1](#1-the-rule)). For these PRs, the mechanism sign-off comes from @abrahamnash instead, whether he gives it on GitHub or off it. If he gives it off GitHub, it's recorded in a comment on the PR that cc's @abrahamnash. The PR still needs a peer's approval as usual. + ### 4.3 The review record The reviewer writes three comments on the PR: From cfbfaccc719d6505795d0607c019b47a812f9168 Mon Sep 17 00:00:00 2001 From: Santiagocetran Date: Wed, 21 Oct 2026 17:36:47 +0000 Subject: [PATCH 19/48] fix(dincli): omit transaction links when explorer is absent --- dincli/cli/utils.py | 13 ++-- dincli/config/din_info.json | 1 - tests/test_tx_info.py | 120 ++++++++++++++++++++++++++++++++++++ 3 files changed, 129 insertions(+), 5 deletions(-) create mode 100644 tests/test_tx_info.py diff --git a/dincli/cli/utils.py b/dincli/cli/utils.py index a09d4df7..8f9b51ea 100644 --- a/dincli/cli/utils.py +++ b/dincli/cli/utils.py @@ -1143,17 +1143,22 @@ def lock_batch_seed_if_pending(ctx, task_coordinator_contract, gi: int, curr_gi: def print_tx_info(tx_hash, network=None, print_url = True): + """Print the transaction hash and its URL when an explorer is configured.""" #ensure tx_hash is hex string if isinstance(tx_hash, bytes): tx_hash_hex = tx_hash.hex() else: tx_hash_hex = tx_hash - #print tx url console.print(f"[bold green]Transaction hash:[/bold green] {tx_hash_hex}") - if print_url: - din_info = load_din_info() - console.print(f"[bold green]Transaction url:[/bold green] [cyan]{din_info[network]['explorer']}/tx/{tx_hash_hex}[/cyan]") + if not print_url: + return + + explorer = load_din_info().get(network, {}).get("explorer") + if not explorer: + return + + console.print(f"[bold green]Transaction url:[/bold green] [cyan]{explorer.rstrip('/')}/tx/{tx_hash_hex}[/cyan]") def _confirm_or_exit(question: str, instruction: str, console): answer = console.input(f"[bold yellow]{question} (y/n):[/bold yellow] ").strip().lower() diff --git a/dincli/config/din_info.json b/dincli/config/din_info.json index 16618503..5ea22dfd 100644 --- a/dincli/config/din_info.json +++ b/dincli/config/din_info.json @@ -6,7 +6,6 @@ "stake": "0xA51c1fc2f0D1a1b8494Ed1FE312d7C3a78Ed91C0", "representative": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", "registry": "0x959922bE3CAee4b8Cd9a407cc3ac1C251C2007B1", - "explorer": "https://sepolia-optimism.etherscan.io", "default_manifest": "bafybeiacrjxubt2aj2dhegbb6yzsbjm4jrnt2bsd2hbep3vy4uri2xtvg4", "default_services": { "model.py": "bafybeieoovd7dofejqvc6i5nxcpz5r4ux7o7oakpo2h3gxv4657otpxfpi", diff --git a/tests/test_tx_info.py b/tests/test_tx_info.py new file mode 100644 index 00000000..57140f8a --- /dev/null +++ b/tests/test_tx_info.py @@ -0,0 +1,120 @@ +"""Transaction display and receipt handling with optional explorer metadata.""" +from io import StringIO +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest +from hexbytes import HexBytes +from rich.console import Console + +from dincli.cli import utils + + +TX_HASH_STRING = "0x" + "AbC123ef" * 8 +TX_HASH_BYTES = bytes.fromhex(TX_HASH_STRING[2:]) +TX_HASH_HEXBYTES = HexBytes(TX_HASH_BYTES) + + +@pytest.fixture +def captured_console(monkeypatch): + output = StringIO() + console = Console(file=output, width=240, color_system=None, force_terminal=False) + monkeypatch.setattr(utils, "console", console) + return console, output + + +@pytest.mark.parametrize( + ("network", "explorer"), + [("local", None), ("sepolia_op_devnet", "https://sepolia-optimism.etherscan.io")], +) +def test_bundled_explorer_metadata(network, explorer, captured_console): + _, output = captured_console + config = utils.load_din_info() + if explorer is None: + assert "explorer" not in config[network] + else: + assert config[network]["explorer"] == explorer + + utils.print_tx_info(TX_HASH_STRING, network) + + expected = f"Transaction hash: {TX_HASH_STRING}\n" + if explorer: + expected += f"Transaction url: {explorer}/tx/{TX_HASH_STRING}\n" + assert output.getvalue() == expected + + +@pytest.mark.parametrize("metadata", [{}, {"explorer": ""}, {"explorer": None}]) +def test_absent_explorer_prints_hash_only(metadata, monkeypatch, captured_console): + monkeypatch.setattr(utils, "load_din_info", lambda: {"local": metadata}) + utils.print_tx_info(TX_HASH_STRING, "local") + assert captured_console[1].getvalue() == f"Transaction hash: {TX_HASH_STRING}\n" + + +@pytest.mark.parametrize("network", ["unknown", None]) +def test_missing_network_prints_hash_only(network, monkeypatch, captured_console): + monkeypatch.setattr(utils, "load_din_info", lambda: {"local": {}}) + if network is None: + utils.print_tx_info(TX_HASH_STRING) + else: + utils.print_tx_info(TX_HASH_STRING, network) + assert captured_console[1].getvalue() == f"Transaction hash: {TX_HASH_STRING}\n" + + +def test_suppressing_url_does_not_load_metadata(monkeypatch, captured_console): + loader = Mock(side_effect=AssertionError("metadata should not be loaded")) + monkeypatch.setattr(utils, "load_din_info", loader) + utils.print_tx_info(TX_HASH_STRING, "local", print_url=False) + loader.assert_not_called() + assert captured_console[1].getvalue() == f"Transaction hash: {TX_HASH_STRING}\n" + + +@pytest.mark.parametrize("explorer", ["http://localhost:4000", "http://localhost:4000/"]) +@pytest.mark.parametrize("tx_hash", [TX_HASH_STRING, TX_HASH_BYTES, TX_HASH_HEXBYTES]) +def test_custom_local_explorer_preserves_hash_format( + explorer, tx_hash, monkeypatch, captured_console +): + monkeypatch.setattr(utils, "load_din_info", lambda: {"local": {"explorer": explorer}}) + utils.print_tx_info(tx_hash, "local") + expected_hash = tx_hash.hex() if isinstance(tx_hash, bytes) else tx_hash + assert captured_console[1].getvalue() == ( + f"Transaction hash: {expected_hash}\n" + f"Transaction url: http://localhost:4000/tx/{expected_hash}\n" + ) + + +def test_missing_explorer_does_not_interrupt_receipt_wait(monkeypatch, captured_console): + console, output = captured_console + monkeypatch.setattr(utils, "load_din_info", lambda: {"local": {}}) + tx_hash = TX_HASH_HEXBYTES + receipt = SimpleNamespace(status=1) + eth = SimpleNamespace( + estimate_gas=Mock(return_value=21000), + send_raw_transaction=Mock(return_value=tx_hash), + wait_for_transaction_receipt=Mock(return_value=receipt), + ) + account = SimpleNamespace( + sign_transaction=Mock(return_value=SimpleNamespace(raw_transaction=b"signed")) + ) + context = SimpleNamespace(obj=SimpleNamespace( + get_en_w3_account_console=lambda: ( + "local", SimpleNamespace(eth=eth), account, console + ), + get_tx_params=lambda: {"nonce": 0}, + )) + contract_function = SimpleNamespace(build_transaction=Mock(return_value={"nonce": 0})) + + result = utils.build_and_send_tx( + context, contract_function, "Sending transaction", "Transaction succeeded", + "Transaction failed", exit_on_failure=False, + ) + + eth.send_raw_transaction.assert_called_once_with(b"signed") + eth.wait_for_transaction_receipt.assert_called_once_with(tx_hash) + assert result is receipt + text = output.getvalue() + assert f"Transaction hash: {tx_hash.hex()}\n" in text + assert "✓ Transaction succeeded" in text + assert "Transaction failed" not in text + assert "Exception:" not in text + assert "Transaction url:" not in text + assert "http" not in text From cfda0e16623d0ade661dab6ccb37aef47ef84613 Mon Sep 17 00:00:00 2001 From: umermjd11 Date: Thu, 22 Oct 2026 00:05:58 +0500 Subject: [PATCH 20/48] chore(skills): generalise the din-* skills for any maintainer (Discussion 208, S3) - Add .claude/scripts/: din_peer_check.py (peer rule, MAINTAINING No. 1), din-maintainers.json (umeradl + umermjd11 = one person), din-env.sh (machine paths, defaults unchanged), README.md (setup). - Drop "must run as umeradl" everywhere; skills act as the active gh user and run the peer check before approving, merging or closing. - din-pr-review: review depth per No. 4.2 (green CI is evidence; reproduce locally only what CI doesn't cover); approve / request changes / recuse step; drop the stale FOUNDRY_VIA_IR=false shortcut. - din-pr-merge: path A (GitHub-side merge commit, fixes pushed onto the PR branch) and path B (admin local merge); merger owns develop CI (No. 4.6). - merge-fix-comment-update / din-merge-build-test: path A works on the PR branch in the review worktree and leaves "Actual merge proposal" to the merger; state when a local build is needed before merging. - din-push-develop: admins only, bypass reason logged in Discussion 170, precheck reads din-env.sh and runs the contract size gate. - din-approve-issue / din-close-issue / din-task-close: peer check first; mechanism issues wait on @umeradl; task close opens the Status: PR. - MAINTAINING.md No. 8: skill map and when a local build is needed. - CODEOWNERS: add Santiago and Robbert on /.claude/. Co-Authored-By: Claude Opus 5.5 --- .claude/scripts/README.md | 37 +++ .claude/scripts/din-env.sh | 18 + .claude/scripts/din-maintainers.json | 10 + .claude/scripts/din_peer_check.py | 194 +++++++++++ .claude/skills/din-approve-issue/SKILL.md | 64 ++-- .claude/skills/din-close-issue/SKILL.md | 61 ++-- .claude/skills/din-merge-build-test/SKILL.md | 152 +++++---- .claude/skills/din-pr-merge/SKILL.md | 313 +++++++++++------- .claude/skills/din-pr-review/SKILL.md | 145 +++++--- .../templates/merge-proposal-comment.md | 5 +- .../templates/outcome-comment.md | 27 +- .../templates/verification-comment.md | 24 +- .claude/skills/din-push-develop/README.md | 21 +- .claude/skills/din-push-develop/SKILL.md | 43 +-- .../din-push-develop/scripts/precheck.sh | 14 +- .claude/skills/din-task-close/SKILL.md | 32 +- .../skills/merge-fix-comment-update/SKILL.md | 144 ++++---- .github/CODEOWNERS | 2 +- Developer/MAINTAINING.md | 17 +- 19 files changed, 929 insertions(+), 394 deletions(-) create mode 100644 .claude/scripts/README.md create mode 100644 .claude/scripts/din-env.sh create mode 100644 .claude/scripts/din-maintainers.json create mode 100644 .claude/scripts/din_peer_check.py diff --git a/.claude/scripts/README.md b/.claude/scripts/README.md new file mode 100644 index 00000000..93505059 --- /dev/null +++ b/.claude/scripts/README.md @@ -0,0 +1,37 @@ +# Shared scripts for the `din-*` skills + +The `din-*` Claude Code skills under [`../skills/`](../skills/) implement the maintainer workflow in [`Developer/MAINTAINING.md`](../../Developer/MAINTAINING.md). Any maintainer can run them. These scripts hold the parts that every skill shares. + +| File | What it does | +|---|---| +| [`din_peer_check.py`](din_peer_check.py) | The peer-rule check ([MAINTAINING No. 1](../../Developer/MAINTAINING.md#1-the-rule)). It refuses when the active `gh` user is the author, assignee, implementer or a co-author of the PR, issue or task. Every skill that approves, merges or closes runs it first. | +| [`din-maintainers.json`](din-maintainers.json) | Maps each person to their GitHub logins and display name. Logins listed together count as one person (Umer: `umeradl` + `umermjd11`). Add an entry when someone joins. | +| [`din-env.sh`](din-env.sh) | Machine-specific paths (`DIN_REPO`, `DIN_WORK`, `DIN_PY`, `DIN_NVM`). Source it. Override any value by exporting it first. | + +## Setup (once per machine) + +1. **`gh` logged in as yourself.** The skills act as whoever `gh api user` returns. They never switch accounts for you. +2. **Paths.** Check the defaults in [`din-env.sh`](din-env.sh). If yours differ, export your own values in your shell profile, for example: + ```bash + export DIN_REPO=~/code/DevNet + export DIN_WORK=~/din-work + export DIN_PY=~/venvs/din/bin/python + ``` + `DIN_PY` needs Python 3.12, `torch`, and `pip install -e ".[test]"` of this repo. +3. **Tools.** `forge` on `PATH` at the version `ci.yml` pins (`FOUNDRY_VERSION`), and Node 20 (`npm`, `npx`). +4. **Optional: the push guard hook.** Only admins push to `develop` directly. If that's you, register the hook described in [`../skills/din-push-develop/README.md`](../skills/din-push-develop/README.md). + +## Running the peer check by hand + +```bash +python3 .claude/scripts/din_peer_check.py whoami +python3 .claude/scripts/din_peer_check.py pr 264 # before reviewing, approving or merging +python3 .claude/scripts/din_peer_check.py issue-approve 223 # before an approval verdict +python3 .claude/scripts/din_peer_check.py issue-close 227 # before closing as completed +python3 .claude/scripts/din_peer_check.py task Developer/tasks/task_240926_17.md +python3 .claude/scripts/din_peer_check.py admin # merge path B / bypass needs the admin role +``` + +It exits with 0 when the action is OK, 1 when it's refused (the reasons are printed), and 2 on an error. + +The check is deliberately conservative. For `issue-close`, the author of any merged PR that cross-references the issue counts as an implementer. If a refusal looks wrong because the mention was incidental, hand the issue to a peer anyway. Don't work around the check. diff --git a/.claude/scripts/din-env.sh b/.claude/scripts/din-env.sh new file mode 100644 index 00000000..4cb5ef37 --- /dev/null +++ b/.claude/scripts/din-env.sh @@ -0,0 +1,18 @@ +# Machine-specific paths for the din-* skills. Source it: `source .claude/scripts/din-env.sh`. +# Every value can be overridden by exporting it first (e.g. in your shell profile). +# The defaults are the original maintainer's layout. + +: "${DIN_REPO:=$HOME/projects/devnet}" # main checkout of InfiniteZeroFoundation/DevNet +: "${DIN_WORK:=$HOME/tempdir/DIN}" # review worktrees (PRs/), merge-fix logs, push-check worktree + logs +: "${DIN_NVM:=$HOME/.nvm/nvm.sh}" # sourced for npm/npx if present (Node 20) + +# Python 3.12 with torch and `pip install -e ".[test]"` of this repo. +if [ -z "${DIN_PY:-}" ]; then + if [ -x "$HOME/my_venvs/torchenv/bin/python" ]; then + DIN_PY=$HOME/my_venvs/torchenv/bin/python + else + DIN_PY=$(command -v python3) + fi +fi + +export DIN_REPO DIN_WORK DIN_PY DIN_NVM diff --git a/.claude/scripts/din-maintainers.json b/.claude/scripts/din-maintainers.json new file mode 100644 index 00000000..45f51c37 --- /dev/null +++ b/.claude/scripts/din-maintainers.json @@ -0,0 +1,10 @@ +{ + "repo": "InfiniteZeroFoundation/DevNet", + "people": [ + {"name": "Umer Majeed", "logins": ["umeradl", "umermjd11"]}, + {"name": "Santiago Rodriguez Cetran", "logins": ["Santiagocetran"]}, + {"name": "Robbert Abimbola", "logins": ["robertocarlous"]}, + {"name": "Similoluwa Abidoye", "logins": ["Abidoyesimze"]}, + {"name": "Abraham Nash", "logins": ["abrahamnash"]} + ] +} diff --git a/.claude/scripts/din_peer_check.py b/.claude/scripts/din_peer_check.py new file mode 100644 index 00000000..48d03e34 --- /dev/null +++ b/.claude/scripts/din_peer_check.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python3 +"""Peer-rule check for the din-* skills (Developer/MAINTAINING.md No. 1). + +Nobody approves, merges or closes their own work. Every din-* skill that +approves, merges or closes runs this first, as the active `gh` user, and stops +if it prints REFUSE. + +Usage: + din_peer_check.py whoami print the active gh login and person name + din_peer_check.py pr may I review / approve / merge PR ? + din_peer_check.py issue-approve may I approve issue 's plan? + din_peer_check.py issue-close may I close issue as completed? + din_peer_check.py task may I close this task's Discussion? + din_peer_check.py admin do I hold the repo admin role (merge path B, bypass)? + +Exit status: 0 = OK, 1 = REFUSE (reasons printed), 2 = error. + +One person can have several GitHub logins (Umer: umeradl + umermjd11). Those +are listed in din-maintainers.json next to this script and count as the same +person, so umeradl can't approve a umermjd11 PR. +""" +import json +import re +import subprocess +import sys +from pathlib import Path + +CONFIG = json.loads((Path(__file__).with_name("din-maintainers.json")).read_text()) +REPO = CONFIG["repo"] +PEOPLE = CONFIG["people"] +BOTS = {"web-flow", "github-actions[bot]", "dependabot[bot]", "Copilot", "claude"} + + +def gh(*args): + out = subprocess.run(["gh", *args], capture_output=True, text=True) + if out.returncode != 0: + print(f"ERROR: gh {' '.join(args)}: {out.stderr.strip()}", file=sys.stderr) + sys.exit(2) + return out.stdout + + +def gh_json(*args): + return json.loads(gh(*args) or "null") + + +def gh_list(endpoint): + """Every item of a paginated list endpoint (works on gh versions without --slurp).""" + out = gh("api", "--paginate", endpoint, "--jq", ".[]") + return [json.loads(line) for line in out.splitlines() if line.strip()] + + +def person(ident): + """Canonical person key for a login or display name; unknown idents map to themselves.""" + key = (ident or "").strip().lower() + for p in PEOPLE: + if key == p["name"].lower() or key in (l.lower() for l in p["logins"]): + return p["name"] + return ident.strip() + + +def from_email(email): + m = re.match(r"^(?:\d+\+)?([^@]+)@users\.noreply\.github\.com$", email or "", re.I) + return m.group(1) if m else None + + +def co_authors(message): + found = [] + for name, email in re.findall(r"^co-authored-by:\s*(.+?)\s*<([^>]*)>", message, re.I | re.M): + if email.lower() == "noreply@anthropic.com": # AI-attribution trailer, not a person + continue + login = from_email(email) + found.append(login or name) + return found + + +def me(): + return gh("api", "user", "--jq", ".login").strip() + + +def verdict(actor, involved, what): + """involved: {person: [reasons]}""" + mine = person(actor) + hits = involved.get(mine, []) + if hits: + print(f"REFUSE: {actor} ({mine}) can't {what}: {'; '.join(sorted(set(hits)))}.") + print("Hand it to a peer (Developer/MAINTAINING.md No. 1, No. 7).") + return 1 + others = ", ".join(sorted(involved)) or "nobody" + print(f"OK: {actor} ({mine}) may {what}. Involved: {others}.") + return 0 + + +def add(involved, ident, reason): + if ident and ident not in BOTS: + involved.setdefault(person(ident), []).append(reason) + + +def check_pr(n): + actor = me() + pr = gh_json("api", f"repos/{REPO}/pulls/{n}") + involved = {} + add(involved, pr["user"]["login"], f"author of PR No. {n}") + for c in gh_list(f"repos/{REPO}/pulls/{n}/commits"): + sha = c["sha"][:7] + if c.get("author"): + add(involved, c["author"]["login"], f"authored commit {sha}") + else: + add(involved, c["commit"]["author"]["name"], f"authored commit {sha}") + if c.get("committer"): + add(involved, c["committer"]["login"], f"committed {sha}") + for ca in co_authors(c["commit"]["message"]): + add(involved, ca, f"co-author on {sha}") + return verdict(actor, involved, f"review, approve or merge PR No. {n}") + + +def issue_people(n, closing): + issue = gh_json("api", f"repos/{REPO}/issues/{n}") + if issue.get("pull_request"): + print(f"ERROR: No. {n} is a PR; use `pr {n}`.", file=sys.stderr) + sys.exit(2) + involved = {} + if not closing: + add(involved, issue["user"]["login"], f"opened issue No. {n}") + for a in issue.get("assignees") or []: + add(involved, a["login"], f"assigned to issue No. {n}") + if closing: + for e in gh_list(f"repos/{REPO}/issues/{n}/timeline"): + src = (e.get("source") or {}).get("issue") or {} + if e.get("event") == "cross-referenced" and (src.get("pull_request") or {}).get("merged_at"): + add(involved, src["user"]["login"], f"authored merged PR No. {src['number']}") + return involved + + +def check_issue(n, closing): + actor = me() + what = f"close issue No. {n} as completed" if closing else f"approve issue No. {n}" + return verdict(actor, issue_people(n, closing), what) + + +def check_task(path): + actor = me() + text = Path(path).read_text() + m = re.search(r"^\*\*Assigned to:\*\*\s*(.+)$", text, re.M) + if not m: + print(f"ERROR: no '**Assigned to:**' line in {path}", file=sys.stderr) + return 2 + line = m.group(1) + involved = {} + handles = re.findall(r"@([A-Za-z0-9-]+)", line) + for h in handles: + add(involved, h, f"assigned to {Path(path).name}") + if not handles: + for name in re.split(r",| and |/", re.sub(r"\(.*?\)", "", line)): + if name.strip(): + add(involved, name.strip(), f"assigned to {Path(path).name}") + return verdict(actor, involved, f"close {Path(path).name}") + + +def check_admin(): + actor = me() + role = gh("api", f"repos/{REPO}/collaborators/{actor}/permission", "--jq", ".role_name").strip() + if role == "admin": + print(f"OK: {actor} holds the admin role (merge path B / bypass allowed).") + return 0 + print(f"REFUSE: {actor} has '{role}', not admin. Use merge path A (Developer/MAINTAINING.md No. 4.4).") + return 1 + + +def main(argv): + if not argv: + print(__doc__) + return 2 + cmd, rest = argv[0], argv[1:] + if cmd == "whoami": + actor = me() + print(f"{actor} ({person(actor)})") + return 0 + if cmd == "admin": + return check_admin() + if not rest: + print(__doc__) + return 2 + if cmd == "pr": + return check_pr(int(rest[0])) + if cmd in ("issue-approve", "issue-close"): + return check_issue(int(rest[0]), cmd == "issue-close") + if cmd == "task": + return check_task(rest[0]) + print(__doc__) + return 2 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/.claude/skills/din-approve-issue/SKILL.md b/.claude/skills/din-approve-issue/SKILL.md index 594dd9d6..ab2310ea 100644 --- a/.claude/skills/din-approve-issue/SKILL.md +++ b/.claude/skills/din-approve-issue/SKILL.md @@ -1,28 +1,38 @@ --- name: din-approve-issue -description: Use when Umer asks to approve, vet, or sign off on a GitHub issue in InfiniteZeroFoundation/DevNet before anyone implements it — "approve issue #N", "run din-approve-issue on 179", "does issue N have merit", "verify the plan in issue N". Must run as the `umeradl` GitHub account. Checks every factual claim in the issue (file:line refs, function names, "the contract doesn't have X", "the ABI already has Y", "no command exists for Z") against `origin/develop`, not the local working tree. Then judges whether the proposed plan is sound, complete, and correctly scoped. Posts one verdict comment (Approved / Approved with amendments / Changes requested / Not approved) and, on approval, applies the `Approved` label. Not for PRs (`din-pr-review`) or for closing task discussions (`din-task-close`). +description: Use when a maintainer asks to approve, vet, or sign off on a GitHub issue in InfiniteZeroFoundation/DevNet before anyone implements it — "approve issue #N", "run din-approve-issue on 179", "does issue N have merit", "verify the plan in issue N". Any maintainer can run it, as long as they didn't open the issue and aren't its intended implementer (checked first with `.claude/scripts/din_peer_check.py`); mechanism-design issues also need @umeradl's sign-off. Checks every factual claim in the issue (file:line refs, function names, "the contract doesn't have X", "the ABI already has Y", "no command exists for Z") against `origin/develop`, not the local working tree. Then judges whether the proposed plan is sound, complete, and correctly scoped. Posts one verdict comment (Approved / Approved with amendments / Changes requested / Not approved) and, on approval, applies the `Approved` label. Not for PRs (`din-pr-review`) or for closing task discussions (`din-task-close`). --- # DIN issue approval Issues in this repo often come with a full plan ("Problem → Proposed change → -Out of scope → Verification"), and many are drafted with Claude's help from -the `umermjd11` account. Before anyone implements one, `umeradl` signs off. +Out of scope → Verification"), and many are drafted with Claude's help. Before +anyone implements one, a peer signs off ([`Developer/MAINTAINING.md`](../../../Developer/MAINTAINING.md) +No. 5.1): someone who didn't open the issue and won't implement it. Approval means someone ran every factual claim against the real code and thought through what the plan leaves out. Reading the text and agreeing with it is not approval. This skill does that check and records the verdict on the issue. ## 0. Hard rules -- **Run as `umeradl`.** Before anything else: +- **Peer check first.** "The operator" is the active `gh` user. Before + anything else: ```bash - gh api user --jq .login + python3 .claude/scripts/din_peer_check.py issue-approve ``` - If it is not `umeradl`, run `gh auth switch --user umeradl` and re-check. - If the switch fails (account not logged in), stop and tell Umer. Don't fall - back to another account. If you switched, say so in the final report. The - `umermjd11` fork workflow switches back at the end of its own step, so leave - `umeradl` active. + `REFUSE` means the operator opened the issue or is assigned to it. Stop, + say so, and suggest a maintainer from MAINTAINING No. 3. Never switch `gh` + accounts to get past it — logins listed together in + `.claude/scripts/din-maintainers.json` are one person. If the issue names + its implementer in the body rather than as an assignee, apply the same rule + by hand. +- **Mechanism-design issues** (slashing, tokenomics, fees, scoring) need + @umeradl's approval (MAINTAINING No. 5.1, mechanism sign-off). If the + operator isn't `umeradl`, post the verdict as usual, but for **Approved** / + **Approved with amendments** end it with "Mechanism sign-off needed from + @umeradl before implementation" and don't add the label — `umeradl` adds it + when signing off. If Umer opened the issue himself, the mechanism sign-off + comes from @abrahamnash (recorded in a comment that cc's him). - **Verify against `origin/develop`, never the working tree.** Run `git fetch origin develop` and read files with `git show origin/develop:` / `git grep origin/develop -- `. The `develop` checkout @@ -30,17 +40,17 @@ with it is not approval. This skill does that check and records the verdict on t half done). That work must not count as evidence for or against a claim. Record `git rev-parse --short origin/develop` and cite it in the comment. - **Foundry is canonical.** Check contract claims against `foundry/src/`; - `hardhat/contracts/` and `cache_model_0` ABIs lag behind - (`foundry-canonical-hardhat-stale`). A hardhat-only mismatch is at most a - note, not a blocker. + `hardhat/contracts/` and `cache_model_0` ABIs lag behind. A hardhat-only + mismatch is at most a note, not a blocker. - **Never write `#N`** in the comment. Write "No. N". - **Never fabricate GitHub IDs or SHAs.** Fetch them. - **`-F body=@`**, never `-f`, and re-fetch after posting to check the body arrived intact. - **Don't close, reassign, or edit the issue body.** The skill only comments - and labels. If the verdict is "Not approved", Umer decides whether to close. -- **Show the draft comment to Umer and wait for a go-ahead before posting**, - unless he has already said to post directly this session. + and labels. If the verdict is "Not approved", the author or a peer decides + whether to close it as not planned (MAINTAINING No. 5.2). +- **Show the draft comment to the operator and wait for a go-ahead before + posting**, unless they have already said to post directly this session. ## 1. Fetch the issue @@ -104,9 +114,11 @@ one level further than the issue did. Questions that have caught real gaps: failure, so a pre-check with a clear message usually is worth it. - **Access control:** who can call the target function (`onlyOwner`, etc.)? Is that the role whose CLI the command lives under? -- **Consistency with recorded decisions:** check memory and `Developer/` for - standing calls (e.g. `eth-burn-din-only`, `p3-issues-defer-fee-changes`, - `din-dao-deferred-post-mainnet`, `foundry-canonical-hardhat-stale`). +- **Consistency with recorded decisions:** check `Developer/MECHANISM_DESIGN.md`, + `Developer/design/`, `Developer/ROADMAP.md` and the decision comments on + related issues and Discussions for standing calls (for example: burning + applies to DIN only — ETH "burn" shares go to the Treasury; DIN-DAO is + deferred until after mainnet; foundry is canonical, hardhat lags). - **Tests and verification:** does the test plan cover the failure paths? Does it follow an existing test file's style? Is the new test file named for what it covers? @@ -152,7 +164,7 @@ bodies. Use "No. N" for every issue/PR number. Run `grep -n '#[0-9]' ` before posting; the only hits allowed are inside code spans or URLs. -## 7. Post and label (after Umer's go-ahead) +## 7. Post and label (after the operator's go-ahead) ```bash gh api repos/InfiniteZeroFoundation/DevNet/issues//comments -F body=@ --jq .html_url @@ -161,19 +173,21 @@ gh api repos/InfiniteZeroFoundation/DevNet/issues//comments --jq '.[-1] | {ht Post through `gh api`, not `gh issue comment`: for `gh issue comment`, `-F` means `--body-file `, so `-F body=@` fails and posts nothing. -Confirm `user` is `umeradl` and the body starts with `## Approval review`. +Confirm `user` is the operator and the body starts with `## Approval review`. Add the label only after that check passes. -For **Approved** / **Approved with amendments**, add the label: +For **Approved** / **Approved with amendments**, add the label (unless it's a +mechanism issue still waiting on @umeradl, see section 0): ```bash gh issue edit --repo InfiniteZeroFoundation/DevNet --add-label Approved ``` The `Approved` label must already exist. If `gh label list` doesn't show it, -ask Umer before creating it. For the other two verdicts, don't label. +ask the operator before creating it. For the other two verdicts, don't label. ## 8. Report back -Give Umer the verdict, the comment URL (fetched), the label change, the -amendments list in one line each, and whether `gh auth` was switched. +Give the operator the verdict, the comment URL (fetched), the label change +(or that it waits on the mechanism sign-off), and the amendments list in one +line each. diff --git a/.claude/skills/din-close-issue/SKILL.md b/.claude/skills/din-close-issue/SKILL.md index e1236476..d38e22ad 100644 --- a/.claude/skills/din-close-issue/SKILL.md +++ b/.claude/skills/din-close-issue/SKILL.md @@ -1,6 +1,6 @@ --- name: din-close-issue -description: Use when deciding whether a GitHub issue in InfiniteZeroFoundation/DevNet can be closed — "close issue N", "can issue N be closed", "is issue N done", "run din-close-issue on 195", or a batch "close issues 159-161". Must run as `umeradl`. Gathers the full context (issue body, every comment including the `din-approve-issue` review and its amendments, linked PRs and their review/outcome comments, sub-issues, follow-up commits) and verifies every proposed change, amendment and verification item against `origin/develop`. It never takes a comment that says "done" or "landed" as proof. If nothing remains, it posts a closing comment and closes the issue as completed. If anything remains, it posts a status-update comment listing done vs. remaining and leaves the issue open. Not for task Discussions (`din-task-close`), pre-implementation sign-off (`din-approve-issue`), or PR review (`din-pr-review`). +description: Use when deciding whether a GitHub issue in InfiniteZeroFoundation/DevNet can be closed — "close issue N", "can issue N be closed", "is issue N done", "run din-close-issue on 195", or a batch "close issues 159-161". Any maintainer can run it, as long as they didn't implement the issue (checked first with `.claude/scripts/din_peer_check.py`). Gathers the full context (issue body, every comment including the `din-approve-issue` review and its amendments, linked PRs and their review/outcome comments, sub-issues, follow-up commits) and verifies every proposed change, amendment and verification item against `origin/develop`. It never takes a comment that says "done" or "landed" as proof. If nothing remains, it posts a closing comment and closes the issue as completed. If anything remains, it posts a status-update comment listing done vs. remaining and leaves the issue open. Not for task Discussions (`din-task-close`), pre-implementation sign-off (`din-approve-issue`), or PR review (`din-pr-review`). --- # DIN issue closure @@ -16,16 +16,23 @@ This is the issue counterpart of `din-task-close`. The verification discipline is the same. The differences: the source of truth is the issue body plus its approval amendments rather than a task file's Deliverables list, and an issue that can't be closed gets a posted status-update comment instead of only a -report back to Umer. +report back to the operator. + +The process is [`Developer/MAINTAINING.md`](../../../Developer/MAINTAINING.md) +No. 5.2: closing as completed is a peer's check, not the implementer's. ## 0. Hard rules -- **Run as `umeradl`.** Before anything else: +- **Peer check first.** "The operator" is the active `gh` user. Before + anything else: ```bash - gh api user --jq .login + python3 .claude/scripts/din_peer_check.py issue-close ``` - If it is not `umeradl`, run `gh auth switch --user umeradl` and check again. - If the switch fails, stop and tell Umer. Don't post from another account. + `REFUSE` means the operator implemented it: they're assigned, or they + authored a merged PR that references it. They may still post a progress + note, but the closing check goes to a peer (MAINTAINING No. 5.2). Stop and + name one. Never switch `gh` accounts to get past it. For a batch, run it per + issue and hand off only the refused ones. - **Verify against `origin/develop`, never the working tree.** Run `git fetch origin develop` and read files with `git show origin/develop:` or `git grep origin/develop -- `. Local uncommitted or @@ -38,9 +45,8 @@ report back to Umer. file on `origin/develop`, or `gh pr view

--json state,mergedAt,baseRefName` showing `MERGED` into `develop`. - **Foundry is canonical.** Check contract claims against `foundry/src/`. - `hardhat/contracts/` and the `cache_model_0` ABIs lag behind - (`foundry-canonical-hardhat-stale`). A hardhat-only gap blocks closure only - if the issue explicitly scoped hardhat in. + `hardhat/contracts/` and the `cache_model_0` ABIs lag behind. A hardhat-only + gap blocks closure only if the issue explicitly scoped hardhat in. - **Never write `#N`** in anything GitHub renders. Write "No. N". Before posting, run `grep -n '#[0-9]' `. The only hits allowed are inside code spans or URLs. @@ -56,7 +62,7 @@ report back to Umer. - **Don't edit the issue body, reassign, or change labels.** The skill comments, and it closes the issue when the verdict is closable. Nothing else. - **Invoking the skill is the go-ahead to post.** The exception is a judgment - call (see step 5). In that case show Umer the draft and wait. + call (see step 5). In that case show the operator the draft and wait. ## 1. Fetch the full context @@ -77,7 +83,7 @@ Read every comment. Pay particular attention to: - **The `## Approval review` comment** from `din-approve-issue`. Its numbered **Amendments** are part of the scope, as binding as the body. -- **Later scope changes by Umer or the author** ("drop Part C", "moving X to +- **Later scope changes by a maintainer or the author** ("drop Part C", "moving X to issue No. M"). An item that was explicitly moved out doesn't block closure, but the comment should say where it went. - **Linked PRs.** For each PR, also read its `din-pr-review` verification, @@ -108,9 +114,9 @@ gh pr view

--repo $R --json state,mergedAt,baseRefName,mergeCommit,isDraft git log --oneline origin/develop --grep="

" -i # merge commit and follow-ups ``` -The PR must be `MERGED` into `develop`. Contributor PRs are often merged by -hand (`din-pr-merge`), so the GitHub state can say `CLOSED` while the work is -on `develop` in a `merge: PR No.

...` commit. In that case find the commit +The PR must be `MERGED` into `develop`. Older PRs and path-B merges were +merged by hand (`din-pr-merge` path B), so the GitHub state can say `CLOSED` +while the work is on `develop` in a `merge: PR No.

...` commit. In that case find the commit with `git log origin/develop --grep` and cite it. A PR that is open, a draft, or closed with no merge commit on `develop` carries nothing. @@ -137,10 +143,11 @@ gh run list --repo $R --branch develop --event push --limit 10 --json databaseId ``` If no green run covers the commit, run the relevant suite yourself in a clean -worktree of `origin/develop`: real via_ir `forge build` (`pr-review-skip-via-ir`), -`forge test --match-contract `, and `pytest -m "not integration"` with -`~/my_venvs/torchenv`. Never run them in the `develop` checkout. Don't treat -the stale dincli integration suite as verification (see the backlog). +worktree of `origin/develop`: real via_ir `forge build` (the plain build no +longer compiles `develop`), `forge test --match-contract `, and +`"$DIN_PY" -m pytest -m "not integration"` (`source .claude/scripts/din-env.sh`). +Never run them in the `develop` checkout. The `tests/dincli/` integration +suite counts only once its CI job is a required check (MAINTAINING No. 4.9). Mark each item ✅ done (with evidence), ❌ missing, or ↪️ moved out of scope (with where it went). @@ -149,8 +156,8 @@ Mark each item ✅ done (with evidence), ❌ missing, or ↪️ moved out of sco PR but is missing or different on `develop` now, check `git log` on the path for a later commit that explains why before calling it a gap. A deliberate change backed by a commit (or by a recorded decision such as -`eth-burn-din-only` or `p3-issues-defer-fee-changes`) counts as ✅ with the -deviation named. An unexplained one is ❌. +a decision comment on the issue or in `Developer/MECHANISM_DESIGN.md`) counts +as ✅ with the deviation named. An unexplained one is ❌. ## 5. Verdict @@ -158,10 +165,10 @@ deviation named. An unexplained one is ❌. |---|---|---| | **Closable** | Every item is ✅ or ↪️ with a destination. | Closing comment, then close as `completed`. | | **Not closable** | Any ❌. | Status-update comment. Leave open. | -| **Judgment call** | For example: the only gap is small and could become a follow-up issue instead; an item was dropped silently with no comment or commit; an amendment reads ambiguously. | Show Umer the draft and the question. Don't post until he answers. | +| **Judgment call** | For example: the only gap is small and could become a follow-up issue instead; an item was dropped silently with no comment or commit; an amendment reads ambiguously. | Show the operator the draft and the question. Don't post until they answer. | A partial issue doesn't get closed with caveats. It gets a status update. If -Umer wants the remainder split out, he says so, and the close happens after +the operator wants the remainder split out, they say so, and the close happens after the follow-up issue exists so the comment can link it. ## 6. Draft the comment @@ -219,7 +226,7 @@ diff <(sed -e '$a\' ) <(sed -e :a -e '/^\n*$/{$d;N;ba' -e '}' `, so `-F body=@` fails with "open body=@...: no such file or directory" and posts nothing. Confirm that `user` -is `umeradl` and the diff prints `BODY-OK` (GitHub can add a trailing blank +is the operator and the diff prints `BODY-OK` (GitHub can add a trailing blank line, which the `sed` strips). Only for **Closable**, and only after `BODY-OK`. Run the close as a separate @@ -236,7 +243,7 @@ status updates on the rest, and don't skip any silently. ## 8. Report back -Tell Umer, per issue: the verdict, the comment URL (fetched), whether the -issue was closed, any deviations named, remaining items in one line each for -status updates, out-of-scope items not tracked anywhere yet, and whether -`gh auth` was switched. Don't update project memory unless asked. +Tell the operator, per issue: the verdict, the comment URL (fetched), whether +the issue was closed, any deviations named, remaining items in one line each +for status updates, out-of-scope items not tracked anywhere yet, and any issue +handed to a peer because the peer check refused it. Don't update project memory unless asked. diff --git a/.claude/skills/din-merge-build-test/SKILL.md b/.claude/skills/din-merge-build-test/SKILL.md index 5aa12670..b34df34c 100644 --- a/.claude/skills/din-merge-build-test/SKILL.md +++ b/.claude/skills/din-merge-build-test/SKILL.md @@ -1,27 +1,41 @@ --- name: din-merge-build-test -description: Use once per PR, after `merge-fix-comment-update` has been run on every file of a DevNet PR's merge-proposal comment — "build and test the merge", "run din-merge-build-test", "now test everything and update the comment". Builds and tests the uncommitted merged tree in the `develop` cwd the way CI does (forge build on the real via_ir profile, full forge test, pytest -m "not integration", plus ABI regeneration / doc-link check / hardhat when relevant), fixes whatever breaks and re-runs until everything is green, then updates the "Actual merge proposal" and "Pending proposal" rows for every file in the live merge-proposal comment in one edit. Never commits or pushes. Not for the per-file code changes (use `merge-fix-comment-update`), the initial review (`din-pr-review`), or the real merge commit (`din-pr-merge`). +description: Use once per PR, after `merge-fix-comment-update` has been run on every file of a DevNet PR's merge-proposal comment — "build and test the merge", "run din-merge-build-test", "now test everything and update the comment". Builds and tests the fixed tree the way CI does (forge build on the real via_ir profile, contract size gate, full forge test, pytest -m "not integration", plus ABI regeneration / doc-link check / hardhat when relevant), fixes whatever breaks and re-runs until everything is green, then updates the merge-proposal comment in one edit. Path A (default): runs in the PR's review worktree before the fixes are pushed onto the PR branch, and fills only "Pending proposal" (the merger fills "Actual merge proposal"). Path B (admins only): runs on the uncommitted merged tree in the `develop` checkout and fills both rows. Not needed when a PR merges as-is — its green `CI OK` is the build/test evidence. Never commits or pushes. Not for the per-file code changes (use `merge-fix-comment-update`), the initial review (`din-pr-review`), or the merge itself (`din-pr-merge`). --- # DIN merge build & test -Second stage of landing a reviewed PR: +Second stage of landing a reviewed PR that needs fixes: 1. `merge-fix-comment-update ` — per file, code changes only, logged to - `~/tempdir/DIN/PRs/PRreview_-merge-fix-log.md`. -2. **`din-merge-build-test`** (this skill) — build + test the whole merged - tree once, fix until green, then write the outcome into the comment for - every file. -3. `din-pr-merge` — the real commit, after Umer reviews. + `$DIN_WORK/PRs/PRreview_-merge-fix-log.md`. +2. **`din-merge-build-test`** (this skill) — build + test the whole fixed tree + once, fix until green, then write the outcome into the comment. +3. `din-pr-merge` — commit, push and merge, after the operator reviews. -Everything here happens on the **uncommitted working tree of the `develop` -cwd** (`/home/umerm/projects/devnet`), which is where stage 1 left the -changes. +"The operator" means the maintainer running the skill (the active `gh` user). +The process is [`Developer/MAINTAINING.md`](../../../Developer/MAINTAINING.md); +setup is [`.claude/scripts/README.md`](../../scripts/README.md). + +## When this skill is needed + +| Situation | Build/test evidence | +|---|---| +| PR merges **as-is** (every "Pending proposal" is `None`) | The PR's green `CI OK`. CI runs the full pipeline on GitHub's merge of the PR into `develop` (via_ir `forge build`, size gate, `forge test`, hardhat, pytest, doc links), and the ruleset requires the branch to be up to date, so that run tests exactly what lands. **Don't run this skill.** | +| Fixes applied, **path A** | This skill, in the review worktree, **before** the fixes are pushed. After the push, the PR's CI re-runs and is the gate. | +| Fixes applied, **path B** | This skill, in the `develop` checkout. There is no PR CI for the deviation, so this run plus `din-push-develop`'s precheck are the gate. | + +Where it runs: + +| | Path A | Path B | +|---|---|---| +| `$TARGET` | `$DIN_WORK/PRs/PRreview_` (PR head + uncommitted fixes) | `$DIN_REPO` (uncommitted merge + fixes) | +| Comment rows it fills | "Pending proposal" only — "Actual merge proposal" stays for the merger (MAINTAINING No. 4.3) | Both — the admin running path B is the merger | ## 0. Hard rules -- **Never commit or push.** Fixes stay uncommitted in the `develop` cwd. -- **Never install packages into Umer's venvs** — if something's missing, +- **Never commit or push.** Fixes stay uncommitted in `$TARGET`. +- **Never install packages into someone's venv** — if something's missing, report the exact `pip install` command and stop. - **Never `sed`/edit `foundry.toml`** to toggle `via_ir` or anything else. The plain (non-via_ir) build doesn't compile on `develop` — always use the @@ -29,37 +43,48 @@ changes. - **Never fabricate a GitHub-assigned ID.** Fetch the comment id/URL, SHAs. - **`gh api` file-sourced bodies need `-F body=@`, not `-f`.** Re-fetch after the PATCH and diff against what you meant to post. -- **`git status --short` + `git diff --stat` repo-wide before the PATCH** — +- **`git status --short` + `git diff --stat` in `$TARGET` before the PATCH** — every changed path must be explained by the merge-fix log or by a fix made - in step 4. Pre-existing unrelated edits (already dirty before stage 1) are - fine but must not be claimed as part of the PR. + in step 4. Pre-existing unrelated edits (path B: already dirty before stage + 1) are fine but must not be claimed as part of the PR. - **Never write `#N`** in anything GitHub renders — "No. N". -- **Only touch the "Actual merge proposal" and "Pending proposal" rows** in - the comment. Every other row and every other part of the body stays - byte-for-byte identical. -- **Don't update the comment until everything is green**, unless Umer tells - you to post a partial result. +- **Only touch the rows this path owns** (table above). Every other row and + every other part of the body stays byte-for-byte identical. +- **Don't update the comment until everything is green**, unless the operator + says to post a partial result. ## 1. Preconditions ```bash +source .claude/scripts/din-env.sh N= -WT=~/tempdir/DIN/PRs/PRreview_$N -LOG=~/tempdir/DIN/PRs/PRreview_$N-merge-fix-log.md -cd /home/umerm/projects/devnet -git status --short && git diff --stat +WT=$DIN_WORK/PRs/PRreview_$N +LOG=$DIN_WORK/PRs/PRreview_$N-merge-fix-log.md +TARGET=$WT # path A; for path B: TARGET=$DIN_REPO +cd "$TARGET" && git status --short && git diff --stat ``` - The log must exist and have an entry for **every** `#### \`\`` block in the live merge-proposal comment (fetch it fresh with `gh api repos/InfiniteZeroFoundation/DevNet/issues/comments/ --jq .body`). If - any file is missing, stop and tell Umer which — those still need + any file is missing, stop and say which — those still need `merge-fix-comment-update`. - Anything logged as "Open" or "deferred" (e.g. generated ABIs) is handled here — note them. -- `dincli` must resolve to this checkout: `~/my_venvs/torchenv/bin/python -c - 'import dincli; print(dincli.__file__)'` → under - `/home/umerm/projects/devnet`. If not, stop and report. +- **Path A: the PR branch must contain current `develop`**, or this run tests + something other than what CI will test: + ```bash + git fetch origin develop + git merge-base --is-ancestor origin/develop HEAD && echo up-to-date + ``` + If it isn't, stop and ask: either the author updates the branch, or the + operator merges `origin/develop` into the worktree branch as part of the fix + push (they're becoming the last pusher anyway). +- `dincli` must resolve to `$TARGET`: + `"$DIN_PY" -c 'import dincli; print(dincli.__file__)'` → under `$TARGET`. + If not, run it with `PYTHONPATH="$TARGET"` or `pip install -e .` in a venv + dedicated to that tree. Don't reinstall someone's main venv to point at a + worktree without asking. ## 2. Build + test (CI-equivalent) @@ -70,9 +95,10 @@ for a success string): **Solidity** (skip only if nothing under `foundry/` changed and no ABI is pending regeneration): ```bash -source ~/.nvm/nvm.sh >/dev/null +[ -s "$DIN_NVM" ] && source "$DIN_NVM" >/dev/null cd foundry && npm ci --silent # must precede forge test (upgrades-core FFI) forge clean && forge build # full real via_ir build; clean avoids stale build-info +python3 ../.github/scripts/contract_size_gate.py # same EIP-170 gate as CI forge test # full suite, incl. UpgradeValidation.t.sol ``` @@ -81,17 +107,16 @@ forge test # full suite, incl. UpgradeValidation.t.sol ```bash CIHOME=$(mktemp -d) HOME="$CIHOME" XDG_CONFIG_HOME="$CIHOME/.config" \ - ~/my_venvs/torchenv/bin/python -m pytest -m "not integration" -q -p no:cacheprovider + "$DIN_PY" -m pytest -m "not integration" -q -p no:cacheprovider ``` -Don't run the `integration`-marked / `tests/dincli/` suite — it's stale -against foundry and needs live services; it isn't verification. +Don't run the `integration`-marked / `tests/dincli/` suite here — it needs +live services and has its own CI job (MAINTAINING No. 4.9). **Conditional extras:** -- **ABIs** — for every contract under `foundry/src/` the merge changed (or - that the merge-proposal comment says to regenerate), after the build: +- **ABIs** — for every contract under `foundry/src/` the PR changed (or that + the merge-proposal comment says to regenerate), after the build: ```bash - # torchenv has dincli importable but no `dincli` console script, so call the app directly - ~/my_venvs/torchenv/bin/python -c "import sys; from dincli.main import app; sys.argv=['dincli']+sys.argv[1:]; app()" \ + "$DIN_PY" -c "import sys; from dincli.main import app; sys.argv=['dincli']+sys.argv[1:]; app()" \ system dump-abi --artifact foundry/out/.sol/.json --output dincli/abis ``` Then `git diff --stat dincli/abis` — the delta should be exactly the ABI @@ -99,7 +124,7 @@ against foundry and needs live services; it isn't verification. like `ReentrancyGuardReentrantCall`); anything else unexpected is worth a look before moving on. - **Docs** — if any `.md` changed: - `~/my_venvs/torchenv/bin/python .github/scripts/check_doc_links.py Documentation`. + `"$DIN_PY" .github/scripts/check_doc_links.py Documentation`. - **Hardhat** — only if anything under `hardhat/` changed: `cd hardhat && npm ci --silent && npx hardhat compile && npx hardhat test`. @@ -107,11 +132,11 @@ against foundry and needs live services; it isn't verification. For each failure, decide which bucket it's in before touching anything: -- **Caused by the merge or a merge-fix change** → fix it (step 4). +- **Caused by the PR or a merge-fix change** → fix it (step 4). - **Pre-existing on `develop`** → confirm by running the same failing test/command in a clean `origin/develop` worktree (e.g. - `git worktree add ~/tempdir/DIN/develop-check origin/develop`, remove it - afterwards). Don't fix; report it to Umer and carry on with the rest. + `git worktree add "$DIN_WORK/develop-check" origin/develop`, remove it + afterwards). Don't fix; report it and carry on with the rest. - **Environmental** (missing package, npx cache race, RPC/IPFS reach) → report, don't paper over it. @@ -129,9 +154,10 @@ Record every fix in the merge-fix log under the affected file's entry (add a `- Build/test fix:` line), and create an entry for any new file you had to touch. -**Stop and ask Umer** instead of fixing when: +**Stop and ask the operator** instead of fixing when: - The fix would change what a file's merge proposal said should happen, or - needs a design/tokenomics/fee decision. + needs a design/tokenomics/fee decision (mechanism sign-off, MAINTAINING + No. 4.2). - A test fails because the PR's behaviour conflicts with another test's expectation and it isn't obvious which one is right. - The same failure survives three fix attempts. @@ -140,32 +166,44 @@ touch. **Final run:** once everything passes in targeted runs, do one full clean run of every track from step 2 on the final tree (`forge clean && forge build`, -full `forge test`, full pytest, plus whichever extras apply). Only those -numbers go into the comment. +size gate, full `forge test`, full pytest, plus whichever extras apply). Only +those numbers go into the comment. ## 5. Update the merge-proposal comment (all files, one edit) -Fetch the live body fresh. For each file's block, replace its two rows. Build -`old` from what's **actually** in those cells now (don't assume `Soon`), -assert `count == 1`, replace: +Fetch the live body fresh. For each file's block, replace the rows this path +owns. Build `old` from what's **actually** in those cells now (don't assume +`Soon`), assert `count == 1`, replace. + +**Path A** — "Pending proposal" only: + +```python +old = "| Pending proposal | |" +new = ("| Pending proposal | ~~~~ **Done (not yet pushed):** ;" + " forge build clean (via_ir), forge test P/F/S, pytest P/F/S on the fixed branch. |") +assert body.count(old) == 1 +body = body.replace(old, new) +``` + +**Path B** — both rows: ```python old = "| Actual merge proposal | |\n| Pending proposal | |" new = ("| Actual merge proposal | ." - " Uncommitted in the `develop` cwd — not committed or pushed. |\n" + " Uncommitted in the `develop` checkout — not committed or pushed. |\n" "| Pending proposal | ~~~~ **Done:** |") # or "None" if it was None assert body.count(old) == 1 body = body.replace(old, new) ``` For a file whose shared block covers several paths (e.g. `A` / `B` / `C`), -write one outcome covering all of them. If something is still open for a -file (pre-existing failure, decision Umer deferred), say so in that file's -cells instead of marking it Done. +write one outcome covering all of them. If something is still open for a file +(pre-existing failure, decision deferred), say so in that file's cells instead +of marking it Done. Then: -1. `git status --short && git diff --stat` repo-wide (hard rule above). +1. `git status --short && git diff --stat` in `$TARGET` (hard rule above). 2. Diff the new body against the fetched one — only the intended rows may differ. 3. Grep the new body for `#[0-9]`. @@ -175,7 +213,9 @@ Then: ## 6. Report back -Summarize for Umer: final test counts per track, every fix made in step 4 -(file + one line), any pre-existing/environmental failures, ABI deltas, and -the updated comment URL. State plainly that nothing was committed — the next -step after Umer's review is `din-pr-merge`. +Summarize for the operator: final test counts per track, every fix made in +step 4 (file + one line), any pre-existing/environmental failures, ABI deltas, +and the updated comment URL. State plainly that nothing was committed. The +next step is `din-pr-merge`: A1 step 3 (commit and push the fixes onto the PR +branch, then hand the final approval to someone else) for path A, or B3 for +path B. diff --git a/.claude/skills/din-pr-merge/SKILL.md b/.claude/skills/din-pr-merge/SKILL.md index b8b7fada..469e491a 100644 --- a/.claude/skills/din-pr-merge/SKILL.md +++ b/.claude/skills/din-pr-merge/SKILL.md @@ -1,85 +1,167 @@ --- name: din-pr-merge -description: Use when actually merging a contributor PR into InfiniteZeroFoundation/DevNet's `develop` — "merge PR #N", "merge this into develop", or any request to land a PR after it's been reviewed. Runs after `din-pr-review` (assumes the 2-comment verification/merge-proposal review already happened) and produces the 3rd comment (`outcome-comment.md`) once the merge is actually pushed. Encodes the stash-merge-pop flow for merging in a cwd that already has unrelated local WIP, plus this repo's attribution/no-push-without-review rules. Not for reviewing a PR (use `din-pr-review`) and not for generic git merges outside this repo's PR workflow. +description: Use when actually merging a PR into InfiniteZeroFoundation/DevNet's `develop` — "merge PR #N", "merge this into develop", or any request to land a PR after it's been reviewed. Any maintainer who isn't the PR's author or a co-author can run it (checked first with `.claude/scripts/din_peer_check.py`). Path A (the default, per Developer/MAINTAINING.md No. 4.4): push any review fixes onto the PR branch, then merge on GitHub with a merge commit once the approval, `CI OK` and up-to-date gates pass. Path B (admins only, exceptional): the local stash-merge-pop flow plus a deviation commit, pushed through `din-push-develop` with the admin bypass. Either way it ends with the merger watching `develop` CI and posting the outcome comment (`outcome-comment.md`). Runs after `din-pr-review`. Not for reviewing a PR (use `din-pr-review`) and not for generic git merges outside this repo's PR workflow. --- # DIN DevNet PR merge -`din-pr-review` produces a verification comment and a merge-proposal table. -This skill is the next step: actually landing the PR on `develop` in this cwd -and posting the outcome comment once it's real. It assumes the review already -happened (or is happening in parallel) and Umer has said to go ahead and -merge PR ``. - -The devnet cwd routinely has unrelated uncommitted WIP sitting on `develop` -(other in-flight tasks, doc edits, scratch config toggles). This flow never -merges into a dirty tree and never lets that WIP silently ride along inside -the merge commit — it stashes it out of the way, merges cleanly, then pops it -back and treats whatever breaks as a distinct, separately-commented deviation. - -## 0. Hard rules — same gates as `din-pr-review` - -- **Never commit or push without Umer reviewing first.** Invoking this skill - is not that review. Stage the merge with `--no-commit` (step 3), summarize - file-by-file, and wait for Umer's go-ahead before the merge commit; the - same applies to any deviation commit (step 6) and to the push (step 7). - A `git commit` on `develop` has been observed to appear pushed already — - treat commit and push as one gated step. -- **Never fabricate a GitHub-assigned ID.** Always `git rev-parse ` for - every SHA that goes in the outcome comment, and `gh api - repos/InfiniteZeroFoundation/DevNet/pulls//commits` for the contributor's - real commits — never pattern-match a nearby real one. -- **Preserve real attribution on the merge commit.** Fetch the PR ref and read - the contributor's real identity before committing: - ```bash - git fetch origin refs/pull//head - git log --format='%an <%ae>' -1 FETCH_HEAD - git commit --author=" " ... - ``` - Trailers: `Co-Authored-By: umeradl ` for anything - Umer/Claude fixed on top, plus this session's standard Claude attribution - trailer. Don't credit the contributor in prose only. +`din-pr-review` produces a verification comment, a merge-proposal table and a +GitHub review. This skill lands the PR on `develop` and posts the outcome +comment once it's real. + +"The operator" means the maintainer running the skill (the active `gh` user). +The process is [`Developer/MAINTAINING.md`](../../../Developer/MAINTAINING.md); +setup is [`.claude/scripts/README.md`](../../scripts/README.md). + +## Which path + +| | Path A — GitHub-side merge commit | Path B — local merge + deviation commit | +|---|---|---| +| Who | Any maintainer who isn't the author or a co-author | Admins only (`din_peer_check.py admin`) | +| When | The default, for every PR | Exceptional: the PR needs adapting against a `develop` that has moved (renames, ABI regeneration, docs drift) and pushing fixes onto the PR branch isn't practical | +| Fixes | Pushed onto the PR branch (or a small follow-up PR); the last-push rule sends the final approval to someone else | A separate deviation commit on top of the merge, on `develop` | +| Gate | The `develop` ruleset: 1 approval of the latest push, `CI OK`, branch up to date | The admin bypass; log a one-line reason in [Discussion 170](https://github.com/InfiniteZeroFoundation/DevNet/discussions/170) | + +Use path A unless the operator explicitly chooses B and is an admin. + +## 0. Hard rules — both paths + +- **Peer check first.** `python3 .claude/scripts/din_peer_check.py pr `. + `REFUSE` → stop and name an eligible maintainer (MAINTAINING No. 1, No. 7). + Never switch `gh` accounts to get past it. +- **Never commit, push or merge without the operator's go-ahead.** Invoking + this skill is not that go-ahead. `gh pr merge` is a push to `develop`; treat + it like one. A `git commit` on `develop` has been observed to appear pushed + already — treat commit and push as one gated step. +- **Merge commits only.** Never squash or rebase (the ruleset refuses them + anyway). Authorship and history must survive. +- **Attribution** (MAINTAINING No. 4.5). The PR author stays the commit author. + Any commit the operator writes on someone else's work credits them with a + `Co-Authored-By: ` trailer taken from their real commits + (`git log --format='%an <%ae>' -1 `), never a guessed address. + The operator's own identity comes from their `git config user.name` / + `user.email`. Add the session's standard Claude attribution trailer. +- **Never fabricate a GitHub-assigned ID.** `git rev-parse` every SHA and fetch + every URL that goes in the outcome comment. - **`gh api` file-sourced comment bodies need `-F body=@`, not `-f`.** - Lowercase posts the literal string `@/path/...`. Re-fetch after every - post/PATCH and check the actual body content, not just exit status. -- **`git status --short` + `git diff --stat` repo-wide before posting the - outcome comment.** Catches stray edits (from the stash pop, from rebuilding) - before they leak into a "verified" claim. + Re-fetch after every post/PATCH and check the body, not just the exit status. +- **`git status --short` + `git diff --stat` repo-wide before posting** the + outcome comment. - **Never write `#N`** for the PR number in anything GitHub renders — write - "PR No. ``" or similar; `#N` auto-links to an unrelated issue. + "PR No. ``". -## 1. Baseline: confirm cwd builds and tests pass *before* touching anything +--- + +## Path A — GitHub-side merge commit (default) + +### A1. Fixes, if the merge proposal has any + +If every file's "Pending proposal" is `None`, skip to A2: the PR merges as-is +and its green `CI OK` is the build/test evidence (MAINTAINING No. 4.2). + +Otherwise the fixes go **onto the PR branch**, not onto `develop`: + +1. `merge-fix-comment-update ` for each file with a pending fix. In path + A it works in the review worktree `$DIN_WORK/PRs/PRreview_`. +2. `din-merge-build-test` once, in the same worktree. It runs the + CI-equivalent build and tests on the fixed branch, so the PR doesn't go red + in public, and fills in the comment rows. +3. Commit the fixes in the worktree (one commit, message naming the review + items it closes; trailers per section 0), show the operator the diff, and on + their go-ahead push to the PR's head branch: + ```bash + gh pr view --json headRepositoryOwner,headRefName,maintainerCanModify + git push HEAD: + ``` + For a fork PR this needs `maintainerCanModify: true`. If it's false, don't + push: ask the author to apply the fixes, or open a small follow-up PR + against their branch. + +**After pushing fixes, the operator can't approve or merge this PR.** They're a +co-author now, and the last-push rule hands the final approval to someone else +(MAINTAINING No. 7). Post a one-line comment saying what was pushed and who +should take the final approval, then stop. That person runs this skill from A2. + +### A2. Check the gates + +```bash +R=InfiniteZeroFoundation/DevNet +gh pr view -R $R --json headRefOid,mergeStateStatus,reviewDecision,isDraft,reviews \ + --jq '{head: .headRefOid, state: .mergeStateStatus, decision: .reviewDecision, draft: .isDraft, + approvals: [.reviews[] | select(.state=="APPROVED") | {by: .author.login, at: .commit.oid}]}' +gh pr checks -R $R +``` + +All of these must hold: + +- `isDraft` is false. +- An approval from an eligible peer is **on the current head SHA**. +- `CI OK` is green on that head. +- `mergeStateStatus` is `CLEAN`. `BEHIND` means the branch isn't up to date: + ask the author to update it, or run `gh pr update-branch `. Updating it + makes the operator the last pusher, so the approval then has to come from + someone else. +- **`foundry/src/**` touched?** The second sign-off (MAINTAINING No. 4.2) is + on the PR, as an approval or a comment from @umeradl, @abrahamnash or a + security reviewer they named. +- **Mechanism change?** The mechanism sign-off is on the PR (MAINTAINING No. 4.2). +- No unresolved "Changes requested" from anyone (MAINTAINING No. 7). + +If any gate fails, report which one and stop. Don't use `--admin`. + +### A3. Merge + +On the operator's go-ahead: + +```bash +gh pr merge -R $R --merge +gh pr view -R $R --json state,mergedAt,mergeCommit --jq '{state, mergedAt, sha: .mergeCommit.oid}' +``` + +Then the outcome steps: **C1–C2** below. + +--- -Run this against the current `develop` HEAD, dirty tree and all — this is the -control measurement. If it's already red, stop and tell Umer; don't let a +## Path B — local merge + deviation commit (admins only) + +```bash +python3 .claude/scripts/din_peer_check.py admin # REFUSE → use path A +source .claude/scripts/din-env.sh +cd "$DIN_REPO" +``` + +Path B lands the PR in the `develop` checkout, which routinely has unrelated +uncommitted WIP. The flow never merges into a dirty tree and never lets that +WIP ride along inside the merge commit: it stashes it, merges cleanly, pops it +back, and treats whatever breaks as a separate, separately described deviation. + +In path B, `merge-fix-comment-update` and `din-merge-build-test` run against the +`develop` checkout instead of the review worktree (each skill's "Path B" notes). + +### B1. Baseline: confirm the checkout builds and tests pass before touching anything + +Run against the current `develop` HEAD, dirty tree and all. This is the control +measurement. If it's already red, stop and tell the operator; don't let a pre-existing failure get attributed to the PR later. ```bash cd foundry && npm ci # populate node_modules before any forge test (see root CLAUDE.md) -FOUNDRY_VIA_IR=false forge build && FOUNDRY_VIA_IR=false forge test -cd .. && pytest +forge build && forge test # real via_ir profile; the plain build no longer compiles develop +cd .. && "$DIN_PY" -m pytest -m "not integration" ``` -Use the fast build path (`FOUNDRY_VIA_IR=false`) for this go/no-go check per -`din-pr-review`'s guidance — same repo, same shortcut. Don't edit -`foundry.toml` to toggle it. +Don't edit `foundry.toml`. -## 2. Stash whatever is currently dirty +### B2. Stash whatever is currently dirty ```bash -git status --short # see what's here +git status --short git stash push -u -m "pre-merge-pr-" # only if the tree is dirty; -u for untracked ``` -If `git status --short` is empty, skip the stash — there's nothing to protect -the merge from and nothing to pop back later. Note in your own head (not a -comment yet) whether you stashed something, so step 4 isn't a surprise. - -## 3. Merge the PR into develop +If `git status --short` is empty, skip the stash. -Merge locally (real merge commit, not squash) — never `gh pr merge`, which -is a GitHub-side push that skips both the review gate and `din-push-develop`. -Check GitHub and a local dry run agree first: +### B3. Merge the PR into develop ```bash gh pr view --json mergeable,mergeStateStatus @@ -89,91 +171,86 @@ git merge --no-ff --no-commit FETCH_HEAD # stage only; resolv git diff --cached --stat ``` -**Stop here.** Summarize the staged merge file-by-file (conflicts and how -they were resolved, if any) and wait for Umer's go-ahead. Only then commit -with the contributor's real `--author` (see section 0) and a -`merge: PR No. (/) into develop` message. Capture the SHA -with `git rev-parse HEAD` immediately (don't defer this — it's easy to lose -track once the stash pop starts moving the tree around again). +**Stop here.** Summarize the staged merge file-by-file (conflicts and how they +were resolved) and wait for the operator's go-ahead. Then commit with the +contributor's real `--author` and a +`merge: PR No. (/) into develop` message, and capture the +SHA with `git rev-parse HEAD` immediately. -If Umer wants changes first, `git merge --abort` returns `develop` to its -pre-merge state with nothing committed. +If the operator wants changes first, `git merge --abort` returns `develop` to +its pre-merge state. -## 4. Pop the stash +### B4. Pop the stash ```bash git stash pop ``` -This is where develop-drift-vs-local-WIP interactions surface. Two distinct -things can happen and they're not the same: - -- **Git-level conflict markers from the pop itself** — resolve these first, - they're mechanical. +- **Conflict markers from the pop** — resolve these first; they're mechanical. - **Silent deviations** — code that applies cleanly but is now wrong because - the PR changed something the stashed WIP depended on (an API, a contract - interface, a manifest field, a test fixture). These won't show up as - conflict markers; find them by actually re-running the build/test suite - (step 5), not by re-reading the diff. - -If nothing was stashed in step 2, there's nothing to pop — go straight to -step 5 to confirm the merge alone is green. - -## 5. Rebuild and retest against the combined state + the PR changed something the WIP depended on. Find them by re-running the + build/tests (B5), not by re-reading the diff. -Same commands as step 1, now against merge + popped WIP: +### B5. Rebuild and retest against the combined state -```bash -cd foundry && FOUNDRY_VIA_IR=false forge build && FOUNDRY_VIA_IR=false forge test -cd .. && pytest -``` - -Compare against the step-1 baseline numbers. Anything newly broken here is a -deviation to fix, not a PR defect to report back to the contributor — the PR -itself already passed review. +Same commands as B1. Anything newly broken is a deviation to fix, not a PR +defect — the PR already passed review. -## 6. Commit the deviations +### B6. Commit the deviations -Once fixed and green, show Umer the deviation diff and wait for his -go-ahead, then commit it as its own commit — never amend the merge commit: +Show the operator the deviation diff, and on their go-ahead commit it as its +own commit — never amend the merge commit: ```bash git add # never -A; review `git status` first git commit -m "$(cat <<'EOF' -Co-Authored-By: umeradl -Co-Authored-By: Claude Sonnet 5 +Co-Authored-By: + EOF )" -git rev-parse HEAD # capture the real deviation SHA — never guess it later +git rev-parse HEAD ``` -If step 4/5 needed no fixes at all, there's no deviation commit — say so -explicitly in the outcome comment rather than omitting the section -ambiguously. +If B4/B5 needed no fixes, there's no deviation commit — say so explicitly in +the outcome comment. -## 7. Stop and get Umer's review before pushing +### B7. Push + +Summarize file-by-file (merge commit + deviation commit) and wait. Push only +on the operator's go-ahead, and only via `din-push-develop` (which uses the +admin bypass and logs the reason in Discussion 170). Then C1–C2. + +--- + +## C1. Own the `develop` CI run (both paths) + +The merger owns the CI run for the push to `develop` (MAINTAINING No. 4.6): + +```bash +bash .claude/skills/din-push-develop/scripts/watch-push-run.sh # Bash run_in_background: true +``` -Summarize file-by-file (merge commit contents + deviation commit contents), -then wait. Do not push until Umer says to, and then push only via -`din-push-develop`. +`din-push-develop` already does this for path B. If it goes red: ping the PR +author first; if they can't fix it the same working day, the operator fixes it +on top (through a PR) or reverts it (MAINTAINING No. 4.7), and logs it in +Discussion 170. -## 8. Post the outcome comment +## C2. Post the outcome comment -Once pushed, use -[`../din-pr-review/templates/outcome-comment.md`](../din-pr-review/templates/outcome-comment.md) -(same template `din-pr-review` defines as its 3rd comment — this skill is -what actually produces the state that template describes). Fill in: +Use [`../din-pr-review/templates/outcome-comment.md`](../din-pr-review/templates/outcome-comment.md): -- The real merge-commit and deviation-commit SHAs (`git rev-parse`, never - fabricated), linked to their GitHub commit URLs. -- `gh pr view --json state,mergedAt` to confirm GitHub agrees the PR shows - **MERGED**. -- The deviation section populated from what step 4/5 actually found — root - cause phrased as "develop moved since this branch was cut, in a way git's - conflict detection couldn't see," matching the template's own framing. -- Final build/test numbers from step 5, not step 1. +- The real merge-commit SHA, plus any fix or deviation commit SHAs (fetched, + never fabricated), linked to their GitHub commit URLs. +- `gh pr view --json state,mergedAt` showing **MERGED**. +- What deviated from the PR as opened: path A's fix commits on the branch, or + path B's deviation commit, with the root cause. +- Final build/test evidence: the PR's green `CI OK` run (path A), or B5's + numbers (path B), plus the `develop` push run from C1. +- Fill each file's "Actual merge proposal" row in the merge-proposal comment if + `din-merge-build-test` didn't already. It's the merger's field. +- Set the label: `merged` (path A) or `Merged Locally` (path B). Post via `gh api ... -F body=@` (capital `-F`), then re-fetch and check -the body content matches before considering this done. +the body matches. diff --git a/.claude/skills/din-pr-review/SKILL.md b/.claude/skills/din-pr-review/SKILL.md index de2f9c5b..43bc3548 100644 --- a/.claude/skills/din-pr-review/SKILL.md +++ b/.claude/skills/din-pr-review/SKILL.md @@ -1,23 +1,35 @@ --- name: din-pr-review -description: Use when reviewing a pull request against InfiniteZeroFoundation/DevNet's `develop` branch — "review PR #N", "review this branch for merge", or evaluating a contributor's PR before merging it into this repo. Encodes this repo's standing 3-comment GitHub workflow (deep verification → merge-proposal table → post-merge outcome) plus this project's specific gotchas (fast build path, GitHub-ID/attribution rules, table format). Not for generic diff review with no PR/GitHub-posting context — use the general `code-review` skill for that. +description: Use when reviewing a pull request against InfiniteZeroFoundation/DevNet's `develop` branch — "review PR #N", "review this branch for merge", or evaluating a contributor's PR before merging it into this repo. Any maintainer can run it, as long as they aren't the PR's author or a co-author (checked first with `.claude/scripts/din_peer_check.py`). Encodes this repo's standing 3-comment GitHub workflow (verification → merge-proposal table → post-merge outcome), the review-depth rule from Developer/MAINTAINING.md No. 4.2 (green CI counts as evidence; reproduce locally only what CI doesn't cover), the approve / request changes / recuse step, and this project's gotchas (via_ir build, GitHub-ID/attribution rules, table format). Not for generic diff review with no PR/GitHub-posting context — use the general `code-review` skill for that. --- # DIN DevNet PR review -This repo (`InfiniteZeroFoundation/DevNet`) reviews every contributor PR the same -way: verify for real (not just read), post a per-file merge-proposal table, then +This repo (`InfiniteZeroFoundation/DevNet`) reviews every PR the same +way: verify (not just read), post a per-file merge-proposal table, then close the loop with what actually happened once it's merged. This skill is the GitHub-posting workflow layered on top of code review — it assumes you've already read the diff and formed an opinion; it's about how that opinion gets verified and communicated in this repo specifically. +The process itself is [`Developer/MAINTAINING.md`](../../../Developer/MAINTAINING.md). +"The operator" below means the maintainer running this skill, i.e. the active +`gh` user (`gh api user --jq .login`). Setup: [`.claude/scripts/README.md`](../../scripts/README.md). + ## 0. Hard rules — check these before every step, not just at the end -- **Never commit or push without Umer reviewing first.** Prepare the diff/merge - result, summarize file-by-file, and ask before `git commit`/`git push`/ - `gh pr merge`. A `git commit` on `develop` has been observed to appear - pushed already — treat commit and push as one gated step. +- **Peer check first, and again before approving.** + ```bash + python3 .claude/scripts/din_peer_check.py pr + ``` + `REFUSE` means the operator is the author or a co-author (MAINTAINING No. 1, + No. 7). Stop, say so, and name a reviewer from MAINTAINING No. 3 instead. + Re-run it right before `gh pr review --approve`: new commits can add + co-authors. Never switch `gh` accounts to get past it. +- **Never commit, push, approve or merge without the operator's go-ahead.** + Prepare the result, summarize file-by-file, and ask first. A `git commit` on + `develop` has been observed to appear pushed already — treat commit and push + as one gated step. - **Never fabricate a GitHub-assigned ID.** Commit SHAs, `issuecomment-N`, `discussioncomment-N` — these are opaque and non-sequential per-thread. Always fetch the real value (`git rev-parse`, `gh api .../comments --jq @@ -32,25 +44,27 @@ verified and communicated in this repo specifically. - **Never write `#N`** for issue/PR/item numbers inside anything GitHub renders (comments, PR bodies, commit messages) — GitHub auto-links it to an unrelated issue. Write "No. 2", "No. 3", etc. -- **"Actual merge proposal" is Umer's field.** On the initial merge-proposal - comment it is always literally `Soon` in every row — grep the drafted - body for `Actual merge proposal | ` and confirm before posting. Put any - verification detail from your own trial merge in "Recommended merge - proposal" instead. +- **"Actual merge proposal" belongs to whoever performs the merge** + (MAINTAINING No. 4.3). On the initial merge-proposal comment it is always + literally `Soon` in every row — grep the drafted body for + `Actual merge proposal | ` and confirm before posting. Put any verification + detail from your own trial merge in "Recommended merge proposal" instead. ## 1. Set up an isolated review -Every review gets its own git worktree under `~/tempdir/DIN/PRs/`, on a branch +Every review gets its own git worktree under `$DIN_WORK/PRs/`, on a branch named `pr--review`. Never `gh pr checkout` in the main repo checkout and never work directly on `develop` — the main checkout stays clean so `git status` there means something. Do this as the first step of every review, even -if the PR is small or you only plan to read the diff; `~/tempdir/DIN/PRs/` is +if the PR is small or you only plan to read the diff; `$DIN_WORK/PRs/` is the record of what has been reviewed. ```bash +source .claude/scripts/din-env.sh # DIN_REPO, DIN_WORK, DIN_PY (see .claude/scripts/README.md) N= -WT=~/tempdir/DIN/PRs/PRreview_$N -cd ~/projects/devnet +WT=$DIN_WORK/PRs/PRreview_$N +mkdir -p "$DIN_WORK/PRs" +cd "$DIN_REPO" git fetch origin develop "pull/$N/head:pr-$N-review" # fork PRs work too — pull/N/head is a GitHub ref git worktree add "$WT" "pr-$N-review" # branch pr--review, dir PRreview_ cd "$WT" @@ -73,26 +87,41 @@ don't create a second one. The branch is throwaway, so hard-reset it to the new head (this also survives force-pushes): ```bash -cd ~/tempdir/DIN/PRs/PRreview_$N +cd $DIN_WORK/PRs/PRreview_$N git fetch origin "pull/$N/head" && git reset --hard FETCH_HEAD ``` If `git worktree add` says the branch or path already exists, run `git worktree list` first — the review may already be set up. -## 2. Fast build/test path +## 2. What to run locally — and what CI already covers + +**Review depth (MAINTAINING No. 4.2).** Required checks green against an +up-to-date `develop` count as evidence for anything CI covers: cite the run +(`gh pr checks `, link the job) instead of re-running it. **Reproduce +locally only what CI doesn't cover:** + +- storage layout and upgrade safety in `foundry/src/**` +- deploy and upgrade scripts in `foundry/script/**` +- `dincli` flows against a live chain or IPFS, until the integration job is a + required check (MAINTAINING No. 4.9) +- any claimed behaviour that no test exercises + +If CI on the PR is red, stale against `develop`, or never ran, CI isn't +evidence: say so, and either ask the author to update the branch or run the +relevant suite yourself. + +When you do run things locally: - **Foundry:** `cd foundry && npm ci` first (see root `CLAUDE.md` — `forge test`'s FFI shells out to `npx @openzeppelin/upgrades-core` and races - without a populated `node_modules`). For a go/no-go compile+test check, - skip `via_ir` — it adds minutes for no signal on most PRs: - `FOUNDRY_VIA_IR=false forge build` / `forge test`. **Never `sed`/edit the - tracked `foundry.toml` to toggle this** — env-var override only. If the - plain build fails, don't call it a PR defect until it's confirmed against - the real `via_ir = true` profile too (some contract may need it to avoid - stack-too-deep). -- **dincli:** `pytest` from repo root (editable install via `pip install -e .` - if not already done in the venv). + without a populated `node_modules`). Use the real profile: `forge build` / + `forge test`. The plain (non-`via_ir`) build no longer compiles `develop` + (stack too deep), so `FOUNDRY_VIA_IR=false` is not a shortcut any more. + **Never `sed`/edit the tracked `foundry.toml`.** +- **dincli:** `"$DIN_PY" -m pytest -m "not integration"` from the worktree + root (editable install via `pip install -e .` if not already done in the + venv). - Before evaluating contract-level findings, check `Documentation/technical/audits/foundry-src-security-review.md` (Similoluwa's baseline audit of `foundry/src/`, findings C-1/C-2/H-1/H-2 etc.) so you don't @@ -108,10 +137,12 @@ Post these in order, using the templates in `templates/`. Skip Comment 3 until the merge is actually pushed. 1. **[`templates/verification-comment.md`](templates/verification-comment.md)** - — deep verification. Reproduce every checkable claim in the PR with a real - command (run it, don't just read it); prefer going beyond the PR's own - verification (forced edge cases + real sampling, break-then-fix a - regression test, exact link/AST counts). + — verification. A "Claimed / Verified" pair for every checkable claim in + the PR. A claim is verified by linking the CI job that covers it, or by a + real local command where step 2 requires one (run it, don't just read it). + Where you do reproduce, prefer going beyond the PR's own verification + (forced edge cases + real sampling, break-then-fix a regression test, exact + link/AST counts). Never accept "done" or "landed" in a comment as proof. 2. **[`templates/merge-proposal-comment.md`](templates/merge-proposal-comment.md)** — per-file breakdown. One small key/value table per file (`#### \`path\`` heading + 2-column table), not one wide multi-column table — this repo's @@ -120,7 +151,39 @@ the merge is actually pushed. after the merge actually lands on `develop`, replacing the table's predictions with the real commit SHAs and any deviation needed. -## 4. Re-review after fixes +After comments 1–2 are posted, add the `Reviewed` label +(`gh pr edit --add-label Reviewed`). + +**Trivial PRs** (typo, dead link, comment fix): skip the three comments and go +straight to step 4 with an approval that says "trivial" (MAINTAINING No. 4.3). + +## 4. Approve, request changes, or recuse + +Once comments 1–2 are up, record the verdict on GitHub — the `develop` ruleset +counts only a GitHub approval: + +```bash +python3 .claude/scripts/din_peer_check.py pr # again: commits may have changed +gh pr review --approve -b ">" +gh pr review --request-changes -b "" +``` + +- **Recusal is a valid outcome.** If the PR is outside the operator's depth, + don't approve. Post "out of my depth — @" and stop (MAINTAINING No. 3). +- **`foundry/src/**` needs two keys** (MAINTAINING No. 4.2): the peer's + approval plus a second sign-off from @umeradl or @abrahamnash, or a security + reviewer they route it to. If the operator is the peer, request the second + key; don't merge on one. +- **Mechanism changes** (slashing, tokenomics, fees, scoring) also need the + mechanism sign-off (MAINTAINING No. 4.2): @umeradl's approval, or + @abrahamnash's for Umer's own PRs. +- **If you pushed fixes onto the PR**, you're now a co-author: the final + approval has to come from someone else (MAINTAINING No. 7). The peer check + will say so. + +Get the operator's go-ahead before submitting the review. + +## 5. Re-review after fixes If the author pushes fixes after Comment 1/2, cite both SHAs in the follow-up: `gh api repos/InfiniteZeroFoundation/DevNet/pulls//commits` to find the @@ -128,18 +191,8 @@ commit that was HEAD when you last reviewed vs. current HEAD — e.g. "Reviewed the fix commits: `d8dc1c8` (reviewed) → `eb68952` (current HEAD)." State "no new commits since review — still at ``" if nothing changed. -## 5. Manual merges (copying files instead of merging the branch) - -When merging by hand rather than `gh pr merge` (e.g. resolving conflicts -yourself), preserve real attribution: - -```bash -git fetch origin refs/pull//head -git log --format='%an <%ae>' -1 FETCH_HEAD # the contributor's real identity -git commit --author=" " ... -``` +## 6. Merging -Trailers on that commit: `Co-Authored-By: umeradl ` -for fixes Umer/Claude applied on top, plus the standard Claude attribution -trailer for this session. Don't credit the contributor in prose only — use the -real `--author` field and `git log` identity, not a guessed noreply address. +Merging is `din-pr-merge`: path A (GitHub-side merge commit by the reviewer, +the default) or path B (local merge, admins only). Attribution rules for any +commit the merger writes are there too. diff --git a/.claude/skills/din-pr-review/templates/merge-proposal-comment.md b/.claude/skills/din-pr-review/templates/merge-proposal-comment.md index 8e55b551..3fff651c 100644 --- a/.claude/skills/din-pr-review/templates/merge-proposal-comment.md +++ b/.claude/skills/din-pr-review/templates/merge-proposal-comment.md @@ -46,8 +46,9 @@ matches GitHub's own status.> with a row per file — only use a normal wide table when the field list is genuinely short (2-4 columns). - **Every "Actual merge proposal" cell is literally `Soon`** on this initial - post. This is Umer's field to fill later, per-file, once he's made a call — - never pre-populate it with your own trial-merge outcome. Grep the drafted + post. It's filled in later, per file, by whoever performs the merge + (`Developer/MAINTAINING.md` No. 4.3) — never pre-populate it with your own + trial-merge outcome. Grep the drafted body for `Actual merge proposal | ` and confirm every row says `Soon` before posting. - The "Functionality — how & why" row sits right after "Diff" and is filled in diff --git a/.claude/skills/din-pr-review/templates/outcome-comment.md b/.claude/skills/din-pr-review/templates/outcome-comment.md index 4cfbcf75..01e641e5 100644 --- a/.claude/skills/din-pr-review/templates/outcome-comment.md +++ b/.claude/skills/din-pr-review/templates/outcome-comment.md @@ -1,10 +1,12 @@ # Template 3 — Actual-outcome comment (post-merge, after pushing) -Posted only after the merge has actually landed on `develop` (pushed). Goal: -replace the table's predictions with what actually happened. +Posted by the merger, only after the merge has actually landed on `develop`. +Goal: replace the table's predictions with what actually happened. Works for +both merge paths (`din-pr-merge`): path A (GitHub-side merge commit, fixes +pushed onto the PR branch first) and path B (local merge + deviation commit). ``` -## Actual outcome — PR # merged [+ deviation applied] (pushed) +## Actual outcome — PR No. merged [+ fixes / deviation applied] This supersedes the [pre-merge review comment]() above with what actually happened when resolving this PR against `develop`. @@ -12,11 +14,12 @@ above with what actually happened when resolving this PR against `develop`. ** commit(s), on `origin/develop`:** 1. **[``]()** — real merge of this PR - (merge commit, not squash). GitHub agrees: PR shows **MERGED**. -2. **[``]()** — follow-up deviation - commit, same pattern as prior deviation commits. (omit if no deviation - needed) + (merge commit, not squash; path A: merged on GitHub by after + 's approval of ``). GitHub agrees: PR shows **MERGED**. +2. **[``]()** — path A: the + review-fix commit pushed onto the PR branch before the merge; path B: the + follow-up deviation commit on `develop`. (omit if none was needed) ### Files unchanged from the PR ( of ) @@ -26,7 +29,7 @@ above with what actually happened when resolving this PR against `develop`. -### Files deviated by `` ( of ) (omit if none) +### Files changed by `` ( of ) (omit if none) | File | What changed vs. this PR's merged version | |---|---| @@ -40,8 +43,10 @@ branch was cut.> ### Verification - + **Local vs. GitHub agree**: diff --git a/.claude/skills/din-pr-review/templates/verification-comment.md b/.claude/skills/din-pr-review/templates/verification-comment.md index 1cb31ab8..d6efb3a0 100644 --- a/.claude/skills/din-pr-review/templates/verification-comment.md +++ b/.claude/skills/din-pr-review/templates/verification-comment.md @@ -1,19 +1,25 @@ # Template 1 — Deep verification / review comment -Posted first, before the merge-proposal table. Goal: reproduce every checkable -claim in the PR with real execution, not just reading. +Posted first, before the merge-proposal table. Goal: a "Claimed / Verified" +pair for every checkable claim in the PR. A claim is verified by linking the +CI job that covers it (green against an up-to-date `develop`), or by running it +locally where `Developer/MAINTAINING.md` No. 4.2 requires that: storage layout +and upgrade safety in `foundry/src/**`, deploy/upgrade scripts, `dincli` flows +against a live chain or IPFS, and any behaviour no test exercises. ``` Reviewed against `develop` in an isolated worktree (branch applies cleanly on top of current tip, ``, commit(s), no conflicts — confirmed by both `git merge-tree` locally and GitHub's own `mergeable`/ -`mergeStateStatus`). Ran the actual rather than just reading it. +`mergeStateStatus`). CI on ``: [`CI OK` green]() — covers +. Reproduced locally: . **Claimed:** -**Verified — :** :** @@ -41,6 +47,6 @@ that didn't hold up).> - Structural claims (AST equivalence, "this file doesn't exist on `develop` yet", link/reference counts) are cheap to verify exactly — do it with a real command, cite the exact output, don't approximate. -- For Solidity changes, use the fast build path (`FOUNDRY_VIA_IR=false`) - unless validating deploy-exact behavior (gas numbers, bytecode) — see the - main SKILL.md. +- For Solidity changes run locally, use the real `via_ir` profile + (`forge build` / `forge test`); the plain build no longer compiles `develop` + — see the main SKILL.md. diff --git a/.claude/skills/din-push-develop/README.md b/.claude/skills/din-push-develop/README.md index bf0c9f88..7b5cdb84 100644 --- a/.claude/skills/din-push-develop/README.md +++ b/.claude/skills/din-push-develop/README.md @@ -1,6 +1,8 @@ # din-push-develop -A Claude Code skill for pushing to `develop` with CI checks before and after the push. It runs a local copy of CI on the exact commit, pushes only if that's green, then watches the GitHub `push` run and alerts if it goes red or never starts. +A Claude Code skill for pushing to `develop` with CI checks before and after the push. It runs a local copy of CI on the exact commit, pushes only if that's green, logs the admin-bypass reason in Discussion 170, then watches the GitHub `push` run and alerts if it goes red or never starts. + +Only admins can push to `develop` directly: the `develop` ruleset requires a PR for everyone else ([`Developer/MAINTAINING.md`](../../../Developer/MAINTAINING.md) No. 2, No. 4.4). Everyone can use `scripts/watch-push-run.sh`, which `din-pr-merge` uses to watch the `develop` run after a GitHub-side merge. The instructions Claude follows are in [`SKILL.md`](SKILL.md). This README covers setup: what you need on your machine to use it. @@ -17,7 +19,7 @@ Background: [PR No. 168](https://github.com/InfiniteZeroFoundation/DevNet/pull/1 ## Setup -### 1. Register the guard hook (per machine, not committed) +### 1. Register the guard hook (per machine, not committed, admins only) The skill is committed and loads automatically. The **hook is not**: it lives in `.claude/settings.local.json`, which is untracked. Without it the skill still works, but Claude can push to `develop` without going through it. @@ -42,17 +44,16 @@ To check it's active, ask Claude to run `git push --dry-run origin develop`. It It only affects Claude's Bash tool. Pushes you type in your own terminal are never blocked. -### 2. Adjust machine-specific paths +### 2. Machine-specific paths -`precheck.sh` hard-codes paths from the maintainer's machine. Change them at the top of the script if yours differ: +`precheck.sh` reads its paths from [`.claude/scripts/din-env.sh`](../../scripts/din-env.sh). Export your own values if the defaults don't match your machine (see [`.claude/scripts/README.md`](../../scripts/README.md)): -| Variable / path | Default | Needs | +| Variable | Used for | Needs | |---|---|---| -| `REPO` | `~/projects/devnet` | the main checkout | -| `WT` | `~/tempdir/DIN/push-check` | reusable detached worktree (created on first run) | -| `PY` | `~/my_venvs/torchenv/bin/python` | a Python 3.12 venv with `torch` and `pip install -e ".[test]"` | -| logs | `~/tempdir/DIN/push-check-logs/.log` | full output of each precheck | -| `~/.nvm/nvm.sh` | sourced for the Solidity job | Node 20 (`npm`, `npx`) | +| `DIN_REPO` | the main checkout | | +| `DIN_WORK` | `$DIN_WORK/push-check` (reusable detached worktree) and `$DIN_WORK/push-check-logs/.log` | | +| `DIN_PY` | Docs + Python jobs | a Python 3.12 venv with `torch` and `pip install -e ".[test]"` | +| `DIN_NVM` | sourced for the Solidity job, if present | Node 20 (`npm`, `npx`) | You also need `forge` on `PATH` (the version CI pins is `FOUNDRY_VERSION` in `ci.yml`) and an authenticated `gh` for the watcher. diff --git a/.claude/skills/din-push-develop/SKILL.md b/.claude/skills/din-push-develop/SKILL.md index 037b5d93..3df63fd2 100644 --- a/.claude/skills/din-push-develop/SKILL.md +++ b/.claude/skills/din-push-develop/SKILL.md @@ -1,27 +1,30 @@ --- name: din-push-develop -description: Use whenever Umer says to push to `develop` on InfiniteZeroFoundation/DevNet — "push to develop", "push it", "go ahead and push" after a merge/commit on develop, or the push step at the end of din-pr-merge. Runs a local mirror of CI on the exact commit being pushed (in a dedicated worktree, empty HOME like the runner), pushes only if it's green, then watches the GitHub Actions `push` run in the background and alerts Umer if it goes red or never starts. A PreToolUse hook blocks raw `git push` to develop so this skill is the only path. Not for pushing feature/PR branches, and never a substitute for Umer's go-ahead to push. +description: Use whenever an admin pushes directly to `develop` on InfiniteZeroFoundation/DevNet — "push to develop", "push it", "go ahead and push" after a path-B merge/commit on develop (din-pr-merge B7) or an emergency fix. The `develop` ruleset blocks direct pushes for everyone except the admin bypass (Developer/MAINTAINING.md No. 4.4), so non-admins merge through PRs instead (din-pr-merge path A). Runs a local mirror of CI on the exact commit being pushed (in a dedicated worktree, empty HOME like the runner), pushes only if it's green, logs the bypass reason in Discussion 170, then watches the GitHub Actions `push` run in the background and alerts the operator if it goes red or never starts. A PreToolUse hook blocks raw `git push` to develop so this skill is the only path. Not for pushing feature/PR branches, and never a substitute for the operator's go-ahead to push. --- # Push to develop, CI-checked -`develop` has two gaps this skill closes: -- Most merges land by local merge + direct push, and admins bypass the required `CI OK` check. -- Until PR No. 168, pushes had no CI run at all. +Normal merges land through a PR (`din-pr-merge` path A), where the `develop` ruleset requires an approval, `CI OK` and an up-to-date branch. A direct push skips all of that, so it's reserved for admins using the bypass: path-B local merges and emergency fixes (Developer/MAINTAINING.md No. 4.4). This skill puts the missing checks back around such a push: a local CI mirror before it, and a watch on the GitHub `push` run after it. -The rule (PR 168 action item 2, [discussion No. 170](https://github.com/InfiniteZeroFoundation/DevNet/discussions/170)): Umer makes sure every `develop` push passes CI, or adds a fix commit on top. If a red run is left unaddressed for ~6h and more commits land on top, Santiago and Robbert open an incident thread in No. 170. This skill is how Umer keeps his half of that rule. +The rule ([Developer/MAINTAINING.md](../../../Developer/MAINTAINING.md) No. 4.6, [discussion No. 170](https://github.com/InfiniteZeroFoundation/DevNet/discussions/170)): whoever pushes or merges owns that push's CI run and makes sure it's fixed the same working day. If a red run is left unaddressed for ~6h and more commits land on top, the other maintainers open an incident thread in No. 170. + +"The operator" means the maintainer running the skill (the active `gh` user). Setup: [`README.md`](README.md) and [`.claude/scripts/README.md`](../../scripts/README.md). ## 0. Gate -- **Only run this after Umer has said to push.** Invoking the skill is not permission to push; the "wait for review before commit/push" rule still applies. If the push request came from a flow like din-pr-merge, make sure the file-by-file review already happened. -- Confirm you are pushing `develop` → `origin/develop` from `~/projects/devnet`, and that it is a fast-forward: +- **Admins only.** `python3 .claude/scripts/din_peer_check.py admin`. `REFUSE` → don't push; open a PR and use `din-pr-merge` path A. +- **Only run this after the operator has said to push.** Invoking the skill is not permission to push; the "wait for review before commit/push" rule still applies. If the push request came from a flow like din-pr-merge, make sure the file-by-file review already happened. +- **Every bypass gets a reason.** Before pushing, have a one-line reason ready (e.g. "path B merge of PR No. N: ABI regeneration against moved develop", "emergency fix for red develop at "). Step 2 logs it in Discussion 170. +- Confirm you are pushing `develop` → `origin/develop` from `$DIN_REPO`, and that it is a fast-forward: ```bash - cd ~/projects/devnet && git fetch -q origin develop + source .claude/scripts/din-env.sh + cd "$DIN_REPO" && git fetch -q origin develop git branch --show-current # must be develop git merge-base --is-ancestor origin/develop develop && echo ff-ok git log --oneline origin/develop..develop # exactly what will land ``` - If it's not a fast-forward (someone pushed in between), stop and ask Umer. Never force-push develop. + If it's not a fast-forward (someone pushed in between), stop and ask the operator. Never force-push develop (the ruleset blocks it anyway). - Uncommitted WIP in the main checkout is normal here and is **not** pushed. The precheck runs on the commit, not the working tree. ## 1. Local CI mirror on the exact commit @@ -32,26 +35,28 @@ bash .claude/skills/din-push-develop/scripts/precheck.sh --all # force the ``` What it does: -- Checks out the commit in a reusable detached worktree, `~/tempdir/DIN/push-check`, and mirrors `.github/workflows/ci.yml`: +- Checks out the commit in a reusable detached worktree, `$DIN_WORK/push-check`, and mirrors `.github/workflows/ci.yml`: - **Docs:** `check_doc_links.py Documentation` - - **Python:** `python -m pytest -m "not integration"` with an **empty HOME** (torchenv), so tests that read `~/.config/dincli` fail here the way they fail on the runner - - **Solidity:** only if `foundry/`, `hardhat/` or `.github/` changed. Uses the **real `via_ir` profile**, not the fast review path, because the goal is to predict CI exactly. -- The log goes to `~/tempdir/DIN/push-check-logs/.log`. + - **Python:** `$DIN_PY -m pytest -m "not integration"` with an **empty HOME**, so tests that read `~/.config/dincli` fail here the way they fail on the runner + - **Solidity:** only if `foundry/`, `hardhat/` or `.github/` changed. Uses the **real `via_ir` profile** and the contract size gate, as CI does. +- The log goes to `$DIN_WORK/push-check-logs/.log`. - Ruff and forge lint are advisory in CI, so they are skipped here. Run it with `run_in_background` when the Solidity job is included (it takes several minutes). -**If it fails:** don't push. Report the failing step and the key error lines to Umer, and propose a fix commit. Don't create one without his go-ahead. +**If it fails:** don't push. Report the failing step and the key error lines to the operator, and propose a fix commit. Don't create one without their go-ahead. -## 2. Push +## 2. Push, and log the bypass ```bash -cd ~/projects/devnet && DIN_PUSH_DEVELOP=1 git push origin develop +cd "$DIN_REPO" && DIN_PUSH_DEVELOP=1 git push origin develop SHA=$(git rev-parse develop) ``` The `DIN_PUSH_DEVELOP=1` prefix is what lets the guard hook (below) pass. Only use it from this step. +Then post the reason as a one-line comment in [Discussion 170](https://github.com/InfiniteZeroFoundation/DevNet/discussions/170): `Admin bypass: — .` Use `gh api graphql` `addDiscussionComment` with `-F body=@`, and re-fetch to check it posted. + ## 3. Watch the GitHub push run in the background First check that the trigger exists on develop. If PR 168 isn't merged yet, there is no push run to wait for: @@ -66,7 +71,7 @@ If it's present, start the watcher with Bash `run_in_background: true`: bash .claude/skills/din-push-develop/scripts/watch-push-run.sh "$SHA" ``` -Tell Umer in one line that the push landed and the run is being watched, then carry on with other work. You are re-invoked when the watcher exits. Its last line is one of: +Tell the operator in one line that the push landed and the run is being watched, then carry on with other work. You are re-invoked when the watcher exits. Its last line is one of: | Last line | Meaning | Do | |---|---|---| @@ -75,12 +80,12 @@ Tell Umer in one line that the push landed and the run is being watched, then ca | `WATCH_ERROR ` | the watcher couldn't read a final result (GitHub unreachable for ~10 min, or the run is still going after ~90 min). **Not a CI verdict.** | No notification. Check the run yourself (`gh run view --json status,conclusion,jobs`), and re-run the watcher if it's still in progress. Then handle the real result as in the rows above. | | `NO_RUN ` | no push run started within ~5 min | **`PushNotification`**: `develop push has no CI run`. Check the Actions tab for a disabled workflow, a YAML error, or a runner outage. | -For red or missing runs, remind Umer of the clock: if it's not fixed within ~6h and more commits land on top, the backup maintainers will open a thread in discussion No. 170. +For red or missing runs, remind the operator of the clock: fix it the same working day (MAINTAINING No. 4.6); if it's not fixed within ~6h and more commits land on top, the other maintainers open a thread in discussion No. 170. ## Guard hook `scripts/guard.py` is registered as a `PreToolUse` Bash hook in `.claude/settings.local.json`. It blocks any `git push` whose destination is `develop`, whether given explicitly, as `HEAD:develop`, or as a bare `git push` while on develop, unless the command carries `DIN_PUSH_DEVELOP=1`. If you get blocked, that's the signal to use this skill. -It only covers Claude's Bash tool. Umer's own terminal pushes are unaffected. +It only covers Claude's Bash tool. Pushes typed in your own terminal are unaffected (the GitHub ruleset still applies to them). `settings.local.json` is untracked, so the hook has to be registered on each machine. If a push to develop is *not* blocked when it should be, the hook is missing: see [`README.md`](README.md) for the snippet and the machine-specific paths `precheck.sh` expects. diff --git a/.claude/skills/din-push-develop/scripts/precheck.sh b/.claude/skills/din-push-develop/scripts/precheck.sh index f41ed77a..c9cabade 100644 --- a/.claude/skills/din-push-develop/scripts/precheck.sh +++ b/.claude/skills/din-push-develop/scripts/precheck.sh @@ -7,13 +7,16 @@ # --all run the Solidity job even if nothing under foundry/ or hardhat/ changed set -uo pipefail -REPO=~/projects/devnet -WT=~/tempdir/DIN/push-check -PY=~/my_venvs/torchenv/bin/python +# Machine paths come from .claude/scripts/din-env.sh (DIN_REPO, DIN_WORK, DIN_PY, DIN_NVM). +# shellcheck disable=SC1091 +source "$(dirname "$0")/../../../scripts/din-env.sh" +REPO=$DIN_REPO +WT=$DIN_WORK/push-check +PY=$DIN_PY ALL=0 [ "${1:-}" = "--all" ] && { ALL=1; shift; } SHA=$(git -C "$REPO" rev-parse "${1:-HEAD}") || exit 2 -LOGDIR=~/tempdir/DIN/push-check-logs +LOGDIR=$DIN_WORK/push-check-logs mkdir -p "$LOGDIR" LOG="$LOGDIR/${SHA:0:12}.log" : > "$LOG" @@ -56,13 +59,14 @@ step "python: pytest -m 'not integration'" \ if [ $ALL = 1 ] || grep -qE '^(foundry|hardhat|\.github)/' <<<"$CHANGED"; then # shellcheck disable=SC1090 - source ~/.nvm/nvm.sh >/dev/null + [ -s "$DIN_NVM" ] && source "$DIN_NVM" >/dev/null step "solidity: submodules" git submodule update --init --recursive -q step "solidity: foundry npm ci" bash -c "cd foundry && npm ci --silent" # forge clean first: the worktree is reused, and stale build-info from an # earlier commit's build makes the OZ upgrade-validation FFI tests fail with # "Found multiple contracts with name ..." (CI builds from a fresh checkout). step "solidity: forge build (real via_ir profile, as CI)" bash -c "cd foundry && forge clean && forge build" + step "solidity: contract size gate" bash -c "cd foundry && python3 ../.github/scripts/contract_size_gate.py" step "solidity: forge test" bash -c "cd foundry && forge test" step "solidity: hardhat npm ci" bash -c "cd hardhat && npm ci --silent" step "solidity: hardhat compile" bash -c "cd hardhat && npx hardhat compile" diff --git a/.claude/skills/din-task-close/SKILL.md b/.claude/skills/din-task-close/SKILL.md index 4208fc0a..752eb0f3 100644 --- a/.claude/skills/din-task-close/SKILL.md +++ b/.claude/skills/din-task-close/SKILL.md @@ -1,6 +1,6 @@ --- name: din-task-close -description: Use when deciding whether a Tasks-category GitHub Discussion for a `Developer/tasks/task_*.md` file can be closed — "can task_N be closed", "close discussion #N", "is task_ done", or a batch "can tasks 6-10 be closed" ask. Verifies every deliverable in the task file's own checklist and every PR it names against the real state of `develop` (not against discussion comments claiming "done"/"landed" — contributors have said that about unmerged branches before), then either posts a closing comment and closes the discussion as Resolved, or reports exactly what's still blocking and leaves it open. Not for reviewing a PR itself (`din-pr-review`) or for the mechanical per-file merge follow-through (`merge-fix-comment-update`). +description: Use when deciding whether a Tasks-category GitHub Discussion for a `Developer/tasks/task_*.md` file can be closed — "can task_N be closed", "close discussion #N", "is task_ done", or a batch "can tasks 6-10 be closed" ask. Any maintainer can run it, as long as the task isn't assigned to them (checked first with `.claude/scripts/din_peer_check.py`). Verifies every deliverable in the task file's own checklist and every PR it names against the real state of `develop` (not against discussion comments claiming "done"/"landed" — contributors have said that about unmerged branches before), then either posts a closing comment, closes the discussion as Resolved and opens the follow-up PR that updates the task file's `Status:` line, or reports exactly what's still blocking and leaves it open. Not for reviewing a PR itself (`din-pr-review`) or for the mechanical per-file merge follow-through (`merge-fix-comment-update`). --- # DIN task-discussion closure @@ -12,12 +12,23 @@ post one comment that recaps the evidence, and close the discussion — or, if something's still missing, say exactly what and leave it open rather than closing on the strength of a comment thread that says "done." +The process is [`Developer/MAINTAINING.md`](../../../Developer/MAINTAINING.md) +No. 5.3: closing is a peer's check, never the assignee's. "The operator" means +the maintainer running the skill (the active `gh` user). + **Discussion numbers do not match task numbers 1:1.** task_210726_5 is discussion #47, task_100926_11 is discussion #135, etc. — always resolve the number, never assume task N ↔ discussion N. ## 0. Hard rules +- **Peer check first**, once the task file is resolved (step 1): + ```bash + python3 .claude/scripts/din_peer_check.py task Developer/tasks/ + ``` + `REFUSE` means the task is assigned to the operator. Stop and name a peer. + Never switch `gh` accounts to get past it. If a PR the task depends on was + co-authored by the operator, treat that the same way. - **Verify against code and merge state, never against discussion-thread claims.** "Landed"/"implemented" in a comment has meant "pushed to an open, unmerged PR branch" before (task_300726_8/#67 — Santiago's comments said @@ -102,7 +113,7 @@ full stop — regardless of what any comment says about it. git log --oneline --all --grep="" -i | grep -i merge # find the merge commit(s) ``` -If Umer's own review comments (posted by `din-pr-review`) flagged blocking +If the PR's review comments (posted by `din-pr-review`) flagged blocking items on that PR, re-check each one was actually fixed on `develop` — read the fix commit, don't just trust a "fixed" reply in the thread. @@ -119,10 +130,10 @@ forge test --match-contract # relevant suite(s) actually pass grep -n "function " foundry/src/.sol ``` -**Watch for later deliberate deviations.** Umer sometimes lands a PR then -follows up with his own commit that intentionally changes scope (e.g. scoping -a fee path back to ETH-only after a design direction shifted, per -`p3-issues-defer-fee-changes`/`eth-burn-din-only`-style calls). If a +**Watch for later deliberate deviations.** A maintainer sometimes lands a PR +then follows up with a commit that intentionally changes scope (e.g. scoping +a fee path back to ETH-only after a design direction shifted, or routing ETH +"burn" shares to the Treasury because burning applies to DIN only). If a deliverable that was clearly implemented in the merged PR is now missing on `develop`, check `git log --oneline -- ` for a later commit explaining why before treating it as a regression — cite that commit in the closing @@ -168,6 +179,10 @@ mutation($discussionId: ID!) { — #47, #49, #76, #105, #106, #135 — used it; `OUTDATED`/`DUPLICATE` are for non-task discussions that stopped being relevant rather than got done). +Then update the task file's `Status:` line through a small PR (MAINTAINING +No. 5.3), e.g. `**Status:** Done (closed , Discussion No. )`. Like +any PR it needs a peer's approval; the operator doesn't merge it themselves. + ## 6b. If something's still open — report, don't close State plainly which deliverable/PR is the blocker, cite the evidence (PR @@ -181,8 +196,9 @@ closed, on a "6-10 can be closed" instruction that only covered 6-10). ## 7. Report back -Summarize for Umer: which discussion(s) got closed (with links), which PRs/ +Summarize for the operator: which discussion(s) got closed (with links), the +`Status:` PR, which PRs/ commits were the evidence, any deliberate deviation called out, and — for anything left open — the exact blocker. Don't offer to update project memory yourself unless asked; just make sure the report contains everything needed -for the state to be captured correctly if Umer wants it remembered. +for the state to be captured correctly if the operator wants it remembered. diff --git a/.claude/skills/merge-fix-comment-update/SKILL.md b/.claude/skills/merge-fix-comment-update/SKILL.md index d9b4695a..0fd41edb 100644 --- a/.claude/skills/merge-fix-comment-update/SKILL.md +++ b/.claude/skills/merge-fix-comment-update/SKILL.md @@ -1,6 +1,6 @@ --- name: merge-fix-comment-update -description: Use per-file, after `din-pr-review` has posted its verification and merge-proposal comments for a PR — "merge and fix ", "apply the fix for ", "land per the merge proposal". Input is one relative file path (e.g. `foundry/src/DinCoordinator.sol`); invoke it again for each other file. For that one file: applies the PR's diff into the `develop` cwd per the merge-proposal comment's "Recommended merge proposal", applies the fix described in "Pending proposal", and records what it did in the PR's merge-fix log. Code changes only — it does NOT build, run forge/pytest, or edit the GitHub comment; once every file is through, Umer runs `din-merge-build-test`, which builds/tests everything, fixes until green, and then updates the comment for all files. Stops and asks Umer whenever the row's proposal implies a decision, a conflict, or a judgment call — never guesses through those. Not for merging a whole PR at once (use `din-pr-merge`) and not for the initial review (use `din-pr-review`). +description: Use per-file, after `din-pr-review` has posted its verification and merge-proposal comments for a PR — "merge and fix ", "apply the fix for ", "land per the merge proposal". Input is one relative file path (e.g. `foundry/src/DinCoordinator.sol`); invoke it again for each other file. For that one file it applies the fix described in "Pending proposal" and records what it did in the PR's merge-fix log. Path A (default, any maintainer, Developer/MAINTAINING.md No. 4.4): the fix goes onto the PR branch in the review worktree, and `din-pr-merge` later pushes it onto the PR. Path B (admins only): the PR's diff is applied into the `develop` checkout per "Recommended merge proposal", then the fix on top. Code changes only — it does NOT build, run forge/pytest, commit, or edit the GitHub comment; once every file is through, the operator runs `din-merge-build-test`, which builds/tests everything, fixes until green, and then updates the comment for all files. Stops and asks the operator whenever the row's proposal implies a decision, a conflict, or a judgment call — never guesses through those. Not for merging a whole PR at once (use `din-pr-merge`) and not for the initial review (use `din-pr-review`). --- # Merge-fix-comment-update @@ -8,31 +8,45 @@ description: Use per-file, after `din-pr-review` has posted its verification and `din-pr-review` produces a merge-proposal comment (Template 2) with one table per changed file: what the diff does, a recommended merge proposal, and (for files that need work) a pending proposal describing the fix. This skill does -the file-by-file follow-through on that table, one file per invocation: -apply the file into `develop`, apply its fix, log what was done — never a -whole-PR merge, never more than the one file passed in. +the file-by-file follow-through on that table, one file per invocation: apply +its fix, log what was done — never a whole-PR merge, never more than the one +file passed in. + +"The operator" means the maintainer running the skill (the active `gh` user). +The process is [`Developer/MAINTAINING.md`](../../../Developer/MAINTAINING.md); +setup is [`.claude/scripts/README.md`](../../scripts/README.md). **This skill only changes code.** No `forge build`, no `forge test`, no -`pytest`, no comment PATCH. Building and testing per file wasted minutes on -every pass (a full `via_ir` build each time) and re-tested the same tree over -and over. The pipeline is: +`pytest`, no commit, no comment PATCH. Building and testing per file wasted +minutes on every pass (a full `via_ir` build each time) and re-tested the same +tree over and over. The pipeline is: 1. `merge-fix-comment-update ` — once per file (this skill). 2. `din-merge-build-test` — once, after every file is through: full build + forge tests + pytest, fix whatever breaks until everything is green, then update "Actual merge proposal"/"Pending proposal" for all files in one comment edit. -3. `din-pr-merge` — the real commit, after Umer reviews. +3. `din-pr-merge` — the commit and push, after the operator reviews. + +**Where the fix goes depends on the merge path** (MAINTAINING No. 4.4, +`din-pr-merge`): + +| | Path A — default, any maintainer | Path B — admins only | +|---|---|---| +| Target tree (`$TARGET`) | The review worktree `$DIN_WORK/PRs/PRreview_`, on `pr--review` at the PR head | The `develop` checkout `$DIN_REPO` | +| The PR's own diff | Already there — skip step 3 | Applied file by file (step 3) | +| What happens next | `din-pr-merge` A1 commits the fixes and pushes them onto the PR branch; someone else then gives the final approval (the last-push rule) | `din-pr-merge` B lands the merge plus a deviation commit | -This is the same shape of work as `din-pr-merge`, but scoped to a single file -landing directly on top of `develop`'s working tree (uncommitted, for Umer to -review) rather than a real merge commit of the whole PR. +Path A is the default. Use B only if the operator says so and +`python3 .claude/scripts/din_peer_check.py admin` passes. Either way the +operator must first pass `python3 .claude/scripts/din_peer_check.py pr ` +(nobody fixes and lands their own PR). ## 0. Hard rules - **Never commit or push.** This skill only ever leaves changes sitting - uncommitted in the `develop` cwd. Landing them as a real commit is - `din-pr-merge`'s job, after Umer has reviewed every file. + uncommitted in `$TARGET`. Committing and pushing them is `din-pr-merge`'s + job, after the operator has reviewed every file. - **Never build or run test suites.** No `forge build`/`forge test`/ `pytest`/`npx hardhat`. If you're tempted to "just quickly check it compiles", don't — that's `din-merge-build-test`'s job, and it will catch @@ -42,28 +56,40 @@ review) rather than a real merge commit of the whole PR. - **Touch only the one file** (plus anything step 4's fix genuinely requires, e.g. a caller of a changed signature — and say so in the log). Don't pre-emptively fix other files from the same PR; they get their own pass. -- **When in doubt, stop and ask Umer** — this skill's whole point is to do - the mechanical, unambiguous part of landing a fix and hand back control the - moment a real decision shows up. See step 5 for what counts. +- **When in doubt, stop and ask the operator** — this skill's whole point is + to do the mechanical, unambiguous part of landing a fix and hand back + control the moment a real decision shows up. See step 5 for what counts. ## 1. Establish context -You need: the PR number, its review worktree (`~/tempdir/DIN/PRs/PRreview_`, -created by `din-pr-review`), and the live merge-proposal comment's id/URL. If -any of these aren't already established in the conversation, ask Umer rather -than guessing which PR a bare file path belongs to — the same path can appear -in more than one open PR's review. +You need: the PR number, the path (A or B), its review worktree +(`$DIN_WORK/PRs/PRreview_`, created by `din-pr-review`), and the live +merge-proposal comment's id/URL. If any of these aren't already established in +the conversation, ask the operator rather than guessing which PR a bare file +path belongs to — the same path can appear in more than one open PR's review. ```bash +source .claude/scripts/din-env.sh N= -WT=~/tempdir/DIN/PRs/PRreview_$N -LOG=~/tempdir/DIN/PRs/PRreview_$N-merge-fix-log.md # outside the worktree, so its git status stays clean +WT=$DIN_WORK/PRs/PRreview_$N +LOG=$DIN_WORK/PRs/PRreview_$N-merge-fix-log.md # outside the worktree, so its git status stays clean +TARGET=$WT # path A; for path B: TARGET=$DIN_REPO cd "$WT" && git status # confirm the worktree exists and is on pr-$N-review ``` +**Path A: make sure the worktree is at the PR's current head** before fixing +anything, or the fix lands on a stale base and the push is rejected: + +```bash +git fetch origin "pull/$N/head" +[ "$(git rev-parse HEAD)" = "$(git rev-parse FETCH_HEAD)" ] || echo "worktree is not at the PR head" +``` + +If it isn't, and the worktree has no uncommitted fixes yet, +`git reset --hard FETCH_HEAD`. If it already has fixes, stop and ask. + If `develop` has moved since the worktree was set up, re-verify mergeability -before trusting the diff you're about to apply (same check as -`din-pr-review` step 1): +before trusting the diff (same check as `din-pr-review` step 1): ```bash git fetch origin develop @@ -73,7 +99,7 @@ gh pr view $N --json mergeable,mergeStateStatus If local and GitHub disagree, or the file you're about to touch was hit by develop's drift since the branch was cut (`git log --oneline ..origin/develop -- `), -stop and tell Umer before applying anything. +stop and tell the operator before applying anything. ## 2. Read that file's row out of the live comment @@ -85,42 +111,45 @@ gh api repos/InfiniteZeroFoundation/DevNet/issues/comments/ --jq .bo ``` Find the `#### \`\`` block and read: -- **Change** — New or Modified (decides step 3's mechanism). +- **Change** — New or Modified (decides step 3's mechanism in path B). - **Recommended merge proposal** — what should land. - **Pending proposal** — the fix still to apply (or `None`). -## 3. Apply the file into `develop` +## 3. Apply the file into `develop` (path B only) + +Path A: skip this step. The PR's diff is already in the worktree. **New file:** copy it from the worktree at the exact relative path: ```bash -cp "$WT/" "/home/umerm/projects/devnet/" +cp "$WT/" "$DIN_REPO/" ``` **Modified file:** diff the file between the PR's merge-base and its head in -the worktree, then apply that diff to the `develop` cwd's current copy: +the worktree, then apply that diff to the `develop` checkout's current copy: ```bash git -C "$WT" diff -- "" > /file.patch -cd /home/umerm/projects/devnet && git apply --check /file.patch && git apply /file.patch +cd "$DIN_REPO" && git apply --check /file.patch && git apply /file.patch ``` If `--check` fails, that's a real conflict — don't force it (`git apply -3` -or hand-resolution) without asking Umer first; this is exactly the kind of -thing step 5 exists for. If the row said "contingent on ``'s fix -landing first," confirm that other file's fix is actually in the `develop` -cwd already before applying this one — if it isn't, stop and ask. +or hand-resolution) without asking the operator first; this is exactly the +kind of thing step 5 exists for. If the row said "contingent on +``'s fix landing first," confirm that other file's fix is actually +in the `develop` checkout already before applying this one — if it isn't, stop +and ask. **Generated files** (e.g. `dincli/abis/*.json` where the row says "regenerate from the merged build"): don't hand-edit or apply the PR's version. Log it as **deferred to `din-merge-build-test`** — regenerating needs -a build, which this skill doesn't run. +a build, which this skill doesn't run. (Same in path A.) ## 4. Apply the fix from "Pending proposal" If Pending proposal is `None`, skip to step 6 — "Recommended merge proposal" already said "merged as-is" and there's nothing further to do. -Otherwise, apply exactly what the row describes. This is real code-review -follow-through, not a mechanical patch — read the file, understand the -described defect (cross-reference the verification comment for the full +Otherwise, apply exactly what the row describes, in `$TARGET`. This is real +code-review follow-through, not a mechanical patch — read the file, understand +the described defect (cross-reference the verification comment for the full repro if the pending-proposal line is terse), and write the fix the way you would for any other bug: matching the file's existing idiom, updating affected natspec/docstrings, and checking for other call sites the fix's @@ -129,40 +158,43 @@ signature or behavior change touches (grep the rest of `src/`/`script/`/ isolation). If the fix calls for a new/updated test, write it now; it gets run in `din-merge-build-test`. -Before finishing, eyeball the result: `git diff -- ` in the `develop` -cwd, and `git status --short` repo-wide to confirm nothing else changed -unexpectedly. +Before finishing, eyeball the result: `git diff -- ` in `$TARGET`, and +`git status --short` repo-wide to confirm nothing else changed unexpectedly. -## 5. Stop and ask Umer whenever — +## 5. Stop and ask the operator whenever — - The diff doesn't apply cleanly, or develop's drift since the branch was cut touched this same file in a way that changes what "the fix" should even do. - The pending-proposal text names a design trade-off rather than a concrete fix (e.g. "coordinate merge order across PRs", "union of both sides" where the union isn't mechanical, "decide whether to fold X in or track - separately", anything requiring a call on tokenomics/fee parameters — see - the `p3-issues-defer-fee-changes` memory). That's asking for a decision, - not describing a fix to apply. + separately", anything requiring a call on tokenomics/fee parameters). That's + asking for a decision — a mechanism one needs the mechanism sign-off + (MAINTAINING No. 4.2) — not describing a fix to apply. - The fix changes a public function's signature or removes/renames state that other files reference — confirm the blast radius is actually contained before treating it as done. - Anything about the fix feels underspecified enough that two reasonable people could implement it differently — say what the options are and let - Umer pick, don't silently choose one. + the operator pick, don't silently choose one. +- Path A and the fix is substantial: pushing it makes the operator a co-author + (MAINTAINING No. 7). Ask whether to push it onto the PR, ask the author to + make it, or open a follow-up PR instead. ## 6. Append to the merge-fix log `din-merge-build-test` writes the comment from this log, so it must say what actually happened, per file. Append (create the file on first use, with a -`# PR merge-fix log` header, the comment URL and the PR head SHA): +`# PR merge-fix log` header, the path (A or B), the comment URL and the PR +head SHA): ```markdown ## `` -- Applied: +- Applied: - Fix: - Other files touched: - Tests added/changed: -- Open: +- Open: ``` If this file already has an entry (a second pass), update that entry in place @@ -170,9 +202,9 @@ rather than appending a duplicate. ## 7. Report back -Summarize for Umer: what was applied, what was fixed and how, any other files -touched, and anything left open. State plainly that nothing was built or -tested yet and nothing was committed — the files are uncommitted in the -`develop` cwd. List which files from the merge-proposal comment are still -without a log entry; when none are left, the next step is -`din-merge-build-test`. +Summarize for the operator: what was applied, what was fixed and how, any +other files touched, and anything left open. State plainly that nothing was +built or tested yet and nothing was committed — the files are uncommitted in +`$TARGET` (the review worktree for path A, the `develop` checkout for path B). +List which files from the merge-proposal comment are still without a log +entry; when none are left, the next step is `din-merge-build-test`. diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 75b337e1..a61a7394 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -33,5 +33,5 @@ # The maintainer process itself and the Claude skills that implement it. /.github/CODEOWNERS @umeradl @Santiagocetran @robertocarlous /Developer/MAINTAINING.md @umeradl @Santiagocetran @robertocarlous -/.claude/ @umeradl +/.claude/ @umeradl @Santiagocetran @robertocarlous /CLAUDE.md @umeradl diff --git a/Developer/MAINTAINING.md b/Developer/MAINTAINING.md index 86327a0f..ee714637 100644 --- a/Developer/MAINTAINING.md +++ b/Developer/MAINTAINING.md @@ -176,4 +176,19 @@ Anyone may answer, label, or lock stale threads. Closing a design or Ideas Discu ## 8. Tooling -The `din-*` Claude Code skills under [`.claude/skills/`](../.claude/skills/) implement this workflow (`din-pr-review`, `merge-fix-comment-update`, `din-merge-build-test`, `din-pr-merge`, `din-push-develop`, `din-approve-issue`, `din-close-issue`, `din-task-close`). Some of them still assume they run as `umeradl`. A follow-up PR generalises them: trailers taken from the active `gh` user, and a self-check that refuses to approve, merge or close when that user is the author, assignee or a co-author. Until it lands, follow this file and the templates by hand. +The `din-*` Claude Code skills under [`.claude/skills/`](../.claude/skills/) implement this workflow, and any maintainer can run them as themselves. Setup and the shared scripts: [`.claude/scripts/README.md`](../.claude/scripts/README.md). Every skill that approves, merges or closes first runs [`din_peer_check.py`](../.claude/scripts/din_peer_check.py), which refuses when the active `gh` user is the author, assignee, implementer or a co-author ([No. 1](#1-the-rule)). Logins that belong to one person, such as `umeradl` and `umermjd11`, count as one. + +| Step | Skill | +|---|---| +| Review a PR: the three comments ([No. 4.3](#43-the-review-record)), then approve, request changes or recuse | `din-pr-review` | +| Apply review fixes, per file | `merge-fix-comment-update` | +| Build and test the fixed tree the way CI does, then update the merge-proposal comment | `din-merge-build-test` | +| Merge: path A (GitHub-side merge commit) or path B (admin local merge) ([No. 4.4](#44-merge-paths)), own the `develop` CI run, post the outcome comment | `din-pr-merge` | +| Admin direct push to `develop` (path B, emergencies), with a local CI mirror and a logged bypass reason | `din-push-develop` | +| Approve an issue's plan ([No. 5.1](#51-issues-approval-before-implementation)) | `din-approve-issue` | +| Close an issue as completed, or post done vs. remaining ([No. 5.2](#52-issues-status-updates-and-closing)) | `din-close-issue` | +| Close a task Discussion ([No. 5.3](#53-tasks)) | `din-task-close` | + +**When a local build is needed before merging.** A PR that merges as-is needs no local build: its required `CI OK` runs the full pipeline on GitHub's merge of the PR into `develop`, and the branch has to be up to date, so that run tests exactly what lands. When a reviewer applies fixes, they run `din-merge-build-test` on the fixed branch before pushing it, so the PR doesn't go red in public. CI then re-runs on the pushed fixes and is the gate again. In path B there's no PR CI for the deviation commit, so the local run and `din-push-develop`'s precheck are the gate. + +The skills are a convenience, not a requirement. Following this file and the [templates](../.claude/skills/din-pr-review/templates/) by hand is equally valid. From 718f06b275488d31e6a2799be050c1ca9279bf28 Mon Sep 17 00:00:00 2001 From: umermjd11 Date: Tue, 6 Oct 2026 21:54:24 +0500 Subject: [PATCH 21/48] feat(stake): cross-model S5 escalation (#193) S5 recidivism was counted per slasher contract (_partialSlashGIs[validator][msg.sender]), so a validator could spread liveness misses across models -- or across one model's coordinator (S2) and auditor (S1) -- and never escalate. - slashPartial keeps the per-slasher GI ring and adds a per-validator ring of block timestamps shared by every slasher (_partialSlashTimes). Timestamps never decrease, so the ascending-order trim stays safe without namespacing by caller. Either level reaching its threshold escalates (full MIN_STAKE + s5JailDuration jail) and clears the caller's ring and the global ring. - New owner-settable s5GlobalWindow / s5GlobalThreshold, 7 days / 6 in initialize (placeholders until No. 155). setS5GlobalParams(window, threshold): (0, 0) is the off switch, otherwise both > 0 (InvalidS5Params). On a proxy upgraded from an earlier version both read 0, i.e. off -- no reinitializer (PR No. 219 Decision 3). - New events ValidatorEscalatedS5Global (only when the global level fired; ValidatorEscalatedS5 keeps its signature so existing indexers keep working) and S5GlobalParamsUpdated; new view getPartialSlashTimes. Storage appended after the S6 block, __gap 50 -> 47. - DeployPlatform.s.sol: S5_GLOBAL_WINDOW / S5_GLOBAL_THRESHOLD env keys (defaults 7 days / 6), applied via setS5GlobalParams when either differs; logged; .env.example. - Tests: SlashingInvariants.t.sol -- escalation across three slashers at the 6th miss (no slasher above 2), S1+S2 of one model adding up, window expiry, 0/0 off, setter validation, defaults. DeployPlatform(.t/Script.t).sol -- defaults, overrides, single global override, off switch, half-zero revert, readTokenomics env keys. The two escalation tests fail with the global check removed. - Docs: DinValidatorStake.md (params, storage, slashPartial, views, events, upgrade, caveat No. 5, change log), DeployPlatform.md (13 keys), dinrep.md, MECHANISM_DESIGN.md S5 row + note on the :87 full-slash/blacklist vs MIN_STAKE/jail discrepancy (recorded, not changed), threat model Row 11 -> DEFENDED above the global threshold. PR No. 218's Row 11 test is not flipped here. - dincli/abis/DinValidatorStake.json regenerated. DinValidatorStake runtime 9,881 -> 10,630 B (margin 13,946 B). forge test: 502 passed, 0 failed. Co-Authored-By: Claude Opus 5.5 --- .env.example | 2 + Developer/design/MECHANISM_DESIGN.md | 3 +- Developer/design/adversarial-threat-model.md | 8 +- Documentation/public/roles/dinrep.md | 2 +- .../technical/contracts/DinValidatorStake.md | 32 +++-- .../foundry/script/DeployPlatform.md | 10 +- dincli/abis/DinValidatorStake.json | 113 +++++++++++++++ foundry/script/DeployPlatform.s.sol | 25 +++- foundry/src/DinValidatorStake.sol | 112 ++++++++++++++- foundry/test/DeployPlatform.t.sol | 11 ++ foundry/test/DeployPlatformScript.t.sol | 39 ++++- foundry/test/SlashingInvariants.t.sol | 135 ++++++++++++++++++ 12 files changed, 460 insertions(+), 32 deletions(-) diff --git a/.env.example b/.env.example index 87adc4b1..df7fee0d 100644 --- a/.env.example +++ b/.env.example @@ -42,3 +42,5 @@ MAINNET_RPC_URL= # S5_RECIDIVISM_THRESHOLD=3 # slashes in window to trigger S5 jail (default: 3) # S5_JAIL_DURATION=604800 # jail duration in seconds (default: 604800 = 7 days) # S6_NO_PARTICIPATION_THRESHOLD=3 # no-participation events before S6 fires (default: 3) +# S5_GLOBAL_WINDOW=604800 # seconds over which S5 counts partial slashes from ALL slasher contracts (default: 604800 = 7 days; 0 with threshold 0 = off) +# S5_GLOBAL_THRESHOLD=6 # partial slashes in that window to trigger S5 jail (default: 6; 0 with window 0 = off) diff --git a/Developer/design/MECHANISM_DESIGN.md b/Developer/design/MECHANISM_DESIGN.md index 52775e85..d2b841b5 100644 --- a/Developer/design/MECHANISM_DESIGN.md +++ b/Developer/design/MECHANISM_DESIGN.md @@ -78,13 +78,14 @@ Grounding: everything below references `foundry/src/` as of `develop`. | S2 | Missed T1/T2 aggregation submission | Aggregator | On-chain (already implemented) | Partial | | S3 | Score deviation beyond threshold from cross-auditor median (per model, per GI) | Auditor | On-chain comparison at `finalizeEvaluation` — requires median computed on-chain | Partial → Full on repeat | | S4 | Invalid aggregation: submitted T1/T2 CID whose recomputation from inputs fails verification | Aggregator | Off-chain challenge + on-chain dispute (S4 is the main dispute-resolution consumer) | Full | -| S5 | Repeated liveness failures (≥ `r` partial slashes within `w` GIs) | Both | On-chain counter | Full + jail/blacklist | +| S5 | Repeated liveness failures (≥ `r` partial slashes within `w` GIs from one slasher contract, or ≥ `r_g` within a time window `w_g` across all models; issue No. 193) | Both | On-chain counter | Full + jail/blacklist | | S6 | Registration without capacity (registers, never participates, across models) | Both | On-chain counter | Partial, escalating | ### Penalty tiers - **Partial slash:** fixed fraction of `minStake` (e.g., 25–50%), not the flat `minStake()` used today — today's flat amount fully unstakes a floor-staked validator for one missed vote, which over-punishes liveness faults. Make the fraction a DAO parameter. - **Full slash:** entire slashable stake (active + unbonding) + blacklist. Reserved for provable malice (S4) and recidivism (S5). + - *Implementation note (issue No. 193 review):* the S5 escalation in `DinValidatorStake.slashPartial` slashes `MIN_STAKE` (not the entire slashable stake) and jails for `s5JailDuration` (7 days by default) rather than blacklisting. Recorded here, not changed; aligning the two is a mechanism decision. - **Jailing:** `_syncValidatorStatus` already produces a Jailed state below min stake; keep — jailed validators cannot register until they top up. ### Slashed-stake destination (resolved, P3-4.2) diff --git a/Developer/design/adversarial-threat-model.md b/Developer/design/adversarial-threat-model.md index 1b441003..b815d552 100644 --- a/Developer/design/adversarial-threat-model.md +++ b/Developer/design/adversarial-threat-model.md @@ -140,9 +140,9 @@ entry in `foundry/test/P3Adversarial.t.sol`. |---|---| | **Attacker** | Validator registered across multiple models, spreading misses so no single model's S5 ring reaches threshold | | **Capability** | Two or more models' task contracts are separate slashers; `_partialSlashGIs[validator][msg.sender]` is namespaced per slasher (`DinValidatorStake.sol:309`). With default threshold 3, ≤ 2 misses per model per window avoids S5 on every individual model. | -| **Protocol response today** | None — S5 is per-slasher-contract, not aggregate across models. A validator can miss `(s5RecidivismThreshold − 1) × N` total votes across N models before any single model escalates. | -| **Expected test outcome** | **KNOWN GAP** ([#193](https://github.com/InfiniteZeroFoundation/DevNet/issues/193)) — the test sets up two task contracts (two models), has a validator miss threshold−1 votes on each, and asserts S5 never fires and `isValidatorActive` remains true. | -| **Trust assumption** | S5 counter would need to be aggregated across all models (or moved to a global platform-level counter) to close this gap. | +| **Protocol response today** | Two-level S5 ([#193](https://github.com/InfiniteZeroFoundation/DevNet/issues/193)): besides the per-slasher GI ring, `slashPartial` counts every partial slash for the validator in one time-windowed ring shared by all slasher contracts (`_partialSlashTimes`; `s5GlobalWindow` 7 days, `s5GlobalThreshold` 6 by default). Reaching either threshold escalates (full `MIN_STAKE` + jail) and emits `ValidatorEscalatedS5Global` when only the global level fired. The same ring also adds up one model's S1 (auditor contract) and S2 (coordinator) misses. | +| **Expected test outcome** | **DEFENDED above the global threshold.** With defaults, a validator escalates on its 6th partial slash within 7 days, however the misses are spread (`SlashingInvariants.t.sol` `test_s5Global_escalatesAcrossSlashers`, `test_s5Global_addsUpTwoSlashersOfOneModel`). Below it the gap is bounded, not closed: up to 5 misses across models in a window only pay their partial slashes. PR #218's `test_knownGap_perSlasherS5Evasion` (2 models × 2 misses = 4 < 6) still passes as written; when it rebases it should spread misses until the global count reaches `s5GlobalThreshold` and assert escalation (`test_defended_…`). | +| **Trust assumption** | `s5GlobalWindow` / `s5GlobalThreshold` are calibrated (placeholders until [#155](https://github.com/InfiniteZeroFoundation/DevNet/issues/155)). On a proxy upgraded from an earlier version both read 0 (global level off) until the owner sets them. | --- @@ -169,7 +169,7 @@ entry in `foundry/test/P3Adversarial.t.sol`. | Decentralized dispute adjudication | [#181](https://github.com/InfiniteZeroFoundation/DevNet/issues/181) | Row 7 (owner suppresses dispute) and bond-locked-forever variant | `resolveDispute` gated by multi-sig, DAO committee, or fraud-proof; timeout added for unresolved disputes | | Dispute timeout for unresolved disputes | [#181](https://github.com/InfiniteZeroFoundation/DevNet/issues/181) | Row 7 (owner never resolves → bond locked) | `expireDispute` (or a new function) reachable when `d.resolved == false` after a deadline | | BlockFLow duplicate-update scoring | [#39](https://github.com/InfiniteZeroFoundation/DevNet/issues/39) | Row 10 (duplicate-update gaming) | BlockFLow fold-in scoring implemented | -| Per-slasher S5 ring evasion | [#193](https://github.com/InfiniteZeroFoundation/DevNet/issues/193) | Row 11 (multi-model S5 evasion) | S5 counter aggregated globally or per-validator across models | +| Per-slasher S5 ring evasion | [#193](https://github.com/InfiniteZeroFoundation/DevNet/issues/193) | Row 11 (multi-model S5 evasion) | **Flipped by task_061026_20 Part C:** per-validator global ring across all slashers (7 days / 6 by default). Misses below the global threshold remain bounded by partial slashes; thresholds pending #155 | | Mainnet VRF for dispute and batch seeds | [#178](https://github.com/InfiniteZeroFoundation/DevNet/issues/178) | Rows 2, 6, 8 (trust assumption TA-4) | `lockDisputeSeed`, `lockAggSeed` and `lockAuditSeed` upgraded to VRF or multi-party commit-reveal | --- diff --git a/Documentation/public/roles/dinrep.md b/Documentation/public/roles/dinrep.md index 6e60a0d2..4e518960 100644 --- a/Documentation/public/roles/dinrep.md +++ b/Documentation/public/roles/dinrep.md @@ -76,7 +76,7 @@ Optimism Sepolia is a public chain, so there is no anvil step. Instead you need - **Funds.** The address needs Optimism Sepolia ETH. A full deploy is 22 transactions: seven implementations, seven proxies, and eight wiring calls. Each proxy creates its own ProxyAdmin inside its constructor, so the ProxyAdmins need no transactions of their own. -- **Tokenomics parameters.** `DeployPlatform.s.sol` reads 11 tokenomics settings from the environment: `DIN_PER_ETH`, `MINT_CAP`, the four `EMISSION_*` keys, `MIN_STAKE`, the three `S5_*` keys and `S6_NO_PARTICIPATION_THRESHOLD`. Any key that isn't set falls back to the in-code default; for example, `MINT_CAP` defaults to `0`, which means uncapped. Set the values chosen for this network in `.env.sepolia_op_devnet` (loaded above) or in `foundry/.env` (forge loads it automatically). The script logs an `[INFO] not set` line for each missing key and ends with a `--- Effective tokenomics ---` block. See [DeployPlatform.md](../../technical/contracts/foundry/script/DeployPlatform.md) for every key and its default. +- **Tokenomics parameters.** `DeployPlatform.s.sol` reads 13 tokenomics settings from the environment: `DIN_PER_ETH`, `MINT_CAP`, the four `EMISSION_*` keys, `MIN_STAKE`, the three `S5_RECIDIVISM_*`/`S5_JAIL_DURATION` keys, the two `S5_GLOBAL_*` keys and `S6_NO_PARTICIPATION_THRESHOLD`. Any key that isn't set falls back to the in-code default; for example, `MINT_CAP` defaults to `0`, which means uncapped. Set the values chosen for this network in `.env.sepolia_op_devnet` (loaded above) or in `foundry/.env` (forge loads it automatically). The script logs an `[INFO] not set` line for each missing key and ends with a `--- Effective tokenomics ---` block. See [DeployPlatform.md](../../technical/contracts/foundry/script/DeployPlatform.md) for every key and its default. **2. Deploy:** diff --git a/Documentation/technical/contracts/DinValidatorStake.md b/Documentation/technical/contracts/DinValidatorStake.md index 71028deb..1370c8b5 100644 --- a/Documentation/technical/contracts/DinValidatorStake.md +++ b/Documentation/technical/contracts/DinValidatorStake.md @@ -56,9 +56,11 @@ Its central safety property: a validator cannot misbehave and then withdraw befo | `s5RecidivismWindow` | `5` GIs | `setS5RecidivismParams` | Rolling window for counting partial slashes | | `s5RecidivismThreshold` | `3` | `setS5RecidivismParams` (`0 < threshold ≤ window`) | Partial slashes within the window that trigger escalation | | `s5JailDuration` | `7 days` | `setS5RecidivismParams` (non-zero) | Jail length applied on escalation | +| `s5GlobalWindow` | `7 days` | `setS5GlobalParams` (both 0 = off, or both > 0) | Time window over which partial slashes from **every** slasher contract are counted (issue No. 193) | +| `s5GlobalThreshold` | `6` | `setS5GlobalParams` | Partial slashes within `s5GlobalWindow`, from any slasher contract, that trigger escalation. `0` = global level off, which is also what a proxy upgraded from an earlier version reads until the setter is called | | `s6NoParticipationThreshold` | `3` | `setS6NoParticipationThreshold` (non-zero) | No-participation count at which S6 slashing starts | -`MIN_STAKE` (10 DIN) is one of the testnet values still to be decided (issue #155). `DeployPlatform.s.sol` can override `MIN_STAKE` and the S5/S6 parameters at deploy time from environment variables (`MIN_STAKE`, `S5_RECIDIVISM_WINDOW`, `S5_RECIDIVISM_THRESHOLD`, `S5_JAIL_DURATION`, `S6_NO_PARTICIPATION_THRESHOLD`). +`MIN_STAKE` (10 DIN) is one of the testnet values still to be decided (issue #155). `DeployPlatform.s.sol` can override `MIN_STAKE` and the S5/S6 parameters at deploy time from environment variables (`MIN_STAKE`, `S5_RECIDIVISM_WINDOW`, `S5_RECIDIVISM_THRESHOLD`, `S5_JAIL_DURATION`, `S5_GLOBAL_WINDOW`, `S5_GLOBAL_THRESHOLD`, `S6_NO_PARTICIPATION_THRESHOLD`). ### 3.3 Per-validator state @@ -69,7 +71,8 @@ Its central safety property: a validator cannot misbehave and then withdraw befo | `encryptionKeys` | `mapping(address => bytes)` | Registered 32-byte X25519 public keys | | `activeRegistrationCount` | `mapping(address => uint256)` | Open GI registrations across all task contracts | | `s6NoParticipationCount` | `mapping(address => uint256)` | Lifetime no-participation count (never decays) | -| `_partialSlashGIs` | `mapping(address => mapping(address => uint256[]))` (private) | Partial-slash GI indices per validator **per calling slasher contract** (S5 ring) | +| `_partialSlashGIs` | `mapping(address => mapping(address => uint256[]))` (private) | Partial-slash GI indices per validator **per calling slasher contract** (S5 per-slasher ring) | +| `_partialSlashTimes` | `mapping(address => uint256[])` (private) | Partial-slash block timestamps per validator across **all** slasher contracts (S5 global ring, issue No. 193) | | `__gap` | `uint256[50]` | Reserved slots | Slot order is in [storage_layout.md](../storage_layout.md#dinvalidatorstake). @@ -120,7 +123,7 @@ Once a jail has expired, *any* call that syncs status (e.g. `stake`) recomputes | `stake`, `unstake`, `claimUnstaked`, `reactivate`, `registerEncryptionKey` | Any address, acting on its own record | | `addSlasherContract`, `removeSlasherContract` | `DIN_COORDINATOR` only (`NotDINCoordinator`) | | `slash`, `slashPartial`, `recordNoParticipation`, `jailValidator`, `incrementActiveRegistration`, `decrementActiveRegistration` | Registered slasher contracts only (`NotSlasherContract`) | -| `blacklistValidator`, `unblacklistValidator`, `setMinStake`, `setUnbondingPeriod`, `setModelStakeBounds`, `setMaxConcurrentRegistrationsPerStakeUnit`, `setSlashTreasury`, `setS5RecidivismParams`, `setS6NoParticipationThreshold` | `owner()` (DIN-Representative) | +| `blacklistValidator`, `unblacklistValidator`, `setMinStake`, `setUnbondingPeriod`, `setModelStakeBounds`, `setMaxConcurrentRegistrationsPerStakeUnit`, `setSlashTreasury`, `setS5RecidivismParams`, `setS5GlobalParams`, `setS6NoParticipationThreshold` | `owner()` (DIN-Representative) | --- @@ -157,11 +160,12 @@ For faults like bad consensus or S3 score deviation. Reverts on zero address / z Used by the task contracts for liveness faults (auditor didn't reveal a vote — S1; aggregator didn't reveal a CID — S2). `amount` is computed by the caller as a fraction of the global `MIN_STAKE`: `minStake() × s1SlashFractionBps / 10 000` (auditor) or `× s2SlashFractionBps` (aggregator), with the fractions set per model on the task contracts. -1. Appends `giIndex` to `_partialSlashGIs[validator][msg.sender]` and trims entries with `giIndex − entry ≥ s5RecidivismWindow`. -2. If the ring length reaches `s5RecidivismThreshold` → **S5 escalation**: slash a full `MIN_STAKE` (reason `S5_RECIDIVISM`), jail for `s5JailDuration`, emit `ValidatorEscalatedS5`, and clear this caller's ring. -3. Otherwise slash `amount` with the given reason. +1. Appends `giIndex` to `_partialSlashGIs[validator][msg.sender]` and trims entries with `giIndex − entry ≥ s5RecidivismWindow` (**per-slasher level**). +2. If `s5GlobalThreshold ≠ 0`, also appends `block.timestamp` to `_partialSlashTimes[validator]` and trims entries with `now − entry ≥ s5GlobalWindow` (**global level**, issue No. 193). +3. If the per-slasher ring reaches `s5RecidivismThreshold`, **or** the global ring reaches `s5GlobalThreshold` → **S5 escalation**: slash a full `MIN_STAKE` (reason `S5_RECIDIVISM`), jail for `s5JailDuration`, and clear both this caller's ring and the global ring. It emits `ValidatorEscalatedS5` when the per-slasher level fired, otherwise `ValidatorEscalatedS5Global`. +4. Otherwise slash `amount` with the given reason. -The ring is namespaced by the **calling task contract** because `giIndex` is a per-model counter: keying by validator alone would interleave different models' GI sequences and break the ascending-order trim. The consequence is that recidivism is counted **per model**, not across models. +The per-slasher ring is namespaced by the **calling task contract** because `giIndex` is a per-model counter: keying by validator alone would interleave different models' GI sequences and break the ascending-order trim. On its own that counts recidivism per task contract, so misses spread across models, or across one model's coordinator (S2) and auditor (S1), never add up. The global ring closes that gap: block timestamps never go backwards, so one ring per validator stays ascending across every slasher. ### 7.4 `recordNoParticipation(validator, reason)` — S6 @@ -203,7 +207,8 @@ Slasher-only. Reverts on zero address or zero duration (`InvalidJailDuration`); | `isSlasherContract(a)` | slasher flag | | `getEncryptionKey(v)` | registered key or empty bytes | | `getModelStakeMin(modelId)` | `modelMinStakeBounds[modelId].min` | -| `getPartialSlashGIs(v, slasher)` | the S5 ring for that validator/caller pair | +| `getPartialSlashGIs(v, slasher)` | the S5 per-slasher ring for that validator/caller pair | +| `getPartialSlashTimes(v)` | the S5 global ring (timestamps, all slashers) | Plus the public getters for all state in §3. @@ -273,7 +278,7 @@ The ring is kept per calling task contract, so the same three misses spread acro ## 12. Events & Errors -**Events:** `ValidatorStaked`, `ValidatorUnstakeRequested`, `ValidatorWithdrawalClaimed`, `ValidatorSlashed`, `ValidatorJailed`, `ValidatorReactivated`, `ValidatorEscalatedS5`, `S6NoParticipationRecorded`, `S6PartialSlashFired`, `ValidatorBlacklisted`, `ValidatorUnblacklisted`, `SlasherContractAdded`, `SlasherContractRemoved`, `ActiveRegistrationIncremented`, `ActiveRegistrationDecremented`, `EncryptionKeyRegistered`, `MinStakeUpdated`, `UnbondingPeriodUpdated`, `ModelStakeBoundsUpdated`, `MaxConcurrentRegistrationsPerStakeUnitUpdated`, `SlashTreasuryUpdated`, `S5RecidivismParamsUpdated`, `S6ParamsUpdated`. +**Events:** `ValidatorStaked`, `ValidatorUnstakeRequested`, `ValidatorWithdrawalClaimed`, `ValidatorSlashed`, `ValidatorJailed`, `ValidatorReactivated`, `ValidatorEscalatedS5`, `ValidatorEscalatedS5Global`, `S6NoParticipationRecorded`, `S6PartialSlashFired`, `ValidatorBlacklisted`, `ValidatorUnblacklisted`, `SlasherContractAdded`, `SlasherContractRemoved`, `ActiveRegistrationIncremented`, `ActiveRegistrationDecremented`, `EncryptionKeyRegistered`, `MinStakeUpdated`, `UnbondingPeriodUpdated`, `ModelStakeBoundsUpdated`, `MaxConcurrentRegistrationsPerStakeUnitUpdated`, `SlashTreasuryUpdated`, `S5RecidivismParamsUpdated`, `S5GlobalParamsUpdated`, `S6ParamsUpdated`. **Errors:** `NotDINCoordinator`, `NotSlasherContract`, `InvalidAddress`, `ValidatorIsBlacklisted`, `ValidatorNotBlacklisted`, `AmountLessThanMinStake`, `NotEnoughStake`, `InvalidUnstakeAmount`, `PendingWithdrawalExists`, `NoPendingWithdrawal`, `WithdrawalNotReady`, `InvalidSlashAmount`, `SlasherContractAlreadyAdded`, `SlasherContractNotAdded`, `InvalidJailDuration`, `NotJailed`, `JailPeriodNotExpired`, `StakeBelowFloor`, `InvalidMinStake`, `InvalidUnbondingPeriod`, `InvalidStakeBounds`, `InvalidEncryptionKey`, `InvalidS5Params`, `InvalidS6Params`. @@ -300,6 +305,7 @@ Until step 8, slasher management through the coordinator reverts `ValidatorStake | ProxyAdmin | One per proxy, owned by the deployer | Implementation upgrades | - **Upgrade path:** `cd foundry && CONTRACT=DinValidatorStake forge script script/UpgradePlatform.s.sol ...` (see [UpgradePlatform](foundry/script/UpgradePlatform.md)); `foundry/test/UpgradeValidation.t.sol` runs `Upgrades.validateImplementation` on the implementation, and `DinValidatorStakeUpgradeTest` in `foundry/test/DeployPlatform.t.sol` upgrades to `foundry/src/upgrade/DinValidatorStakeV2.sol` and checks stakes and access control survive. +- **Storage:** the S5 global level appends three slots (`s5GlobalWindow`, `s5GlobalThreshold`, `_partialSlashTimes`) after the S6 block and shrinks `__gap` from 50 to 47, so the layout stays upgrade-compatible. - **Trust implication:** this contract custodies all staked DIN; the ProxyAdmin owner can replace every rule here without moving the balance. --- @@ -310,7 +316,7 @@ Until step 8, slasher management through the coordinator reverts `ValidatorStake - **No. 2 — S5 escalation on a blacklisted validator reverts the whole slash:** escalation calls `_jailInternal`, which reverts `ValidatorIsBlacklisted`. A task contract's slashing loop hitting a blacklisted repeat offender would revert, not just skip that validator. - **No. 3 — Jail exit does not require `reactivate()`:** after `jailedUntil` passes, any status-syncing call (e.g. `stake`) recomputes the status, bypassing `reactivate()`'s `StakeBelowFloor` check (the later sync still requires `≥ MIN_STAKE` for `Active`). - **No. 4 — Stale NatSpec (partly fixed, issue No. 180):** the `setModelStakeBounds` / `setMaxConcurrentRegistrationsPerStakeUnit` NatSpec now says where the task contracts enforce both (§8), `activeRegistrationCount` names `releaseGIRegistrationSlots` as the decrement, and `getModelStakeMin` names the `onlyOwner` (DIN-Representative) setter instead of "the model owner". Still open: `modelMinStakeBounds[].max` is never read. -- **No. 5 — Recidivism is per model:** the S5 ring is keyed by calling contract, so a validator faulting across many models never escalates unless it hits the threshold within one model. +- **No. 5 — Recidivism is per model (fixed, issue No. 193):** the per-slasher ring is keyed by calling contract, so on its own a validator faulting across many models never escalates. The global ring (`s5GlobalWindow` / `s5GlobalThreshold`, defaults 7 days / 6) now counts every slasher's partial slashes together. Defaults are placeholders until issue No. 155. A proxy upgraded from an earlier version reads `0` for both, so the global level stays off until the owner calls `setS5GlobalParams`. - **No. 6 — Blacklisted funds are frozen:** blacklisted validators cannot unstake or claim; there is no recovery path other than unblacklisting. - **No. 7 — Custody meets upgradeability:** see §13. @@ -318,6 +324,12 @@ Until step 8, slasher management through the coordinator reverts `ValidatorStake ## 15. Change Log +### Cross-model S5 (issue No. 193) + +- `slashPartial` keeps the per-slasher GI ring and adds a per-validator timestamp ring shared by every slasher contract (`_partialSlashTimes`). Either level reaching its threshold escalates (full `MIN_STAKE` + jail) and clears both rings. +- New owner-settable `s5GlobalWindow` (7 days) / `s5GlobalThreshold` (6), set in `initialize`; `setS5GlobalParams(window, threshold)` with (0, 0) as the off switch. On an upgraded proxy both read 0 (off); no reinitializer. +- New events `ValidatorEscalatedS5Global` (emitted when only the global level fired; `ValidatorEscalatedS5` keeps its signature) and `S5GlobalParamsUpdated`; new view `getPartialSlashTimes`. `__gap` 50 → 47. + ### P3 — slashing, jailing, parameters (foundry) - Slashed DIN is now disposed of: 50% burned, 50% to `slashTreasury` (`setSlashTreasury`), or also burned if no treasury is set. diff --git a/Documentation/technical/contracts/foundry/script/DeployPlatform.md b/Documentation/technical/contracts/foundry/script/DeployPlatform.md index ee675116..dccc9cb4 100644 --- a/Documentation/technical/contracts/foundry/script/DeployPlatform.md +++ b/Documentation/technical/contracts/foundry/script/DeployPlatform.md @@ -11,7 +11,7 @@ The deployer becomes `owner()` of every contract and the owner of every ProxyAdm `run()` is what `forge script` calls. It does three things in order: -1. **`readTokenomics()`:** reads the 11 tokenomics keys below with `vm.envOr`, falling back to `defaultTokenomics()`, and logs `[INFO] not set - using default: ` for every key that is absent. +1. **`readTokenomics()`:** reads the 13 tokenomics keys below with `vm.envOr`, falling back to `defaultTokenomics()`, and logs `[INFO] not set - using default: ` for every key that is absent. 2. **`deploy(Tokenomics, deployer)`:** runs steps 1–15 under `vm.startBroadcast(deployer)`, and passes `deployer` as the owner of all seven ProxyAdmins. `run()` passes `msg.sender`, i.e. forge script's `--sender`. It returns a `Deployment` struct with the 7 proxies and 7 ProxyAdmins. 3. **`_writeDeployments(...)`:** step 16, after `vm.stopBroadcast()`. @@ -34,8 +34,10 @@ Tests call `deploy()` directly with explicit values, so they need neither `vm.se | `S5_RECIDIVISM_THRESHOLD` | `3` | step 15 | | `S5_JAIL_DURATION` | `604800` (7 days) | step 15 | | `S6_NO_PARTICIPATION_THRESHOLD` | `3` | step 15 | +| `S5_GLOBAL_WINDOW` | `604800` (7 days) | step 15 | +| `S5_GLOBAL_THRESHOLD` | `6` (with `S5_GLOBAL_WINDOW=0`, `0` turns the global level off) | step 15 | -The script ends the broadcast by logging a `--- Effective tokenomics ---` block with all 11 values. +The script ends the broadcast by logging a `--- Effective tokenomics ---` block with all 13 values. ## Sequence @@ -55,7 +57,7 @@ The script ends the broadcast by logging a `--- Effective tokenomics ---` block | 12 | `dinModelRegistry.setFeeRouter(dinFeeRouter)` | | | 13 | `DinEmission` proxy — `initialize(dinCoordinator, dinToken, dinModelRegistry, emissionPerGI, emissionDecayBps, emissionEpochLength, emissionMaxEpochs)` | Schedule from the env keys; defaults are 100 DIN/GI, 80% retained per epoch, 100 GIs per epoch, 10 epochs | | 14 | `dinCoordinator.setEmissionContract(dinEmission)` | Authorises emission minting | -| 15 | Apply each post-deploy override only when it differs from its default: `updateDinPerEth`, `setMintCap`, `setMinStake`, `setS5RecidivismParams` (all three S5 values together, if any one differs), `setS6NoParticipationThreshold`. Then record the seven ProxyAdmins (`Upgrades.getAdminAddress`) | Invalid values revert the whole deploy (e.g. `InvalidMinStake`, `InvalidS5Params`, `InvalidS6Params`) | +| 15 | Apply each post-deploy override only when it differs from its default: `updateDinPerEth`, `setMintCap`, `setMinStake`, `setS5RecidivismParams` (all three S5 values together, if any one differs), `setS6NoParticipationThreshold`, `setS5GlobalParams` (window and threshold together, if either differs). Then record the seven ProxyAdmins (`Upgrades.getAdminAddress`) | Invalid values revert the whole deploy (e.g. `InvalidMinStake`, `InvalidS5Params`, `InvalidS6Params`) | | 16 | `run()` writes the JSON, after `vm.stopBroadcast()` | | ## Output @@ -96,7 +98,7 @@ This writes `foundry/deployments/sepolia_op_devnet.json` and `foundry/broadcast/ ## Tested by -[`foundry/test/DeployPlatform.t.sol`](../../../../../foundry/test/DeployPlatform.t.sol) reproduces this sequence in its `PlatformTest._deployPlatform()` fixture and checks every wiring edge (`ProxyWiringTest`), initializer protection (`ReInitializerProtectionTest`), and V2 upgrades (`*UpgradeTest`). [`foundry/test/DeployPlatformScript.t.sol`](../../../../../foundry/test/DeployPlatformScript.t.sol) (`DeployPlatformScriptTest`) runs the script's own `deploy()`: defaults, all 11 overrides and a single S5 override land on chain, invalid overrides revert the deploy, every contract and ProxyAdmin is owned by the deployer, and `readTokenomics()` returns the defaults and then the values set with `vm.setEnv`. The `tests/dincli/` integration harness runs this script against a local chain (`PLATFORM_DEPLOY_TOOLCHAIN=foundry`). +[`foundry/test/DeployPlatform.t.sol`](../../../../../foundry/test/DeployPlatform.t.sol) reproduces this sequence in its `PlatformTest._deployPlatform()` fixture and checks every wiring edge (`ProxyWiringTest`), initializer protection (`ReInitializerProtectionTest`), and V2 upgrades (`*UpgradeTest`). [`foundry/test/DeployPlatformScript.t.sol`](../../../../../foundry/test/DeployPlatformScript.t.sol) (`DeployPlatformScriptTest`) runs the script's own `deploy()`: defaults, all 13 overrides, a single S5 override, a single S5 global override and the S5 global off switch (0, 0) land on chain, invalid overrides revert the deploy, every contract and ProxyAdmin is owned by the deployer, and `readTokenomics()` returns the defaults and then the values set with `vm.setEnv`. The `tests/dincli/` integration harness runs this script against a local chain (`PLATFORM_DEPLOY_TOOLCHAIN=foundry`). ## Notes diff --git a/dincli/abis/DinValidatorStake.json b/dincli/abis/DinValidatorStake.json index 022ec4ec..44d01541 100644 --- a/dincli/abis/DinValidatorStake.json +++ b/dincli/abis/DinValidatorStake.json @@ -203,6 +203,25 @@ ], "stateMutability": "view" }, + { + "type": "function", + "name": "getPartialSlashTimes", + "inputs": [ + { + "name": "validator", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256[]", + "internalType": "uint256[]" + } + ], + "stateMutability": "view" + }, { "type": "function", "name": "getStake", @@ -441,6 +460,32 @@ "outputs": [], "stateMutability": "nonpayable" }, + { + "type": "function", + "name": "s5GlobalThreshold", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "s5GlobalWindow", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, { "type": "function", "name": "s5JailDuration", @@ -561,6 +606,24 @@ "outputs": [], "stateMutability": "nonpayable" }, + { + "type": "function", + "name": "setS5GlobalParams", + "inputs": [ + { + "name": "window", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "threshold", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, { "type": "function", "name": "setS5RecidivismParams", @@ -956,6 +1019,25 @@ ], "anonymous": false }, + { + "type": "event", + "name": "S5GlobalParamsUpdated", + "inputs": [ + { + "name": "window", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + }, + { + "name": "threshold", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + } + ], + "anonymous": false + }, { "type": "event", "name": "S5RecidivismParamsUpdated", @@ -1146,6 +1228,37 @@ ], "anonymous": false }, + { + "type": "event", + "name": "ValidatorEscalatedS5Global", + "inputs": [ + { + "name": "validator", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "slashedAmount", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + }, + { + "name": "globalCount", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + }, + { + "name": "slasher", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false + }, { "type": "event", "name": "ValidatorJailed", diff --git a/foundry/script/DeployPlatform.s.sol b/foundry/script/DeployPlatform.s.sol index 0fa6e430..c15043e3 100644 --- a/foundry/script/DeployPlatform.s.sol +++ b/foundry/script/DeployPlatform.s.sol @@ -43,6 +43,8 @@ import {DeploymentsPath} from "./DeploymentsPath.sol"; /// S5_RECIDIVISM_THRESHOLD — slashes in window to trigger S5 jail (default: 3) /// S5_JAIL_DURATION — jail duration in seconds (default: 604800 = 7 days) /// S6_NO_PARTICIPATION_THRESHOLD — no-participation events before S6 fires (default: 3) +/// S5_GLOBAL_WINDOW — seconds over which S5 counts slashes from all slasher contracts (default: 604800 = 7 days) +/// S5_GLOBAL_THRESHOLD — slashes in that window to trigger S5 jail, 0 with window 0 = off (default: 6) /// /// Usage (from repo root): /// ./foundry/anvil.sh & @@ -82,6 +84,8 @@ contract DeployPlatform is DeploymentsPath { uint256 internal constant DEFAULT_S5_THRESHOLD = 3; uint256 internal constant DEFAULT_S5_JAIL_DURATION = 7 days; uint256 internal constant DEFAULT_S6_THRESHOLD = 3; + uint256 internal constant DEFAULT_S5_GLOBAL_WINDOW = 7 days; + uint256 internal constant DEFAULT_S5_GLOBAL_THRESHOLD= 6; /// @notice Tokenomics values the deploy applies; see the env keys in the header. struct Tokenomics { @@ -96,6 +100,8 @@ contract DeployPlatform is DeploymentsPath { uint256 s5Threshold; uint256 s5JailDuration; uint256 s6Threshold; + uint256 s5GlobalWindow; + uint256 s5GlobalThreshold; } /// @notice Deployed proxy and ProxyAdmin addresses, as written to the deployments JSON. @@ -152,7 +158,9 @@ contract DeployPlatform is DeploymentsPath { s5Window: DEFAULT_S5_WINDOW, s5Threshold: DEFAULT_S5_THRESHOLD, s5JailDuration: DEFAULT_S5_JAIL_DURATION, - s6Threshold: DEFAULT_S6_THRESHOLD + s6Threshold: DEFAULT_S6_THRESHOLD, + s5GlobalWindow: DEFAULT_S5_GLOBAL_WINDOW, + s5GlobalThreshold: DEFAULT_S5_GLOBAL_THRESHOLD }); } @@ -171,6 +179,8 @@ contract DeployPlatform is DeploymentsPath { t.s5Threshold = vm.envOr("S5_RECIDIVISM_THRESHOLD", dflt.s5Threshold); t.s5JailDuration = vm.envOr("S5_JAIL_DURATION", dflt.s5JailDuration); t.s6Threshold = vm.envOr("S6_NO_PARTICIPATION_THRESHOLD", dflt.s6Threshold); + t.s5GlobalWindow = vm.envOr("S5_GLOBAL_WINDOW", dflt.s5GlobalWindow); + t.s5GlobalThreshold = vm.envOr("S5_GLOBAL_THRESHOLD", dflt.s5GlobalThreshold); _logIfUnset("DIN_PER_ETH", t.dinPerEth); _logIfUnset("MINT_CAP", t.mintCap); @@ -183,6 +193,8 @@ contract DeployPlatform is DeploymentsPath { _logIfUnset("S5_RECIDIVISM_THRESHOLD", t.s5Threshold); _logIfUnset("S5_JAIL_DURATION", t.s5JailDuration); _logIfUnset("S6_NO_PARTICIPATION_THRESHOLD", t.s6Threshold); + _logIfUnset("S5_GLOBAL_WINDOW", t.s5GlobalWindow); + _logIfUnset("S5_GLOBAL_THRESHOLD", t.s5GlobalThreshold); } /// @notice Deploys and wires the platform with the given tokenomics (steps @@ -327,6 +339,15 @@ contract DeployPlatform is DeploymentsPath { DinValidatorStake(dinValidatorStakeProxy).setS6NoParticipationThreshold(t.s6Threshold); console.log("DinValidatorStake.s6NoParticipationThreshold set to:", t.s6Threshold); } + if ( + t.s5GlobalWindow != DEFAULT_S5_GLOBAL_WINDOW || + t.s5GlobalThreshold != DEFAULT_S5_GLOBAL_THRESHOLD + ) { + DinValidatorStake(dinValidatorStakeProxy).setS5GlobalParams( + t.s5GlobalWindow, t.s5GlobalThreshold + ); + console.log("DinValidatorStake S5 global params updated"); + } console.log("--- Effective tokenomics ---"); console.log("dinPerEth: ", t.dinPerEth); @@ -340,6 +361,8 @@ contract DeployPlatform is DeploymentsPath { console.log("s5Threshold: ", t.s5Threshold); console.log("s5JailDuration: ", t.s5JailDuration); console.log("s6Threshold: ", t.s6Threshold); + console.log("s5GlobalWindow: ", t.s5GlobalWindow); + console.log("s5GlobalThreshold: ", t.s5GlobalThreshold); // ProxyAdmin — OZ v5 deploys one ProxyAdmin per proxy; record all seven address proxyAdminTreasury = Upgrades.getAdminAddress(dinTreasuryProxy); diff --git a/foundry/src/DinValidatorStake.sol b/foundry/src/DinValidatorStake.sol index 12ce88bf..f39627d5 100644 --- a/foundry/src/DinValidatorStake.sol +++ b/foundry/src/DinValidatorStake.sol @@ -172,8 +172,36 @@ contract DinValidatorStake is ); event S6PartialSlashFired(address indexed validator, uint256 slashedAmount, address indexed slasher); + // ── S5 — global (cross-model) recidivism level (issue No. 193) ───────── + /// @notice Rolling time window (seconds) for the per-validator S5 count + /// that spans every slasher contract. DAO-settable. 0 = off. + uint256 public s5GlobalWindow; + /// @notice Number of partial slashes, from any slasher contract, within + /// s5GlobalWindow that triggers S5 escalation. DAO-settable. 0 = off. + /// Reads 0 on a proxy upgraded from a version without this field, + /// so the global level stays off until setS5GlobalParams is called. + uint256 public s5GlobalThreshold; + /// @dev Per-validator ordered list of block timestamps at which a partial + /// slash was recorded, from any slasher contract. Unlike + /// _partialSlashGIs this ring is shared across models: timestamps + /// never decrease, so the ascending-order trim stays safe without + /// namespacing by caller. Entries older than s5GlobalWindow are trimmed. + mapping(address => uint256[]) private _partialSlashTimes; + + event S5GlobalParamsUpdated(uint256 window, uint256 threshold); + /// @notice Emitted when the global (cross-model) S5 level escalates a + /// partial slash and the per-slasher level did not. The per-slasher + /// escalation keeps emitting ValidatorEscalatedS5. + event ValidatorEscalatedS5Global( + address indexed validator, + uint256 slashedAmount, + uint256 globalCount, + address indexed slasher + ); + // Reserved for future state variables at this inheritance level. - uint256[50] private __gap; + // 50 - 3 slots used by the S5 global level above. + uint256[47] private __gap; /// @custom:oz-upgrades-unsafe-allow constructor constructor() { @@ -198,6 +226,8 @@ contract DinValidatorStake is s5RecidivismWindow = 5; s5RecidivismThreshold = 3; s5JailDuration = 7 days; + s5GlobalWindow = 7 days; + s5GlobalThreshold = 6; s6NoParticipationThreshold = 3; } @@ -283,8 +313,11 @@ contract DinValidatorStake is /// recidivism tracking. Applies a fraction of MIN_STAKE rather than /// the full amount. If the validator's partial-slash count within /// s5RecidivismWindow GIs (tracked per calling slasher contract, - /// see _partialSlashGIs) reaches s5RecidivismThreshold, the slash - /// is automatically escalated to MIN_STAKE and the validator is jailed. + /// see _partialSlashGIs) reaches s5RecidivismThreshold, or its + /// count across all slasher contracts within s5GlobalWindow seconds + /// reaches s5GlobalThreshold (the global level, issue No. 193; see + /// _partialSlashTimes), the slash is automatically escalated to + /// MIN_STAKE and the validator is jailed. /// @param validator Address of the validator to slash. /// @param amount Partial slash amount (computed by the calling task contract /// as a fraction of minStake). Ignored on S5 escalation — full MIN_STAKE @@ -310,13 +343,29 @@ contract DinValidatorStake is // Count entries now in the window (includes the one just added). uint256 countInWindow = _partialSlashGIs[validator][msg.sender].length; - if (countInWindow >= s5RecidivismThreshold) { + // Global level (issue No. 193): the same partial slash also counts in + // a per-validator, time-windowed ring shared by every slasher contract, + // so misses spread across models (or across one model's coordinator + // and auditor) still add up. Off while s5GlobalThreshold is 0. + uint256 globalCount; + if (s5GlobalThreshold != 0) { + globalCount = _trimAndRecordGlobalPartialSlash(validator); + } + bool perSlasherHit = countInWindow >= s5RecidivismThreshold; + + if (perSlasherHit || (s5GlobalThreshold != 0 && globalCount >= s5GlobalThreshold)) { // Escalate: full MIN_STAKE slash + jail. uint256 escalatedAmount = _applySlash(validator, MIN_STAKE, "S5_RECIDIVISM"); _jailInternal(validator, uint64(s5JailDuration), "S5_RECIDIVISM"); - emit ValidatorEscalatedS5(validator, escalatedAmount, giIndex, msg.sender); - // Clear this caller's ring so its next GI starts a fresh window after jail exit. + if (perSlasherHit) { + emit ValidatorEscalatedS5(validator, escalatedAmount, giIndex, msg.sender); + } else { + emit ValidatorEscalatedS5Global(validator, escalatedAmount, globalCount, msg.sender); + } + // Clear this caller's ring and the global ring so the next GI + // starts a fresh window after jail exit. delete _partialSlashGIs[validator][msg.sender]; + delete _partialSlashTimes[validator]; return escalatedAmount; } @@ -601,6 +650,19 @@ contract DinValidatorStake is emit S5RecidivismParamsUpdated(window, threshold, jailDuration); } + /// @notice Updates the global (cross-model) S5 level (issue No. 193). + /// @param window Time window in seconds over which partial slashes from + /// every slasher contract are counted. + /// @param threshold Partial slashes within the window that trigger + /// escalation. Pass (0, 0) to turn the global level off; otherwise + /// both must be > 0. + function setS5GlobalParams(uint256 window, uint256 threshold) external onlyOwner { + if ((window == 0) != (threshold == 0)) revert InvalidS5Params(); + s5GlobalWindow = window; + s5GlobalThreshold = threshold; + emit S5GlobalParamsUpdated(window, threshold); + } + /// @notice Updates the S6 no-participation threshold. /// @param threshold Number of no-show GIs before an escalating slash fires. function setS6NoParticipationThreshold(uint256 threshold) external onlyOwner { @@ -621,6 +683,13 @@ contract DinValidatorStake is return _partialSlashGIs[validator][slasherContract]; } + /// @notice Returns a validator's global partial-slash timestamp ring + /// (all slasher contracts; for testing/inspection). + /// @param validator Validator address to query. + function getPartialSlashTimes(address validator) external view returns (uint256[] memory) { + return _partialSlashTimes[validator]; + } + // ─── Internal helpers ───────────────────────────────────────────────── function _applySlash( @@ -713,6 +782,37 @@ contract DinValidatorStake is } } + /// @dev Pushes block.timestamp onto the validator's global ring, trims + /// entries older than s5GlobalWindow from the front, and returns the + /// count left in the window (including the one just added). The ring + /// is non-decreasing because block timestamps never go backwards. + function _trimAndRecordGlobalPartialSlash(address validator) internal returns (uint256) { + uint256[] storage ring = _partialSlashTimes[validator]; + uint256 nowTs = block.timestamp; + ring.push(nowTs); + + uint256 window = s5GlobalWindow; + uint256 len = ring.length; + uint256 removeCount = 0; + for (uint256 i = 0; i < len - 1; i++) { + if (nowTs - ring[i] >= window) { + removeCount++; + } else { + break; + } + } + if (removeCount > 0) { + uint256 newLen = len - removeCount; + for (uint256 i = 0; i < newLen; i++) { + ring[i] = ring[i + removeCount]; + } + for (uint256 i = 0; i < removeCount; i++) { + ring.pop(); + } + } + return ring.length; + } + function _syncValidatorStatus(ValidatorInfo storage validator) internal { if (validator.status == ValidatorStatus.Blacklisted) { return; diff --git a/foundry/test/DeployPlatform.t.sol b/foundry/test/DeployPlatform.t.sol index 8319609a..c51b16c9 100644 --- a/foundry/test/DeployPlatform.t.sol +++ b/foundry/test/DeployPlatform.t.sol @@ -649,6 +649,11 @@ contract DeployPlatformTokenomicsOverrideTest is PlatformTest { assertEq(p.dinValidatorStake.s5JailDuration(), 7 days, "default s5JailDuration"); } + function test_dinValidatorStake_defaultS5GlobalParams() public view { + assertEq(p.dinValidatorStake.s5GlobalWindow(), 7 days, "default s5GlobalWindow"); + assertEq(p.dinValidatorStake.s5GlobalThreshold(), 6, "default s5GlobalThreshold"); + } + function test_dinValidatorStake_defaultS6Threshold() public view { assertEq(p.dinValidatorStake.s6NoParticipationThreshold(), 3, "default s6Threshold"); } @@ -678,6 +683,12 @@ contract DeployPlatformTokenomicsOverrideTest is PlatformTest { assertEq(p.dinValidatorStake.s5JailDuration(), 14 days, "updated s5JailDuration"); } + function test_dinValidatorStake_overrideS5GlobalParams_appliesAfterDeploy() public { + p.dinValidatorStake.setS5GlobalParams(3 days, 9); + assertEq(p.dinValidatorStake.s5GlobalWindow(), 3 days, "updated s5GlobalWindow"); + assertEq(p.dinValidatorStake.s5GlobalThreshold(), 9, "updated s5GlobalThreshold"); + } + function test_dinValidatorStake_overrideS6Threshold_appliesAfterDeploy() public { p.dinValidatorStake.setS6NoParticipationThreshold(5); assertEq(p.dinValidatorStake.s6NoParticipationThreshold(), 5, "updated s6Threshold"); diff --git a/foundry/test/DeployPlatformScript.t.sol b/foundry/test/DeployPlatformScript.t.sol index 830df250..cf218fb6 100644 --- a/foundry/test/DeployPlatformScript.t.sol +++ b/foundry/test/DeployPlatformScript.t.sol @@ -43,7 +43,9 @@ contract DeployPlatformScriptTest is Test { s5Window: 10, s5Threshold: 4, s5JailDuration: 14 days, - s6Threshold: 5 + s6Threshold: 5, + s5GlobalWindow: 3 days, + s5GlobalThreshold: 8 }); } @@ -74,6 +76,8 @@ contract DeployPlatformScriptTest is Test { assertEq(stake.s5RecidivismThreshold(), t.s5Threshold, "s5RecidivismThreshold"); assertEq(stake.s5JailDuration(), t.s5JailDuration, "s5JailDuration"); assertEq(stake.s6NoParticipationThreshold(), t.s6Threshold, "s6NoParticipationThreshold"); + assertEq(stake.s5GlobalWindow(), t.s5GlobalWindow, "s5GlobalWindow"); + assertEq(stake.s5GlobalThreshold(), t.s5GlobalThreshold, "s5GlobalThreshold"); _assertOwnedByDeployer(d.dinTreasury, "DinTreasury"); _assertOwnedByDeployer(d.dinToken, "DinToken"); @@ -120,6 +124,31 @@ contract DeployPlatformScriptTest is Test { script.deploy(t, deployer); } + /// @dev The S5 global setter takes window and threshold together; + /// overriding only one must apply both, with the other at its default. + function test_singleS5GlobalOverride_appliesWithOtherDefault() public { + DeployPlatform.Tokenomics memory t = script.defaultTokenomics(); + t.s5GlobalThreshold = 9; + DeployPlatform.Deployment memory d = script.deploy(t, deployer); + _assertOnChain(d, t); + } + + /// @dev (0, 0) turns the global S5 level off at deploy time. + function test_s5GlobalOff_landsOnChain() public { + DeployPlatform.Tokenomics memory t = script.defaultTokenomics(); + t.s5GlobalWindow = 0; + t.s5GlobalThreshold = 0; + DeployPlatform.Deployment memory d = script.deploy(t, deployer); + _assertOnChain(d, t); + } + + function test_s5GlobalHalfZero_revertsDeploy() public { + DeployPlatform.Tokenomics memory t = script.defaultTokenomics(); + t.s5GlobalWindow = 0; + vm.expectRevert(DinValidatorStake.InvalidS5Params.selector); + script.deploy(t, deployer); + } + function test_zeroS6Threshold_revertsDeploy() public { DeployPlatform.Tokenomics memory t = script.defaultTokenomics(); t.s6Threshold = 0; @@ -140,11 +169,11 @@ contract DeployPlatformScriptTest is Test { /// Skipped when the caller's shell or foundry/.env already sets a key, /// so the defaults half stays deterministic. function test_readTokenomics_defaultsThenEnvOverrides() public { - string[11] memory keys = [ + string[13] memory keys = [ "DIN_PER_ETH", "MINT_CAP", "EMISSION_PER_GI", "EMISSION_DECAY_BPS", "EMISSION_EPOCH_LENGTH", "EMISSION_MAX_EPOCHS", "MIN_STAKE", "S5_RECIDIVISM_WINDOW", "S5_RECIDIVISM_THRESHOLD", "S5_JAIL_DURATION", - "S6_NO_PARTICIPATION_THRESHOLD" + "S6_NO_PARTICIPATION_THRESHOLD", "S5_GLOBAL_WINDOW", "S5_GLOBAL_THRESHOLD" ]; for (uint256 i = 0; i < keys.length; i++) { if (vm.envExists(keys[i])) vm.skip(true, "tokenomics env key set"); @@ -152,11 +181,11 @@ contract DeployPlatformScriptTest is Test { assertEq(abi.encode(script.readTokenomics()), abi.encode(script.defaultTokenomics()), "defaults"); DeployPlatform.Tokenomics memory t = _overrides(); - uint256[11] memory values = [ + uint256[13] memory values = [ t.dinPerEth, t.mintCap, t.emissionPerGI, t.emissionDecayBps, t.emissionEpochLength, t.emissionMaxEpochs, t.minStake, t.s5Window, t.s5Threshold, t.s5JailDuration, - t.s6Threshold + t.s6Threshold, t.s5GlobalWindow, t.s5GlobalThreshold ]; for (uint256 i = 0; i < keys.length; i++) { vm.setEnv(keys[i], vm.toString(values[i])); diff --git a/foundry/test/SlashingInvariants.t.sol b/foundry/test/SlashingInvariants.t.sol index 7c781af0..d1a6cfa5 100644 --- a/foundry/test/SlashingInvariants.t.sol +++ b/foundry/test/SlashingInvariants.t.sol @@ -553,6 +553,141 @@ contract SlashingInvariantsTest is StdInvariant, Test { vm.stopPrank(); } + // ───────────────────────────────────────────────────────────────────── + // S5 global (cross-model) level tests (issue No. 193) + // ───────────────────────────────────────────────────────────────────── + + /// @dev Registers a second slasher contract, standing in for another + /// model's task contract (or the same model's other task contract). + function _addSecondSlasher() internal returns (address slasher2) { + slasher2 = makeAddr("slasher2"); + vm.prank(admin); + coordinator.addSlasherContract(slasher2); + } + + function test_s5Global_defaults() public view { + assertEq(stake.s5GlobalWindow(), 7 days, "default s5GlobalWindow"); + assertEq(stake.s5GlobalThreshold(), 6, "default s5GlobalThreshold"); + } + + /// @dev The #193 attack: (threshold - 1) misses on each of several models + /// never trips a per-slasher ring, but the global level adds them up. + function test_s5Global_escalatesAcrossSlashers() public { + _setUpSingleValidator(stake.MIN_STAKE() * 20); + address slasher2 = _addSecondSlasher(); + address slasher3 = makeAddr("slasher3"); + vm.prank(admin); + coordinator.addSlasherContract(slasher3); + + // Per-slasher: window 5, threshold 3. Global: 7 days, threshold 6. + uint256 partialAmt = (stake.MIN_STAKE() * 3000) / 10_000; + address[3] memory slashers = [slasher, slasher2, slasher3]; + + // Five partial slashes, at most 2 per slasher: no level reached yet. + uint256 n; + for (uint256 gi = 1; gi <= 2; gi++) { + for (uint256 k = 0; k < 3; k++) { + if (n == 5) break; + vm.warp(block.timestamp + 1 hours); + vm.prank(slashers[k]); + uint256 actual = stake.slashPartial(validator1, partialAmt, "AUD_NO_VOTE", gi); + assertEq(actual, partialAmt, "below both thresholds: partial amount"); + n++; + } + } + assertEq(stake.getPartialSlashTimes(validator1).length, 5, "global ring holds 5"); + + // Sixth, from slasher3 (its 2nd): per-slasher count 2 < 3, global 6 >= 6. + vm.warp(block.timestamp + 1 hours); + vm.expectEmit(true, true, false, true, address(stake)); + emit DinValidatorStake.ValidatorEscalatedS5Global(validator1, stake.MIN_STAKE(), 6, slasher3); + vm.prank(slasher3); + uint256 escalated = stake.slashPartial(validator1, partialAmt, "AUD_NO_VOTE", 2); + assertEq(escalated, stake.MIN_STAKE(), "global level: full MIN_STAKE slashed"); + + (, , , uint64 jailedUntil, ) = stake.validators(validator1); + assertGt(jailedUntil, block.timestamp, "validator jailed after global S5 escalation"); + assertEq(stake.getPartialSlashTimes(validator1).length, 0, "global ring cleared"); + assertEq(stake.getPartialSlashGIs(validator1, slasher3).length, 0, "caller ring cleared"); + } + + /// @dev Same-model split: S1 (auditor contract) and S2 (coordinator) are + /// separate slashers, so their misses used to sit in separate rings. + function test_s5Global_addsUpTwoSlashersOfOneModel() public { + _setUpSingleValidator(stake.MIN_STAKE() * 20); + address coordinatorLike = _addSecondSlasher(); + vm.prank(admin); + stake.setS5GlobalParams(7 days, 4); + + uint256 partialAmt = (stake.MIN_STAKE() * 3000) / 10_000; + vm.prank(slasher); stake.slashPartial(validator1, partialAmt, "AUD_NO_VOTE", 1); + vm.prank(coordinatorLike); stake.slashPartial(validator1, partialAmt, "AGG_T1_NO_SUBMISSION", 1); + vm.prank(slasher); stake.slashPartial(validator1, partialAmt, "AUD_NO_VOTE", 2); + + vm.prank(coordinatorLike); + uint256 escalated = stake.slashPartial(validator1, partialAmt, "AGG_T1_NO_SUBMISSION", 2); + assertEq(escalated, stake.MIN_STAKE(), "S1 + S2 misses add up on the global level"); + } + + function test_s5Global_windowExpiry_resetsCount() public { + _setUpSingleValidator(stake.MIN_STAKE() * 20); + address slasher2 = _addSecondSlasher(); + vm.prank(admin); + stake.setS5GlobalParams(1 days, 3); + + uint256 partialAmt = (stake.MIN_STAKE() * 3000) / 10_000; + vm.prank(slasher); stake.slashPartial(validator1, partialAmt, "AUD_NO_VOTE", 1); + vm.prank(slasher2); stake.slashPartial(validator1, partialAmt, "AUD_NO_VOTE", 1); + + // A full window later both entries have expired: count is 1, not 3. + vm.warp(block.timestamp + 1 days); + vm.prank(slasher2); + uint256 actual = stake.slashPartial(validator1, partialAmt, "AUD_NO_VOTE", 2); + assertEq(actual, partialAmt, "expired entries trimmed: partial amount applied"); + assertEq(stake.getPartialSlashTimes(validator1).length, 1, "only the new entry remains"); + } + + /// @dev 0/0 is the off switch, and what an upgraded proxy reads for the + /// new fields until setS5GlobalParams is called. + function test_s5Global_offWhenZero() public { + _setUpSingleValidator(stake.MIN_STAKE() * 20); + address slasher2 = _addSecondSlasher(); + address slasher3 = makeAddr("slasher3"); + vm.prank(admin); + coordinator.addSlasherContract(slasher3); + vm.prank(admin); + stake.setS5GlobalParams(0, 0); + + uint256 partialAmt = (stake.MIN_STAKE() * 3000) / 10_000; + address[3] memory slashers = [slasher, slasher2, slasher3]; + for (uint256 gi = 1; gi <= 2; gi++) { + for (uint256 k = 0; k < 3; k++) { + vm.prank(slashers[k]); + uint256 actual = stake.slashPartial(validator1, partialAmt, "AUD_NO_VOTE", gi); + assertEq(actual, partialAmt, "global level off: partial amount only"); + } + } + assertEq(stake.getPartialSlashTimes(validator1).length, 0, "global ring untouched when off"); + } + + function test_s5Global_setterValidates() public { + vm.startPrank(admin); + vm.expectRevert(DinValidatorStake.InvalidS5Params.selector); + stake.setS5GlobalParams(0, 6); + vm.expectRevert(DinValidatorStake.InvalidS5Params.selector); + stake.setS5GlobalParams(7 days, 0); + stake.setS5GlobalParams(3 days, 9); + assertEq(stake.s5GlobalWindow(), 3 days); + assertEq(stake.s5GlobalThreshold(), 9); + stake.setS5GlobalParams(0, 0); + assertEq(stake.s5GlobalThreshold(), 0); + vm.stopPrank(); + + vm.prank(validator1); + vm.expectRevert(); // OwnableUnauthorizedAccount + stake.setS5GlobalParams(1 days, 2); + } + // ───────────────────────────────────────────────────────────────────── // S6 no-participation counter tests // ───────────────────────────────────────────────────────────────────── From 631963c010a075838ac1a0a306beba4ed8ad8016 Mon Sep 17 00:00:00 2001 From: umermjd11 Date: Fri, 23 Oct 2026 16:25:24 +0500 Subject: [PATCH 22/48] fix(skills): peer check covers merged-PR co-authors; path B is Umer only (PR 271 review) - din_peer_check.py: issue-close now counts every contributor of a merged cross-referenced PR (author, commit authors, committers, Co-Authored-By trailers), via a pr_contributors() helper shared with `pr`. Adds tests/test_din_peer_check.py as regression coverage. - Path B matches Developer/MAINTAINING.md No. 4.4 (Umer only): new `path-b` mode (person listed in din-maintainers.json `path_b` plus the admin role), and the skills' "admins only" wording for path B now says Umer only. `admin` stays as the emergency-bypass check. Co-Authored-By: Claude Opus 5.5 --- .claude/scripts/README.md | 5 +- .claude/scripts/din-maintainers.json | 1 + .claude/scripts/din_peer_check.py | 43 ++++-- .claude/skills/din-merge-build-test/SKILL.md | 4 +- .claude/skills/din-pr-merge/SKILL.md | 10 +- .claude/skills/din-pr-review/SKILL.md | 2 +- .claude/skills/din-push-develop/SKILL.md | 1 + .../skills/merge-fix-comment-update/SKILL.md | 4 +- tests/test_din_peer_check.py | 145 ++++++++++++++++++ 9 files changed, 191 insertions(+), 24 deletions(-) create mode 100644 tests/test_din_peer_check.py diff --git a/.claude/scripts/README.md b/.claude/scripts/README.md index 93505059..f6bdaac1 100644 --- a/.claude/scripts/README.md +++ b/.claude/scripts/README.md @@ -29,9 +29,10 @@ python3 .claude/scripts/din_peer_check.py pr 264 # before reviewing python3 .claude/scripts/din_peer_check.py issue-approve 223 # before an approval verdict python3 .claude/scripts/din_peer_check.py issue-close 227 # before closing as completed python3 .claude/scripts/din_peer_check.py task Developer/tasks/task_240926_17.md -python3 .claude/scripts/din_peer_check.py admin # merge path B / bypass needs the admin role +python3 .claude/scripts/din_peer_check.py admin # the emergency bypass needs the admin role +python3 .claude/scripts/din_peer_check.py path-b # merge path B: Umer only, plus the admin role ``` It exits with 0 when the action is OK, 1 when it's refused (the reasons are printed), and 2 on an error. -The check is deliberately conservative. For `issue-close`, the author of any merged PR that cross-references the issue counts as an implementer. If a refusal looks wrong because the mention was incidental, hand the issue to a peer anyway. Don't work around the check. +The check is deliberately conservative. For `issue-close`, everyone who worked on a merged PR that cross-references the issue counts as an implementer: its author and each commit's author, committer and `Co-Authored-By` co-authors. If a refusal looks wrong because the mention was incidental, hand the issue to a peer anyway. Don't work around the check. diff --git a/.claude/scripts/din-maintainers.json b/.claude/scripts/din-maintainers.json index 45f51c37..da5d5874 100644 --- a/.claude/scripts/din-maintainers.json +++ b/.claude/scripts/din-maintainers.json @@ -1,5 +1,6 @@ { "repo": "InfiniteZeroFoundation/DevNet", + "path_b": ["Umer Majeed"], "people": [ {"name": "Umer Majeed", "logins": ["umeradl", "umermjd11"]}, {"name": "Santiago Rodriguez Cetran", "logins": ["Santiagocetran"]}, diff --git a/.claude/scripts/din_peer_check.py b/.claude/scripts/din_peer_check.py index 48d03e34..f5dd52f3 100644 --- a/.claude/scripts/din_peer_check.py +++ b/.claude/scripts/din_peer_check.py @@ -11,7 +11,8 @@ din_peer_check.py issue-approve may I approve issue 's plan? din_peer_check.py issue-close may I close issue as completed? din_peer_check.py task may I close this task's Discussion? - din_peer_check.py admin do I hold the repo admin role (merge path B, bypass)? + din_peer_check.py admin do I hold the repo admin role (emergency bypass)? + din_peer_check.py path-b may I use merge path B? (Umer only, plus the admin role) Exit status: 0 = OK, 1 = REFUSE (reasons printed), 2 = error. @@ -95,21 +96,26 @@ def add(involved, ident, reason): involved.setdefault(person(ident), []).append(reason) -def check_pr(n): - actor = me() - pr = gh_json("api", f"repos/{REPO}/pulls/{n}") - involved = {} - add(involved, pr["user"]["login"], f"author of PR No. {n}") +def pr_contributors(involved, n, author, suffix=""): + """Add PR 's author and every commit author, committer and co-author.""" + add(involved, author, f"author of PR No. {n}") for c in gh_list(f"repos/{REPO}/pulls/{n}/commits"): sha = c["sha"][:7] if c.get("author"): - add(involved, c["author"]["login"], f"authored commit {sha}") + add(involved, c["author"]["login"], f"authored commit {sha}{suffix}") else: - add(involved, c["commit"]["author"]["name"], f"authored commit {sha}") + add(involved, c["commit"]["author"]["name"], f"authored commit {sha}{suffix}") if c.get("committer"): - add(involved, c["committer"]["login"], f"committed {sha}") + add(involved, c["committer"]["login"], f"committed {sha}{suffix}") for ca in co_authors(c["commit"]["message"]): - add(involved, ca, f"co-author on {sha}") + add(involved, ca, f"co-author on {sha}{suffix}") + + +def check_pr(n): + actor = me() + pr = gh_json("api", f"repos/{REPO}/pulls/{n}") + involved = {} + pr_contributors(involved, n, pr["user"]["login"]) return verdict(actor, involved, f"review, approve or merge PR No. {n}") @@ -127,7 +133,8 @@ def issue_people(n, closing): for e in gh_list(f"repos/{REPO}/issues/{n}/timeline"): src = (e.get("source") or {}).get("issue") or {} if e.get("event") == "cross-referenced" and (src.get("pull_request") or {}).get("merged_at"): - add(involved, src["user"]["login"], f"authored merged PR No. {src['number']}") + m = src["number"] + pr_contributors(involved, m, src["user"]["login"], f" of merged PR No. {m}") return involved @@ -160,12 +167,22 @@ def check_admin(): actor = me() role = gh("api", f"repos/{REPO}/collaborators/{actor}/permission", "--jq", ".role_name").strip() if role == "admin": - print(f"OK: {actor} holds the admin role (merge path B / bypass allowed).") + print(f"OK: {actor} holds the admin role (emergency bypass allowed).") return 0 print(f"REFUSE: {actor} has '{role}', not admin. Use merge path A (Developer/MAINTAINING.md No. 4.4).") return 1 +def check_path_b(): + """Merge path B is Umer only (MAINTAINING No. 4.4), and needs the admin bypass.""" + actor = me() + if person(actor) not in CONFIG["path_b"]: + print(f"REFUSE: {actor} ({person(actor)}) can't use merge path B; it's reserved for " + f"{', '.join(CONFIG['path_b'])} (Developer/MAINTAINING.md No. 4.4). Use path A.") + return 1 + return check_admin() + + def main(argv): if not argv: print(__doc__) @@ -177,6 +194,8 @@ def main(argv): return 0 if cmd == "admin": return check_admin() + if cmd == "path-b": + return check_path_b() if not rest: print(__doc__) return 2 diff --git a/.claude/skills/din-merge-build-test/SKILL.md b/.claude/skills/din-merge-build-test/SKILL.md index b34df34c..5b1226af 100644 --- a/.claude/skills/din-merge-build-test/SKILL.md +++ b/.claude/skills/din-merge-build-test/SKILL.md @@ -1,6 +1,6 @@ --- name: din-merge-build-test -description: Use once per PR, after `merge-fix-comment-update` has been run on every file of a DevNet PR's merge-proposal comment — "build and test the merge", "run din-merge-build-test", "now test everything and update the comment". Builds and tests the fixed tree the way CI does (forge build on the real via_ir profile, contract size gate, full forge test, pytest -m "not integration", plus ABI regeneration / doc-link check / hardhat when relevant), fixes whatever breaks and re-runs until everything is green, then updates the merge-proposal comment in one edit. Path A (default): runs in the PR's review worktree before the fixes are pushed onto the PR branch, and fills only "Pending proposal" (the merger fills "Actual merge proposal"). Path B (admins only): runs on the uncommitted merged tree in the `develop` checkout and fills both rows. Not needed when a PR merges as-is — its green `CI OK` is the build/test evidence. Never commits or pushes. Not for the per-file code changes (use `merge-fix-comment-update`), the initial review (`din-pr-review`), or the merge itself (`din-pr-merge`). +description: Use once per PR, after `merge-fix-comment-update` has been run on every file of a DevNet PR's merge-proposal comment — "build and test the merge", "run din-merge-build-test", "now test everything and update the comment". Builds and tests the fixed tree the way CI does (forge build on the real via_ir profile, contract size gate, full forge test, pytest -m "not integration", plus ABI regeneration / doc-link check / hardhat when relevant), fixes whatever breaks and re-runs until everything is green, then updates the merge-proposal comment in one edit. Path A (default): runs in the PR's review worktree before the fixes are pushed onto the PR branch, and fills only "Pending proposal" (the merger fills "Actual merge proposal"). Path B (Umer only, MAINTAINING No. 4.4): runs on the uncommitted merged tree in the `develop` checkout and fills both rows. Not needed when a PR merges as-is — its green `CI OK` is the build/test evidence. Never commits or pushes. Not for the per-file code changes (use `merge-fix-comment-update`), the initial review (`din-pr-review`), or the merge itself (`din-pr-merge`). --- # DIN merge build & test @@ -30,7 +30,7 @@ Where it runs: | | Path A | Path B | |---|---|---| | `$TARGET` | `$DIN_WORK/PRs/PRreview_` (PR head + uncommitted fixes) | `$DIN_REPO` (uncommitted merge + fixes) | -| Comment rows it fills | "Pending proposal" only — "Actual merge proposal" stays for the merger (MAINTAINING No. 4.3) | Both — the admin running path B is the merger | +| Comment rows it fills | "Pending proposal" only — "Actual merge proposal" stays for the merger (MAINTAINING No. 4.3) | Both — Umer, running path B, is the merger | ## 0. Hard rules diff --git a/.claude/skills/din-pr-merge/SKILL.md b/.claude/skills/din-pr-merge/SKILL.md index 469e491a..06c15f09 100644 --- a/.claude/skills/din-pr-merge/SKILL.md +++ b/.claude/skills/din-pr-merge/SKILL.md @@ -1,6 +1,6 @@ --- name: din-pr-merge -description: Use when actually merging a PR into InfiniteZeroFoundation/DevNet's `develop` — "merge PR #N", "merge this into develop", or any request to land a PR after it's been reviewed. Any maintainer who isn't the PR's author or a co-author can run it (checked first with `.claude/scripts/din_peer_check.py`). Path A (the default, per Developer/MAINTAINING.md No. 4.4): push any review fixes onto the PR branch, then merge on GitHub with a merge commit once the approval, `CI OK` and up-to-date gates pass. Path B (admins only, exceptional): the local stash-merge-pop flow plus a deviation commit, pushed through `din-push-develop` with the admin bypass. Either way it ends with the merger watching `develop` CI and posting the outcome comment (`outcome-comment.md`). Runs after `din-pr-review`. Not for reviewing a PR (use `din-pr-review`) and not for generic git merges outside this repo's PR workflow. +description: Use when actually merging a PR into InfiniteZeroFoundation/DevNet's `develop` — "merge PR #N", "merge this into develop", or any request to land a PR after it's been reviewed. Any maintainer who isn't the PR's author or a co-author can run it (checked first with `.claude/scripts/din_peer_check.py`). Path A (the default, per Developer/MAINTAINING.md No. 4.4): push any review fixes onto the PR branch, then merge on GitHub with a merge commit once the approval, `CI OK` and up-to-date gates pass. Path B (Umer only per MAINTAINING No. 4.4, exceptional): the local stash-merge-pop flow plus a deviation commit, pushed through `din-push-develop` with the admin bypass. Either way it ends with the merger watching `develop` CI and posting the outcome comment (`outcome-comment.md`). Runs after `din-pr-review`. Not for reviewing a PR (use `din-pr-review`) and not for generic git merges outside this repo's PR workflow. --- # DIN DevNet PR merge @@ -17,12 +17,12 @@ setup is [`.claude/scripts/README.md`](../../scripts/README.md). | | Path A — GitHub-side merge commit | Path B — local merge + deviation commit | |---|---|---| -| Who | Any maintainer who isn't the author or a co-author | Admins only (`din_peer_check.py admin`) | +| Who | Any maintainer who isn't the author or a co-author | Umer only (MAINTAINING No. 4.4; `din_peer_check.py path-b`) | | When | The default, for every PR | Exceptional: the PR needs adapting against a `develop` that has moved (renames, ABI regeneration, docs drift) and pushing fixes onto the PR branch isn't practical | | Fixes | Pushed onto the PR branch (or a small follow-up PR); the last-push rule sends the final approval to someone else | A separate deviation commit on top of the merge, on `develop` | | Gate | The `develop` ruleset: 1 approval of the latest push, `CI OK`, branch up to date | The admin bypass; log a one-line reason in [Discussion 170](https://github.com/InfiniteZeroFoundation/DevNet/discussions/170) | -Use path A unless the operator explicitly chooses B and is an admin. +Use path A unless the operator explicitly chooses B and is Umer. ## 0. Hard rules — both paths @@ -122,10 +122,10 @@ Then the outcome steps: **C1–C2** below. --- -## Path B — local merge + deviation commit (admins only) +## Path B — local merge + deviation commit (Umer only) ```bash -python3 .claude/scripts/din_peer_check.py admin # REFUSE → use path A +python3 .claude/scripts/din_peer_check.py path-b # REFUSE → use path A source .claude/scripts/din-env.sh cd "$DIN_REPO" ``` diff --git a/.claude/skills/din-pr-review/SKILL.md b/.claude/skills/din-pr-review/SKILL.md index 43bc3548..93c97f42 100644 --- a/.claude/skills/din-pr-review/SKILL.md +++ b/.claude/skills/din-pr-review/SKILL.md @@ -194,5 +194,5 @@ new commits since review — still at ``" if nothing changed. ## 6. Merging Merging is `din-pr-merge`: path A (GitHub-side merge commit by the reviewer, -the default) or path B (local merge, admins only). Attribution rules for any +the default) or path B (local merge, Umer only). Attribution rules for any commit the merger writes are there too. diff --git a/.claude/skills/din-push-develop/SKILL.md b/.claude/skills/din-push-develop/SKILL.md index 3df63fd2..20bf5cf5 100644 --- a/.claude/skills/din-push-develop/SKILL.md +++ b/.claude/skills/din-push-develop/SKILL.md @@ -14,6 +14,7 @@ The rule ([Developer/MAINTAINING.md](../../../Developer/MAINTAINING.md) No. 4.6, ## 0. Gate - **Admins only.** `python3 .claude/scripts/din_peer_check.py admin`. `REFUSE` → don't push; open a PR and use `din-pr-merge` path A. + A path B merge push also needs `python3 .claude/scripts/din_peer_check.py path-b` (path B is Umer only, MAINTAINING No. 4.4); other admins push directly only for emergencies. - **Only run this after the operator has said to push.** Invoking the skill is not permission to push; the "wait for review before commit/push" rule still applies. If the push request came from a flow like din-pr-merge, make sure the file-by-file review already happened. - **Every bypass gets a reason.** Before pushing, have a one-line reason ready (e.g. "path B merge of PR No. N: ABI regeneration against moved develop", "emergency fix for red develop at "). Step 2 logs it in Discussion 170. - Confirm you are pushing `develop` → `origin/develop` from `$DIN_REPO`, and that it is a fast-forward: diff --git a/.claude/skills/merge-fix-comment-update/SKILL.md b/.claude/skills/merge-fix-comment-update/SKILL.md index 0fd41edb..a3ad57d0 100644 --- a/.claude/skills/merge-fix-comment-update/SKILL.md +++ b/.claude/skills/merge-fix-comment-update/SKILL.md @@ -1,6 +1,6 @@ --- name: merge-fix-comment-update -description: Use per-file, after `din-pr-review` has posted its verification and merge-proposal comments for a PR — "merge and fix ", "apply the fix for ", "land per the merge proposal". Input is one relative file path (e.g. `foundry/src/DinCoordinator.sol`); invoke it again for each other file. For that one file it applies the fix described in "Pending proposal" and records what it did in the PR's merge-fix log. Path A (default, any maintainer, Developer/MAINTAINING.md No. 4.4): the fix goes onto the PR branch in the review worktree, and `din-pr-merge` later pushes it onto the PR. Path B (admins only): the PR's diff is applied into the `develop` checkout per "Recommended merge proposal", then the fix on top. Code changes only — it does NOT build, run forge/pytest, commit, or edit the GitHub comment; once every file is through, the operator runs `din-merge-build-test`, which builds/tests everything, fixes until green, and then updates the comment for all files. Stops and asks the operator whenever the row's proposal implies a decision, a conflict, or a judgment call — never guesses through those. Not for merging a whole PR at once (use `din-pr-merge`) and not for the initial review (use `din-pr-review`). +description: Use per-file, after `din-pr-review` has posted its verification and merge-proposal comments for a PR — "merge and fix ", "apply the fix for ", "land per the merge proposal". Input is one relative file path (e.g. `foundry/src/DinCoordinator.sol`); invoke it again for each other file. For that one file it applies the fix described in "Pending proposal" and records what it did in the PR's merge-fix log. Path A (default, any maintainer, Developer/MAINTAINING.md No. 4.4): the fix goes onto the PR branch in the review worktree, and `din-pr-merge` later pushes it onto the PR. Path B (Umer only): the PR's diff is applied into the `develop` checkout per "Recommended merge proposal", then the fix on top. Code changes only — it does NOT build, run forge/pytest, commit, or edit the GitHub comment; once every file is through, the operator runs `din-merge-build-test`, which builds/tests everything, fixes until green, and then updates the comment for all files. Stops and asks the operator whenever the row's proposal implies a decision, a conflict, or a judgment call — never guesses through those. Not for merging a whole PR at once (use `din-pr-merge`) and not for the initial review (use `din-pr-review`). --- # Merge-fix-comment-update @@ -31,7 +31,7 @@ tree over and over. The pipeline is: **Where the fix goes depends on the merge path** (MAINTAINING No. 4.4, `din-pr-merge`): -| | Path A — default, any maintainer | Path B — admins only | +| | Path A — default, any maintainer | Path B — Umer only | |---|---|---| | Target tree (`$TARGET`) | The review worktree `$DIN_WORK/PRs/PRreview_`, on `pr--review` at the PR head | The `develop` checkout `$DIN_REPO` | | The PR's own diff | Already there — skip step 3 | Applied file by file (step 3) | diff --git a/tests/test_din_peer_check.py b/tests/test_din_peer_check.py new file mode 100644 index 00000000..6642f6ef --- /dev/null +++ b/tests/test_din_peer_check.py @@ -0,0 +1,145 @@ +"""Tests for the peer-rule check behind the din-* skills (.claude/scripts/). + +The check is the only thing stopping a maintainer from approving, merging or +closing their own work, so a gap in who counts as "involved" is a policy hole. +GitHub is faked at the gh_json/gh_list boundary. +""" +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path + +import pytest + +SCRIPT = Path(__file__).resolve().parents[1] / ".claude" / "scripts" / "din_peer_check.py" + + +def _load(): + spec = spec_from_file_location("din_peer_check", SCRIPT) + module = module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +peer = _load() + +ISSUE = 900 +PR = 901 + + +def _commit(sha, author, message): + return { + "sha": sha, + "author": {"login": author}, + "committer": {"login": "web-flow"}, + "commit": {"author": {"name": author}, "message": message}, + } + + +COMMITS = [ + _commit( + "a" * 40, + "pr-owner", + "fix: the thing\n\n" + "Co-Authored-By: Helper <123+co-dev@users.noreply.github.com>\n" + "Co-Authored-By: Claude \n", + ), + _commit("b" * 40, "second-committer", "fix: follow-up"), +] + + +@pytest.fixture +def fake_github(monkeypatch): + def gh_json(*args): + endpoint = args[1] + if endpoint.endswith(f"/issues/{ISSUE}"): + return {"user": {"login": "issue-opener"}, "assignees": []} + if endpoint.endswith(f"/pulls/{PR}"): + return {"user": {"login": "pr-owner"}} + raise AssertionError(f"unexpected gh_json {args}") + + def gh_list(endpoint): + if endpoint.endswith(f"/issues/{ISSUE}/timeline"): + return [ + { + "event": "cross-referenced", + "source": { + "issue": { + "number": PR, + "user": {"login": "pr-owner"}, + "pull_request": {"merged_at": "2026-10-01T00:00:00Z"}, + } + }, + }, + # An unmerged PR's people don't count as implementers. + { + "event": "cross-referenced", + "source": { + "issue": { + "number": 902, + "user": {"login": "unmerged-author"}, + "pull_request": {"merged_at": None}, + } + }, + }, + ] + if endpoint.endswith(f"/pulls/{PR}/commits"): + return COMMITS + raise AssertionError(f"unexpected gh_list {endpoint}") + + monkeypatch.setattr(peer, "gh_json", gh_json) + monkeypatch.setattr(peer, "gh_list", gh_list) + + +def _as(monkeypatch, login): + monkeypatch.setattr(peer, "me", lambda: login) + + +@pytest.mark.parametrize("login", ["pr-owner", "co-dev", "second-committer"]) +def test_issue_close_refuses_everyone_on_a_merged_pr(fake_github, monkeypatch, capsys, login): + _as(monkeypatch, login) + assert peer.check_issue(ISSUE, closing=True) == 1 + assert "REFUSE" in capsys.readouterr().out + + +@pytest.mark.parametrize("login", ["bystander", "unmerged-author"]) +def test_issue_close_allows_uninvolved(fake_github, monkeypatch, capsys, login): + _as(monkeypatch, login) + assert peer.check_issue(ISSUE, closing=True) == 0 + assert capsys.readouterr().out.startswith("OK") + + +def test_issue_close_names_the_merged_pr_in_the_reason(fake_github, monkeypatch, capsys): + _as(monkeypatch, "co-dev") + peer.check_issue(ISSUE, closing=True) + assert f"of merged PR No. {PR}" in capsys.readouterr().out + + +def test_ai_trailer_is_not_a_person(fake_github): + involved = peer.issue_people(ISSUE, closing=True) + assert "Claude" not in involved + assert "web-flow" not in involved + + +def test_pr_check_still_counts_co_authors(fake_github, monkeypatch): + _as(monkeypatch, "co-dev") + assert peer.check_pr(PR) == 1 + + +def test_path_b_refuses_non_umer_admin(monkeypatch, capsys): + _as(monkeypatch, "Santiagocetran") + monkeypatch.setattr(peer, "gh", lambda *a: "admin\n") + assert peer.check_path_b() == 1 + assert "REFUSE" in capsys.readouterr().out + + +@pytest.mark.parametrize("login", ["umeradl", "umermjd11"]) +def test_path_b_allows_umer_with_admin_role(monkeypatch, login): + _as(monkeypatch, login) + monkeypatch.setattr(peer, "gh", lambda *a: "admin\n") + assert peer.check_path_b() == 0 + + +def test_path_b_still_needs_the_admin_role(monkeypatch): + _as(monkeypatch, "umermjd11") + monkeypatch.setattr(peer, "gh", lambda *a: "write\n") + assert peer.check_path_b() == 1 From 301abda90f403a4cb75441032c831eef8add5dfa Mon Sep 17 00:00:00 2001 From: umermjd11 Date: Fri, 23 Oct 2026 17:46:02 +0500 Subject: [PATCH 23/48] fix(stake): keep DinValidatorStake __gap at 50; refresh line refs (PR 236 review) storage_layout.md "Core rules" (as applied to DinEmission in 07fd597) say nothing on develop is deployed yet, so new variables go above __gap without shrinking it. Restore uint256[50]: the three S5 global slots stay at 17-19 and __gap moves to slot 20. Add them to the storage_layout.md DinValidatorStake block, reword DinValidatorStake.md section 13 and the change log, and amend task_061026_20 Part C's storage line to match. Threat model: slashPartial is now at DinValidatorStake.sol:329 (was :294) and the per-slasher ring read at :344 (was :309). Co-Authored-By: Claude Opus 5.5 --- Developer/design/adversarial-threat-model.md | 4 ++-- Developer/tasks/task_061026_20.md | 2 +- Documentation/technical/contracts/DinValidatorStake.md | 4 ++-- Documentation/technical/storage_layout.md | 9 +++++++-- foundry/src/DinValidatorStake.sol | 3 +-- 5 files changed, 13 insertions(+), 9 deletions(-) diff --git a/Developer/design/adversarial-threat-model.md b/Developer/design/adversarial-threat-model.md index b815d552..dc825ca7 100644 --- a/Developer/design/adversarial-threat-model.md +++ b/Developer/design/adversarial-threat-model.md @@ -44,7 +44,7 @@ entry in `foundry/test/P3Adversarial.t.sol`. |---|---| | **Attacker** | Validator repeatedly misses S1 votes (or S2 submissions) across GIs | | **Capability** | Registers each GI, never votes; tolerates a partial slash each time | -| **Protocol response today** | `slashPartial` in `DinValidatorStake.sol:294` maintains a per-validator, **per-slasher-contract** rolling GI list (`_partialSlashGIs[validator][msg.sender]`). When slashes within the last `s5RecidivismWindow` (default: 5) GIs for that slasher reach `s5RecidivismThreshold` (default: 3), the slash is escalated to full `MIN_STAKE` and the validator is jailed for `s5JailDuration` (default: 7 days). `isValidatorActive` returns `false` while jailed, blocking re-registration. | +| **Protocol response today** | `slashPartial` in `DinValidatorStake.sol:329` maintains a per-validator, **per-slasher-contract** rolling GI list (`_partialSlashGIs[validator][msg.sender]`). When slashes within the last `s5RecidivismWindow` (default: 5) GIs for that slasher reach `s5RecidivismThreshold` (default: 3), the slash is escalated to full `MIN_STAKE` and the validator is jailed for `s5JailDuration` (default: 7 days). `isValidatorActive` returns `false` while jailed, blocking re-registration. | | **Expected test outcome** | **DEFENDED** — the test runs the attacker through exactly `s5RecidivismThreshold` GIs of missed votes (3 missed GIs). Escalation fires on the **3rd miss** (the threshold-th slash, not threshold+1). After the 3rd slash, `isValidatorActive` is `false` and a 4th registration attempt reverts. | | **Trust assumption** | S5 counter is per-slasher-contract (per model). A validator who spreads misses across different models' task contracts (≤ `s5RecidivismThreshold − 1` per model per window) never reaches the escalation threshold for any single model. This is a gap in the multi-model case (see gaps-before-audit). | @@ -139,7 +139,7 @@ entry in `foundry/test/P3Adversarial.t.sol`. | Field | Value | |---|---| | **Attacker** | Validator registered across multiple models, spreading misses so no single model's S5 ring reaches threshold | -| **Capability** | Two or more models' task contracts are separate slashers; `_partialSlashGIs[validator][msg.sender]` is namespaced per slasher (`DinValidatorStake.sol:309`). With default threshold 3, ≤ 2 misses per model per window avoids S5 on every individual model. | +| **Capability** | Two or more models' task contracts are separate slashers; `_partialSlashGIs[validator][msg.sender]` is namespaced per slasher (`DinValidatorStake.sol:344`). With default threshold 3, ≤ 2 misses per model per window avoids S5 on every individual model. | | **Protocol response today** | Two-level S5 ([#193](https://github.com/InfiniteZeroFoundation/DevNet/issues/193)): besides the per-slasher GI ring, `slashPartial` counts every partial slash for the validator in one time-windowed ring shared by all slasher contracts (`_partialSlashTimes`; `s5GlobalWindow` 7 days, `s5GlobalThreshold` 6 by default). Reaching either threshold escalates (full `MIN_STAKE` + jail) and emits `ValidatorEscalatedS5Global` when only the global level fired. The same ring also adds up one model's S1 (auditor contract) and S2 (coordinator) misses. | | **Expected test outcome** | **DEFENDED above the global threshold.** With defaults, a validator escalates on its 6th partial slash within 7 days, however the misses are spread (`SlashingInvariants.t.sol` `test_s5Global_escalatesAcrossSlashers`, `test_s5Global_addsUpTwoSlashersOfOneModel`). Below it the gap is bounded, not closed: up to 5 misses across models in a window only pay their partial slashes. PR #218's `test_knownGap_perSlasherS5Evasion` (2 models × 2 misses = 4 < 6) still passes as written; when it rebases it should spread misses until the global count reaches `s5GlobalThreshold` and assert escalation (`test_defended_…`). | | **Trust assumption** | `s5GlobalWindow` / `s5GlobalThreshold` are calibrated (placeholders until [#155](https://github.com/InfiniteZeroFoundation/DevNet/issues/155)). On a proxy upgraded from an earlier version both read 0 (global level off) until the owner sets them. | diff --git a/Developer/tasks/task_061026_20.md b/Developer/tasks/task_061026_20.md index 810c8115..77f45075 100644 --- a/Developer/tasks/task_061026_20.md +++ b/Developer/tasks/task_061026_20.md @@ -128,7 +128,7 @@ There are two ways back out of the warn band: a fold that actually saves bytes, - This also closes the same-model split: one validator's S1 and S2 misses now add up. **Scope:** -- **Storage.** Append the new variables before `__gap` (`:174`, 50 slots) and shrink the gap by the slots used. `DinValidatorStakeV2` inherits them. `UpgradeValidation.t.sol` and the `DeployPlatform.t.sol` upgrade tests must stay green. +- **Storage.** Append the new variables before `__gap` (`:174`, 50 slots) and shrink the gap by the slots used. *(Amended at PR No. 236: `__gap` stays at 50. Nothing on `develop` is deployed yet, so per the "Core rules" in `storage_layout.md` new variables go above `__gap` without shrinking it, as `07fd597` did for `DinEmission`.)* `DinValidatorStakeV2` inherits them. `UpgradeValidation.t.sol` and the `DeployPlatform.t.sol` upgrade tests must stay green. - **Setter.** `setS5GlobalParams(window, threshold)`, validated like `setS5RecidivismParams` (`:583-594`). - **Defaults.** `s5GlobalWindow = 7 days` and `s5GlobalThreshold = 6` (2 × `s5RecidivismThreshold`). These are placeholders until #155. - Set them in `initialize`. diff --git a/Documentation/technical/contracts/DinValidatorStake.md b/Documentation/technical/contracts/DinValidatorStake.md index 1370c8b5..2e9d2006 100644 --- a/Documentation/technical/contracts/DinValidatorStake.md +++ b/Documentation/technical/contracts/DinValidatorStake.md @@ -305,7 +305,7 @@ Until step 8, slasher management through the coordinator reverts `ValidatorStake | ProxyAdmin | One per proxy, owned by the deployer | Implementation upgrades | - **Upgrade path:** `cd foundry && CONTRACT=DinValidatorStake forge script script/UpgradePlatform.s.sol ...` (see [UpgradePlatform](foundry/script/UpgradePlatform.md)); `foundry/test/UpgradeValidation.t.sol` runs `Upgrades.validateImplementation` on the implementation, and `DinValidatorStakeUpgradeTest` in `foundry/test/DeployPlatform.t.sol` upgrades to `foundry/src/upgrade/DinValidatorStakeV2.sol` and checks stakes and access control survive. -- **Storage:** the S5 global level appends three slots (`s5GlobalWindow`, `s5GlobalThreshold`, `_partialSlashTimes`) after the S6 block and shrinks `__gap` from 50 to 47, so the layout stays upgrade-compatible. +- **Storage:** the S5 global level appends three slots (`s5GlobalWindow`, `s5GlobalThreshold`, `_partialSlashTimes`) after the S6 block, above `__gap`. `__gap` stays at `[50]`: nothing on `develop` is deployed yet, so per the "Core rules" in [storage_layout.md](../storage_layout.md) new variables go above it without shrinking it. - **Trust implication:** this contract custodies all staked DIN; the ProxyAdmin owner can replace every rule here without moving the balance. --- @@ -328,7 +328,7 @@ Until step 8, slasher management through the coordinator reverts `ValidatorStake - `slashPartial` keeps the per-slasher GI ring and adds a per-validator timestamp ring shared by every slasher contract (`_partialSlashTimes`). Either level reaching its threshold escalates (full `MIN_STAKE` + jail) and clears both rings. - New owner-settable `s5GlobalWindow` (7 days) / `s5GlobalThreshold` (6), set in `initialize`; `setS5GlobalParams(window, threshold)` with (0, 0) as the off switch. On an upgraded proxy both read 0 (off); no reinitializer. -- New events `ValidatorEscalatedS5Global` (emitted when only the global level fired; `ValidatorEscalatedS5` keeps its signature) and `S5GlobalParamsUpdated`; new view `getPartialSlashTimes`. `__gap` 50 → 47. +- New events `ValidatorEscalatedS5Global` (emitted when only the global level fired; `ValidatorEscalatedS5` keeps its signature) and `S5GlobalParamsUpdated`; new view `getPartialSlashTimes`. Three storage slots appended above `__gap` (kept at 50). ### P3 — slashing, jailing, parameters (foundry) diff --git a/Documentation/technical/storage_layout.md b/Documentation/technical/storage_layout.md index 502b016a..86fb4ed0 100644 --- a/Documentation/technical/storage_layout.md +++ b/Documentation/technical/storage_layout.md @@ -104,7 +104,10 @@ above for why that's fine pre-deployment. _partialSlashGIs : mapping(address => mapping(address => uint256[])) (slot 14) s6NoParticipationThreshold : uint256 (slot 15) s6NoParticipationCount : mapping(address => uint256) (slot 16) - __gap : uint256[50] (slot 17) + s5GlobalWindow : uint256 (slot 17, issue #193) + s5GlobalThreshold : uint256 (slot 18, issue #193) + _partialSlashTimes : mapping(address => uint256[]) (slot 19, issue #193) + __gap : uint256[50] (slot 20) ``` `DIN_TOKEN` / `DIN_COORDINATOR` are set once in `initialize` (upper-case names, but @@ -114,7 +117,9 @@ to `10 DIN` / `7 days` and owner-settable (`setMinStake`, `setUnbondingPeriod`), `UNBONDING_PERIOD` is a `uint64` but still takes a full slot: the next variable is a mapping, which always starts a new slot. `ValidatorInfo` / `ModelStakeBounds` are structs stored inside mapping entries; their internal layout does not affect the -contract's top-level slot numbering. +contract's top-level slot numbering. `s5GlobalWindow` / `s5GlobalThreshold` are set +in `initialize` and by `setS5GlobalParams`. `__gap` stays at `[50]` rather than +shrinking — see "Core rules" above for why that's fine pre-deployment. --- diff --git a/foundry/src/DinValidatorStake.sol b/foundry/src/DinValidatorStake.sol index f39627d5..721aee9d 100644 --- a/foundry/src/DinValidatorStake.sol +++ b/foundry/src/DinValidatorStake.sol @@ -200,8 +200,7 @@ contract DinValidatorStake is ); // Reserved for future state variables at this inheritance level. - // 50 - 3 slots used by the S5 global level above. - uint256[47] private __gap; + uint256[50] private __gap; /// @custom:oz-upgrades-unsafe-allow constructor constructor() { From 3d356b20e42030f47eec7820f329a56155e1e77d Mon Sep 17 00:00:00 2001 From: umermjd11 Date: Fri, 23 Oct 2026 21:02:01 +0500 Subject: [PATCH 24/48] style(deploy): space before = in DEFAULT_S5_GLOBAL_THRESHOLD (PR 236 review) Co-Authored-By: Claude Opus 5.5 --- foundry/script/DeployPlatform.s.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/foundry/script/DeployPlatform.s.sol b/foundry/script/DeployPlatform.s.sol index c15043e3..826231b7 100644 --- a/foundry/script/DeployPlatform.s.sol +++ b/foundry/script/DeployPlatform.s.sol @@ -85,7 +85,7 @@ contract DeployPlatform is DeploymentsPath { uint256 internal constant DEFAULT_S5_JAIL_DURATION = 7 days; uint256 internal constant DEFAULT_S6_THRESHOLD = 3; uint256 internal constant DEFAULT_S5_GLOBAL_WINDOW = 7 days; - uint256 internal constant DEFAULT_S5_GLOBAL_THRESHOLD= 6; + uint256 internal constant DEFAULT_S5_GLOBAL_THRESHOLD = 6; /// @notice Tokenomics values the deploy applies; see the env keys in the header. struct Tokenomics { From d63ed98bc1407785e976eb54d7cecb081f28300b Mon Sep 17 00:00:00 2001 From: umermjd11 Date: Fri, 23 Oct 2026 22:08:06 +0500 Subject: [PATCH 25/48] fix(stake): S5 escalation epoch so no miss counts toward two escalations A global S5 escalation cleared only the caller's per-slasher ring, so the misses other slashers had fed into it stayed in their own rings and could trigger a second full MIN_STAKE slash from one new miss after the jail (PR 236 review, finding No. 1). Every escalation (either level) now increments s5EscalationEpoch[validator]. _partialSlashEpoch records the epoch each [validator][slasher] ring was last written in; a ring from an earlier epoch is dropped on that slasher's next partial slash and reads as empty through getPartialSlashGIs. Two mappings appended at slots 20-21, __gap stays at 50 (slot 22). Tests: test_s5Global_escalationSettlesOtherSlashersRings (the review's scenario) and test_s5_perSlasherEscalation_settlesOtherSlashersRings. With the lazy drop disabled both fail (stale ring 2 != 0; 10e18 != 3e18). Co-Authored-By: Claude Opus 5.5 --- Developer/design/adversarial-threat-model.md | 4 +- .../technical/contracts/DinValidatorStake.md | 18 +++--- Documentation/technical/storage_layout.md | 4 +- dincli/abis/DinValidatorStake.json | 19 +++++++ foundry/src/DinValidatorStake.sol | 27 ++++++++- foundry/test/SlashingInvariants.t.sol | 56 +++++++++++++++++++ 6 files changed, 115 insertions(+), 13 deletions(-) diff --git a/Developer/design/adversarial-threat-model.md b/Developer/design/adversarial-threat-model.md index dc825ca7..bc48e808 100644 --- a/Developer/design/adversarial-threat-model.md +++ b/Developer/design/adversarial-threat-model.md @@ -44,7 +44,7 @@ entry in `foundry/test/P3Adversarial.t.sol`. |---|---| | **Attacker** | Validator repeatedly misses S1 votes (or S2 submissions) across GIs | | **Capability** | Registers each GI, never votes; tolerates a partial slash each time | -| **Protocol response today** | `slashPartial` in `DinValidatorStake.sol:329` maintains a per-validator, **per-slasher-contract** rolling GI list (`_partialSlashGIs[validator][msg.sender]`). When slashes within the last `s5RecidivismWindow` (default: 5) GIs for that slasher reach `s5RecidivismThreshold` (default: 3), the slash is escalated to full `MIN_STAKE` and the validator is jailed for `s5JailDuration` (default: 7 days). `isValidatorActive` returns `false` while jailed, blocking re-registration. | +| **Protocol response today** | `slashPartial` in `DinValidatorStake.sol:340` maintains a per-validator, **per-slasher-contract** rolling GI list (`_partialSlashGIs[validator][msg.sender]`). When slashes within the last `s5RecidivismWindow` (default: 5) GIs for that slasher reach `s5RecidivismThreshold` (default: 3), the slash is escalated to full `MIN_STAKE` and the validator is jailed for `s5JailDuration` (default: 7 days). `isValidatorActive` returns `false` while jailed, blocking re-registration. | | **Expected test outcome** | **DEFENDED** — the test runs the attacker through exactly `s5RecidivismThreshold` GIs of missed votes (3 missed GIs). Escalation fires on the **3rd miss** (the threshold-th slash, not threshold+1). After the 3rd slash, `isValidatorActive` is `false` and a 4th registration attempt reverts. | | **Trust assumption** | S5 counter is per-slasher-contract (per model). A validator who spreads misses across different models' task contracts (≤ `s5RecidivismThreshold − 1` per model per window) never reaches the escalation threshold for any single model. This is a gap in the multi-model case (see gaps-before-audit). | @@ -139,7 +139,7 @@ entry in `foundry/test/P3Adversarial.t.sol`. | Field | Value | |---|---| | **Attacker** | Validator registered across multiple models, spreading misses so no single model's S5 ring reaches threshold | -| **Capability** | Two or more models' task contracts are separate slashers; `_partialSlashGIs[validator][msg.sender]` is namespaced per slasher (`DinValidatorStake.sol:344`). With default threshold 3, ≤ 2 misses per model per window avoids S5 on every individual model. | +| **Capability** | Two or more models' task contracts are separate slashers; `_partialSlashGIs[validator][msg.sender]` is namespaced per slasher (`DinValidatorStake.sol:355`). With default threshold 3, ≤ 2 misses per model per window avoids S5 on every individual model. | | **Protocol response today** | Two-level S5 ([#193](https://github.com/InfiniteZeroFoundation/DevNet/issues/193)): besides the per-slasher GI ring, `slashPartial` counts every partial slash for the validator in one time-windowed ring shared by all slasher contracts (`_partialSlashTimes`; `s5GlobalWindow` 7 days, `s5GlobalThreshold` 6 by default). Reaching either threshold escalates (full `MIN_STAKE` + jail) and emits `ValidatorEscalatedS5Global` when only the global level fired. The same ring also adds up one model's S1 (auditor contract) and S2 (coordinator) misses. | | **Expected test outcome** | **DEFENDED above the global threshold.** With defaults, a validator escalates on its 6th partial slash within 7 days, however the misses are spread (`SlashingInvariants.t.sol` `test_s5Global_escalatesAcrossSlashers`, `test_s5Global_addsUpTwoSlashersOfOneModel`). Below it the gap is bounded, not closed: up to 5 misses across models in a window only pay their partial slashes. PR #218's `test_knownGap_perSlasherS5Evasion` (2 models × 2 misses = 4 < 6) still passes as written; when it rebases it should spread misses until the global count reaches `s5GlobalThreshold` and assert escalation (`test_defended_…`). | | **Trust assumption** | `s5GlobalWindow` / `s5GlobalThreshold` are calibrated (placeholders until [#155](https://github.com/InfiniteZeroFoundation/DevNet/issues/155)). On a proxy upgraded from an earlier version both read 0 (global level off) until the owner sets them. | diff --git a/Documentation/technical/contracts/DinValidatorStake.md b/Documentation/technical/contracts/DinValidatorStake.md index 2e9d2006..86a9428e 100644 --- a/Documentation/technical/contracts/DinValidatorStake.md +++ b/Documentation/technical/contracts/DinValidatorStake.md @@ -73,6 +73,8 @@ Its central safety property: a validator cannot misbehave and then withdraw befo | `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 per-slasher ring) | | `_partialSlashTimes` | `mapping(address => uint256[])` (private) | Partial-slash block timestamps per validator across **all** slasher contracts (S5 global ring, issue No. 193) | +| `s5EscalationEpoch` | `mapping(address => uint256)` | Number of S5 escalations (either level) per validator; an escalation settles every slasher's ring | +| `_partialSlashEpoch` | `mapping(address => mapping(address => uint256))` (private) | The `s5EscalationEpoch` each per-slasher ring was last written in; a ring from an earlier epoch is stale | | `__gap` | `uint256[50]` | Reserved slots | Slot order is in [storage_layout.md](../storage_layout.md#dinvalidatorstake). @@ -160,9 +162,9 @@ For faults like bad consensus or S3 score deviation. Reverts on zero address / z Used by the task contracts for liveness faults (auditor didn't reveal a vote — S1; aggregator didn't reveal a CID — S2). `amount` is computed by the caller as a fraction of the global `MIN_STAKE`: `minStake() × s1SlashFractionBps / 10 000` (auditor) or `× s2SlashFractionBps` (aggregator), with the fractions set per model on the task contracts. -1. Appends `giIndex` to `_partialSlashGIs[validator][msg.sender]` and trims entries with `giIndex − entry ≥ s5RecidivismWindow` (**per-slasher level**). +1. If this caller's ring is from an earlier `s5EscalationEpoch`, drops it first. Then appends `giIndex` to `_partialSlashGIs[validator][msg.sender]` and trims entries with `giIndex − entry ≥ s5RecidivismWindow` (**per-slasher level**). 2. If `s5GlobalThreshold ≠ 0`, also appends `block.timestamp` to `_partialSlashTimes[validator]` and trims entries with `now − entry ≥ s5GlobalWindow` (**global level**, issue No. 193). -3. If the per-slasher ring reaches `s5RecidivismThreshold`, **or** the global ring reaches `s5GlobalThreshold` → **S5 escalation**: slash a full `MIN_STAKE` (reason `S5_RECIDIVISM`), jail for `s5JailDuration`, and clear both this caller's ring and the global ring. It emits `ValidatorEscalatedS5` when the per-slasher level fired, otherwise `ValidatorEscalatedS5Global`. +3. If the per-slasher ring reaches `s5RecidivismThreshold`, **or** the global ring reaches `s5GlobalThreshold` → **S5 escalation**: slash a full `MIN_STAKE` (reason `S5_RECIDIVISM`), jail for `s5JailDuration`, increment `s5EscalationEpoch[validator]`, and clear both this caller's ring and the global ring. The other slashers' rings are now stale, and each is dropped on that slasher's next call (step 1), so the misses that led to the escalation never count toward a second one. It emits `ValidatorEscalatedS5` when the per-slasher level fired, otherwise `ValidatorEscalatedS5Global`. 4. Otherwise slash `amount` with the given reason. The per-slasher ring is namespaced by the **calling task contract** because `giIndex` is a per-model counter: keying by validator alone would interleave different models' GI sequences and break the ascending-order trim. On its own that counts recidivism per task contract, so misses spread across models, or across one model's coordinator (S2) and auditor (S1), never add up. The global ring closes that gap: block timestamps never go backwards, so one ring per validator stays ascending across every slasher. @@ -207,7 +209,7 @@ Slasher-only. Reverts on zero address or zero duration (`InvalidJailDuration`); | `isSlasherContract(a)` | slasher flag | | `getEncryptionKey(v)` | registered key or empty bytes | | `getModelStakeMin(modelId)` | `modelMinStakeBounds[modelId].min` | -| `getPartialSlashGIs(v, slasher)` | the S5 per-slasher ring for that validator/caller pair | +| `getPartialSlashGIs(v, slasher)` | the S5 per-slasher ring for that validator/caller pair (empty if it predates the validator's last S5 escalation) | | `getPartialSlashTimes(v)` | the S5 global ring (timestamps, all slashers) | Plus the public getters for all state in §3. @@ -272,7 +274,7 @@ A validator with `20 DIN` misses a reveal on the same model in three consecutive - **GI 3:** the ring becomes `[1, 2, 3]`, reaching the threshold of 3 within the window of 5, so the call **escalates**. It slashes a full `MIN_STAKE` (10 DIN, reason `S5_RECIDIVISM`) instead of 3 DIN, jails the validator for 7 days, emits `ValidatorEscalatedS5` and clears the ring. The result is `activeStake = 4`, status `Jailed`. - **After the jail:** `reactivate()` reverts with `StakeBelowFloor` (4 < 10). The validator has to `stake(10)` first. Once the jail has expired, that `stake` call already recomputes the status to `Active`, so `reactivate()` is never needed (§14 No. 3). -The ring is kept per calling task contract, so the same three misses spread across three different models would not escalate (§14 No. 5). +The ring is kept per calling task contract, so the same three misses spread across three different models would not escalate on this level. The global level adds them up (§7.3, §14 No. 5). Either way, the escalation also settles the validator's rings with every other task contract: a miss after the jail starts a fresh count everywhere. --- @@ -305,7 +307,7 @@ Until step 8, slasher management through the coordinator reverts `ValidatorStake | ProxyAdmin | One per proxy, owned by the deployer | Implementation upgrades | - **Upgrade path:** `cd foundry && CONTRACT=DinValidatorStake forge script script/UpgradePlatform.s.sol ...` (see [UpgradePlatform](foundry/script/UpgradePlatform.md)); `foundry/test/UpgradeValidation.t.sol` runs `Upgrades.validateImplementation` on the implementation, and `DinValidatorStakeUpgradeTest` in `foundry/test/DeployPlatform.t.sol` upgrades to `foundry/src/upgrade/DinValidatorStakeV2.sol` and checks stakes and access control survive. -- **Storage:** the S5 global level appends three slots (`s5GlobalWindow`, `s5GlobalThreshold`, `_partialSlashTimes`) after the S6 block, above `__gap`. `__gap` stays at `[50]`: nothing on `develop` is deployed yet, so per the "Core rules" in [storage_layout.md](../storage_layout.md) new variables go above it without shrinking it. +- **Storage:** the S5 global level appends five slots (`s5GlobalWindow`, `s5GlobalThreshold`, `_partialSlashTimes`, `s5EscalationEpoch`, `_partialSlashEpoch`) after the S6 block, above `__gap`. `__gap` stays at `[50]`: nothing on `develop` is deployed yet, so per the "Core rules" in [storage_layout.md](../storage_layout.md) new variables go above it without shrinking it. - **Trust implication:** this contract custodies all staked DIN; the ProxyAdmin owner can replace every rule here without moving the balance. --- @@ -316,7 +318,7 @@ Until step 8, slasher management through the coordinator reverts `ValidatorStake - **No. 2 — S5 escalation on a blacklisted validator reverts the whole slash:** escalation calls `_jailInternal`, which reverts `ValidatorIsBlacklisted`. A task contract's slashing loop hitting a blacklisted repeat offender would revert, not just skip that validator. - **No. 3 — Jail exit does not require `reactivate()`:** after `jailedUntil` passes, any status-syncing call (e.g. `stake`) recomputes the status, bypassing `reactivate()`'s `StakeBelowFloor` check (the later sync still requires `≥ MIN_STAKE` for `Active`). - **No. 4 — Stale NatSpec (partly fixed, issue No. 180):** the `setModelStakeBounds` / `setMaxConcurrentRegistrationsPerStakeUnit` NatSpec now says where the task contracts enforce both (§8), `activeRegistrationCount` names `releaseGIRegistrationSlots` as the decrement, and `getModelStakeMin` names the `onlyOwner` (DIN-Representative) setter instead of "the model owner". Still open: `modelMinStakeBounds[].max` is never read. -- **No. 5 — Recidivism is per model (fixed, issue No. 193):** the per-slasher ring is keyed by calling contract, so on its own a validator faulting across many models never escalates. The global ring (`s5GlobalWindow` / `s5GlobalThreshold`, defaults 7 days / 6) now counts every slasher's partial slashes together. Defaults are placeholders until issue No. 155. A proxy upgraded from an earlier version reads `0` for both, so the global level stays off until the owner calls `setS5GlobalParams`. +- **No. 5 — Recidivism is per model (fixed, issue No. 193):** the per-slasher ring is keyed by calling contract, so on its own a validator faulting across many models never escalates. The global ring (`s5GlobalWindow` / `s5GlobalThreshold`, defaults 7 days / 6) now counts every slasher's partial slashes together. Any S5 escalation increments `s5EscalationEpoch`, which settles every slasher's per-slasher ring, so misses that fed a global escalation are not counted again toward a per-slasher one. Defaults are placeholders until issue No. 155. A proxy upgraded from an earlier version reads `0` for both, so the global level stays off until the owner calls `setS5GlobalParams`. - **No. 6 — Blacklisted funds are frozen:** blacklisted validators cannot unstake or claim; there is no recovery path other than unblacklisting. - **No. 7 — Custody meets upgradeability:** see §13. @@ -326,9 +328,9 @@ Until step 8, slasher management through the coordinator reverts `ValidatorStake ### Cross-model S5 (issue No. 193) -- `slashPartial` keeps the per-slasher GI ring and adds a per-validator timestamp ring shared by every slasher contract (`_partialSlashTimes`). Either level reaching its threshold escalates (full `MIN_STAKE` + jail) and clears both rings. +- `slashPartial` keeps the per-slasher GI ring and adds a per-validator timestamp ring shared by every slasher contract (`_partialSlashTimes`). Either level reaching its threshold escalates (full `MIN_STAKE` + jail) and clears both rings. Every escalation also bumps the validator's `s5EscalationEpoch`, and per-slasher rings from an earlier epoch are dropped lazily, so no miss counts toward two escalations. - New owner-settable `s5GlobalWindow` (7 days) / `s5GlobalThreshold` (6), set in `initialize`; `setS5GlobalParams(window, threshold)` with (0, 0) as the off switch. On an upgraded proxy both read 0 (off); no reinitializer. -- New events `ValidatorEscalatedS5Global` (emitted when only the global level fired; `ValidatorEscalatedS5` keeps its signature) and `S5GlobalParamsUpdated`; new view `getPartialSlashTimes`. Three storage slots appended above `__gap` (kept at 50). +- New events `ValidatorEscalatedS5Global` (emitted when only the global level fired; `ValidatorEscalatedS5` keeps its signature) and `S5GlobalParamsUpdated`; new view `getPartialSlashTimes`; new public getter `s5EscalationEpoch`. Five storage slots appended above `__gap` (kept at 50). ### P3 — slashing, jailing, parameters (foundry) diff --git a/Documentation/technical/storage_layout.md b/Documentation/technical/storage_layout.md index 86fb4ed0..f657625f 100644 --- a/Documentation/technical/storage_layout.md +++ b/Documentation/technical/storage_layout.md @@ -107,7 +107,9 @@ above for why that's fine pre-deployment. s5GlobalWindow : uint256 (slot 17, issue #193) s5GlobalThreshold : uint256 (slot 18, issue #193) _partialSlashTimes : mapping(address => uint256[]) (slot 19, issue #193) - __gap : uint256[50] (slot 20) + s5EscalationEpoch : mapping(address => uint256) (slot 20, issue #193) + _partialSlashEpoch : mapping(address => mapping(address => uint256)) (slot 21, issue #193) + __gap : uint256[50] (slot 22) ``` `DIN_TOKEN` / `DIN_COORDINATOR` are set once in `initialize` (upper-case names, but diff --git a/dincli/abis/DinValidatorStake.json b/dincli/abis/DinValidatorStake.json index 44d01541..4f1c2bef 100644 --- a/dincli/abis/DinValidatorStake.json +++ b/dincli/abis/DinValidatorStake.json @@ -460,6 +460,25 @@ "outputs": [], "stateMutability": "nonpayable" }, + { + "type": "function", + "name": "s5EscalationEpoch", + "inputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, { "type": "function", "name": "s5GlobalThreshold", diff --git a/foundry/src/DinValidatorStake.sol b/foundry/src/DinValidatorStake.sol index 721aee9d..3685634c 100644 --- a/foundry/src/DinValidatorStake.sol +++ b/foundry/src/DinValidatorStake.sol @@ -199,6 +199,18 @@ contract DinValidatorStake is address indexed slasher ); + /// @notice Number of S5 escalations (either level) applied to each + /// validator. An escalation settles every partial slash recorded + /// before it, in every slasher's ring, not only the caller's. + mapping(address => uint256) public s5EscalationEpoch; + /// @dev The s5EscalationEpoch at which each [validator][slasher] ring in + /// _partialSlashGIs was last written. A ring from an earlier epoch is + /// stale: it is dropped on that slasher's next partial slash and + /// reads as empty through getPartialSlashGIs. Without this, misses + /// that fed a global escalation would stay in the other slashers' + /// rings and count a second time toward per-slasher escalation. + mapping(address => mapping(address => uint256)) private _partialSlashEpoch; + // Reserved for future state variables at this inheritance level. uint256[50] private __gap; @@ -361,8 +373,10 @@ contract DinValidatorStake is } else { emit ValidatorEscalatedS5Global(validator, escalatedAmount, globalCount, msg.sender); } - // Clear this caller's ring and the global ring so the next GI - // starts a fresh window after jail exit. + // Start a new epoch so every slasher's ring counts from zero after + // jail exit (others are dropped lazily, see _partialSlashEpoch), + // and clear this caller's ring and the global ring now. + s5EscalationEpoch[validator]++; delete _partialSlashGIs[validator][msg.sender]; delete _partialSlashTimes[validator]; return escalatedAmount; @@ -679,6 +693,9 @@ contract DinValidatorStake is address validator, address slasherContract ) external view returns (uint256[] memory) { + if (_partialSlashEpoch[validator][slasherContract] != s5EscalationEpoch[validator]) { + return new uint256[](0); + } return _partialSlashGIs[validator][slasherContract]; } @@ -755,6 +772,12 @@ contract DinValidatorStake is /// arbitrarily relative to each other. function _trimAndRecordPartialSlash(address validator, uint256 giIndex) internal { uint256[] storage ring = _partialSlashGIs[validator][msg.sender]; + // Drop a ring left over from before the validator's last S5 escalation. + uint256 epoch = s5EscalationEpoch[validator]; + if (_partialSlashEpoch[validator][msg.sender] != epoch) { + delete _partialSlashGIs[validator][msg.sender]; + _partialSlashEpoch[validator][msg.sender] = epoch; + } ring.push(giIndex); uint256 window = s5RecidivismWindow; diff --git a/foundry/test/SlashingInvariants.t.sol b/foundry/test/SlashingInvariants.t.sol index d1a6cfa5..996e398a 100644 --- a/foundry/test/SlashingInvariants.t.sol +++ b/foundry/test/SlashingInvariants.t.sol @@ -611,6 +611,62 @@ contract SlashingInvariantsTest is StdInvariant, Test { assertEq(stake.getPartialSlashGIs(validator1, slasher3).length, 0, "caller ring cleared"); } + /// @dev The misses that fed a global escalation must not count again in + /// the other slashers' per-slasher rings: one new miss after the jail + /// is a partial slash, not a second MIN_STAKE escalation. + function test_s5Global_escalationSettlesOtherSlashersRings() public { + _setUpSingleValidator(stake.MIN_STAKE() * 20); + address slasher2 = _addSecondSlasher(); + address slasher3 = makeAddr("slasher3"); + vm.prank(admin); + coordinator.addSlasherContract(slasher3); + + uint256 partialAmt = (stake.MIN_STAKE() * 3000) / 10_000; + address[3] memory slashers = [slasher, slasher2, slasher3]; + + // Two misses per slasher; the sixth (slasher3, GI 2) escalates globally. + for (uint256 gi = 1; gi <= 2; gi++) { + for (uint256 k = 0; k < 3; k++) { + vm.prank(slashers[k]); + stake.slashPartial(validator1, partialAmt, "AUD_NO_VOTE", gi); + } + } + assertEq(stake.s5EscalationEpoch(validator1), 1, "one escalation recorded"); + assertEq(stake.getPartialSlashGIs(validator1, slasher).length, 0, "other slasher's ring settled"); + assertEq(stake.getPartialSlashGIs(validator1, slasher2).length, 0, "other slasher's ring settled"); + + // Jail served; one new miss on slasher at GI 3. + vm.warp(block.timestamp + stake.s5JailDuration() + 1); + vm.prank(slasher); + uint256 actual = stake.slashPartial(validator1, partialAmt, "AUD_NO_VOTE", 3); + assertEq(actual, partialAmt, "pre-escalation misses not counted twice"); + assertEq(stake.getPartialSlashGIs(validator1, slasher).length, 1, "ring restarts from the new miss"); + assertEq(stake.s5EscalationEpoch(validator1), 1, "no second escalation"); + } + + /// @dev A per-slasher escalation also clears the global ring, so it settles + /// the other slashers' rings too, for the same reason. + function test_s5_perSlasherEscalation_settlesOtherSlashersRings() public { + _setUpSingleValidator(stake.MIN_STAKE() * 20); + address slasher2 = _addSecondSlasher(); + + uint256 partialAmt = (stake.MIN_STAKE() * 3000) / 10_000; + vm.prank(slasher2); stake.slashPartial(validator1, partialAmt, "AUD_NO_VOTE", 1); + vm.prank(slasher2); stake.slashPartial(validator1, partialAmt, "AUD_NO_VOTE", 2); + // Third miss on slasher escalates through its own ring (global count 5 < 6). + for (uint256 gi = 1; gi <= 3; gi++) { + vm.prank(slasher); + stake.slashPartial(validator1, partialAmt, "AUD_NO_VOTE", gi); + } + assertEq(stake.s5EscalationEpoch(validator1), 1, "per-slasher escalation recorded"); + + vm.warp(block.timestamp + stake.s5JailDuration() + 1); + vm.prank(slasher2); + uint256 actual = stake.slashPartial(validator1, partialAmt, "AUD_NO_VOTE", 3); + assertEq(actual, partialAmt, "slasher2's earlier misses settled by the escalation"); + assertEq(stake.getPartialSlashGIs(validator1, slasher2).length, 1, "ring restarts from the new miss"); + } + /// @dev Same-model split: S1 (auditor contract) and S2 (coordinator) are /// separate slashers, so their misses used to sit in separate rings. function test_s5Global_addsUpTwoSlashersOfOneModel() public { From 594a98a4409ae25b0600f37723f015e4444c7c03 Mon Sep 17 00:00:00 2001 From: umermjd11 Date: Sat, 24 Oct 2026 11:18:32 +0500 Subject: [PATCH 26/48] =?UTF-8?q?feat(task-coordinator):=20richer=20GI=20s?= =?UTF-8?q?tates=20=E2=80=94=20AuditSeedLocked,=20AuditTestDataAssigned,?= =?UTF-8?q?=20AggSeedLocked=20(task=5F061026=5F21=20Parts=20A+B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part B: three progress states, each set by the function that already does the step, inserted at their lifecycle positions (GIended 25 -> 28): - AuditSeedLocked (12), set by lockAuditSeed on a successful lock (never on a re-anchor); createAuditorsBatches requires it, on both contracts. - AuditTestDataAssigned (14), set by setTestDataAssignedFlag; startLMsubmissionsEvaluation requires it. DINTaskAuditor now checks that every audit batch of the GI has a stored test-data commitment, else TA_TestDataNotAssigned. That closes the gap where evaluation could start without test data. Leaving AuditorsBatchesCreated makes the flag one-way, so Is_testdataCIDs_Assigned and TA_FlagAlreadySet are removed. - AggSeedLocked (18), set by lockAggSeed; autoCreateTier1AndTier2 requires it (TC_EvalPhaseNotClosed removed). To stay inside the coordinator budget, releaseGIRegistrationSlots' string requires become TC_GINotEnded / TC_SlotsAlreadyReleased. dincli: states/stateDescription mirrors, the auditor-batches create / create-tier1-tier2 seed paths (skip the lock when it already moved the state) and the lms-evaluation start gate. Part A: line refs in the spec re-checked at eb8085d; DINShared.sol table count and the state-27 text ("Aggregators slashed") fixed. Bundled ABIs regenerated; DINShared.md, DINTaskCoordinator.md, DINTaskAuditor.md updated. Foundry tests updated for the ordinals, with the Part B transition/gate/re-anchor/regression tests in LifecycleEvents.t.sol and a shared assignPlaceholderTestData helper. Sizes (via_ir): DINTaskCoordinator 23,497 -> 23,507 B (1,069 B margin, warn band as before); DINTaskAuditor 21,290 -> 21,297 B. Co-Authored-By: Claude Opus 5.5 --- Developer/tasks/task_061026_21.md | 16 +- .../technical/contracts/DINShared.md | 123 ++++++----- .../technical/contracts/DINTaskAuditor.md | 9 +- .../technical/contracts/DINTaskCoordinator.md | 38 ++-- dincli/abis/DINTaskAuditor.json | 10 +- dincli/abis/DINTaskCoordinator.json | 15 +- dincli/cli/modelownerd/aggregation.py | 18 +- dincli/cli/modelownerd/auditor_batches.py | 18 +- dincli/cli/modelownerd/lms_evaluation.py | 2 +- dincli/cli/utils.py | 10 +- foundry/src/DINShared.sol | 50 +++-- foundry/src/DINTaskAuditor.sol | 20 +- foundry/src/DINTaskCoordinator.sol | 44 ++-- foundry/test/AggregatorCommitReveal.t.sol | 2 + foundry/test/AuditorCommitReveal.t.sol | 2 + foundry/test/DisputeResolution.t.sol | 2 + foundry/test/GasSimulation.t.sol | 2 + foundry/test/LifecycleEvents.t.sol | 207 +++++++++++++++--- foundry/test/PR146SlashingRegression.t.sol | 2 + foundry/test/RewardEngine.t.sol | 3 + foundry/test/ScoringValidation.t.sol | 2 + foundry/test/SecurityFindings.t.sol | 7 + foundry/test/StakingEnforcement.t.sol | 5 +- foundry/test/TreasuryForwarding.t.sol | 2 + foundry/test/utils/PlaceholderTestData.sol | 29 +++ 25 files changed, 451 insertions(+), 187 deletions(-) create mode 100644 foundry/test/utils/PlaceholderTestData.sol diff --git a/Developer/tasks/task_061026_21.md b/Developer/tasks/task_061026_21.md index 572f5498..6829372c 100644 --- a/Developer/tasks/task_061026_21.md +++ b/Developer/tasks/task_061026_21.md @@ -46,7 +46,7 @@ dincli and the full-GI integration suite never caught up: - batches created vs test data assigned - aggregation seed waiting vs locked - Worse, `startLMsubmissionsEvaluation` (`DINTaskCoordinator.sol:532-538`) **doesn't check** that test data was assigned. The auditor-side flag (`DINTaskAuditor.sol:1017-1029`) is one-way, but it's only the owner's own claim; nothing checks that every batch actually has test data. + Worse, `startLMsubmissionsEvaluation` (`DINTaskCoordinator.sol:532-538`) **doesn't check** that test data was assigned. The auditor-side flag (`DINTaskAuditor.sol:1033-1045`) is one-way, but it's only the owner's own claim; nothing checks that every batch actually has test data. - **The public GI docs are stale**: wrong command names, and the funding, key, seed and reveal steps are missing. ### Discussion #216 follow-ups @@ -86,11 +86,13 @@ This is a read-only pass over every owner and role GI function in `DINTaskCoordi |---|---|---| | `modelId` constructor argument on both task contracts | `DINTaskCoordinator.sol:290`, `DINTaskAuditor.sol:406` | Deploy fails at ABI encoding (BL-27) | | `setDinToken` on the auditor (and the coordinator, for dispute bonds) | `DINTaskAuditor.sol:440`, `DINTaskCoordinator.sol:1307` | `depositRewards`, claims and bonds can't move DIN | -| The GI's reward pool funded before each `startGI` | `DINTaskCoordinator.sol:379` → `DINTaskAuditor.depositRewards` (`:481`) or `DinEmission.fundGI` (`DinEmission.sol:174`) | `TC_GIRewardPoolNotFunded` | -| Every batch auditor has an X25519 key on `DinValidatorStake` | `registerEncryptionKey` (`DinValidatorStake.sol:501`), checked at `DINTaskAuditor.sol:1000-1001` | `TA_AuditorEncryptionKeyNotRegistered` when test data is assigned | -| Seed blocks get mined | `disputeSeedDelay` = 7 blocks; dincli polls (`utils.py:998-1080`); anvil `--block-time 2` mines | The seed lock hangs | +| The GI's reward pool funded before each `startGI` | `DINTaskCoordinator.sol:380` → `DINTaskAuditor.depositRewards` (`:481`) or `DinEmission.fundGI` (`DinEmission.sol:212`) | `TC_GIRewardPoolNotFunded` | +| Every batch auditor has an X25519 key on `DinValidatorStake` | `registerEncryptionKey` (`DinValidatorStake.sol:509`), checked at `DINTaskAuditor.sol:1018-1019` | `TA_AuditorEncryptionKeyNotRegistered` when test data is assigned | +| Seed blocks get mined | `disputeSeedDelay` = 7 blocks; dincli polls (`utils.py:1029-1143`); anvil `--block-time 2` mines | The seed lock hangs | | The owner opens each of the three reveal windows | `start*Reveal` | `close` / `finalize` revert | +**Re-checked at `eb8085d` (PR 1's base):** the preconditions hold, and line references in this spec are updated to that commit. The coverage table below is unchanged: every row marked **missing** is still missing. + **dincli coverage at `a1fcce2`:** | GI step | Contract function | dincli command | @@ -135,7 +137,7 @@ Each new state is **set by the function that already does the step**, so a GI ne | New state (lifecycle / enum order) | Inserted | Set by | Gate change | |---|---|---|---| | `AuditSeedLocked` | between `LMSclosed` and `AuditorsBatchesCreated` | `lockAuditSeed` (`DINTaskCoordinator.sol:1483`, anyone). Only on a successful lock, never on a re-anchor | `createAuditorsBatches` requires `AuditSeedLocked` | -| `AuditTestDataAssigned` | between `AuditorsBatchesCreated` and `LMSevaluationStarted` | `setTestDataAssignedFlag` (`:519`) | `startLMsubmissionsEvaluation` requires it. **The auditor side (`DINTaskAuditor.sol:1017-1029`) checks that every batch of the GI has a stored commitment** (`testDataCommitments[gi][b] != 0`, written by `assignAuditTestDataset`), else it reverts with a new `TA_TestDataNotAssigned`. The flag is already one-way (`TA_FlagMustBeTrue`, `TA_FlagAlreadySet`) | +| `AuditTestDataAssigned` | between `AuditorsBatchesCreated` and `LMSevaluationStarted` | `setTestDataAssignedFlag` (`:519`) | `startLMsubmissionsEvaluation` requires it. **The auditor side (`DINTaskAuditor.sol:1033-1045`) checks that every batch of the GI has a stored commitment** (`testDataCommitments[gi][b] != 0`, written by `assignAuditTestDataset`), else it reverts with a new `TA_TestDataNotAssigned`. The flag is already one-way (`TA_FlagMustBeTrue`, `TA_FlagAlreadySet`) | | `AggSeedLocked` | between `LMSevaluationClosed` and `T1nT2Bcreated` | `lockAggSeed` (`:1459`) | `autoCreateTier1AndTier2` requires `AggSeedLocked` | **Not added**, and why: @@ -196,7 +198,7 @@ Each new state is **set by the function that already does the step**, so a GI ne | `dincli rewards claim --gi N` / `dincli rewards withdraw ` | `claimReward(gi)` / `claimRewards()` | All roles. First checks `giRewardSnapshot(gi).settled` and `rewardClaimed` | | `dincli auditor register-encryption-key` | `DinValidatorStake.registerEncryptionKey` | Generates the X25519 key **per wallet** (e.g. `auditor_x25519_

.key`, chmod 600), replacing today's single shared `auditor_x25519.key`, which breaks several `--demokey` auditors on one machine. Updates `_load_auditor_x25519_key` (`auditor.py:33-41`) to match. The owner key (`auditor_batches.py:170-177`) also gets chmod 600 | | `dincli model-owner gi release-slots --gi N` | `releaseGIRegistrationSlots` | BL-28 | -| `dincli auditor dispute-test-data`, `dincli model-owner disputes resolve-test-data / reassign-test-data`, `… disputes close-expired` | `openTestDataDispute` (`DINTaskAuditor.sol:1480`), `resolveTestDataDispute` (`:1524`), `closeExpiredDispute` (`:1557`), `reassignAuditTestDataset` (`:1601`) | **Decision 4: in this task.** Built against the PR #215 shape: only batch auditors open a dispute, only the owner resolves, an unanswered dispute is upheld on expiry, there's no penalty after settlement, and `TA_RewardsAlreadySettled` applies. Bond approval comes first | +| `dincli auditor dispute-test-data`, `dincli model-owner disputes resolve-test-data / reassign-test-data`, `… disputes close-expired` | `openTestDataDispute` (`DINTaskAuditor.sol:1496`), `resolveTestDataDispute` (`:1540`), `closeExpiredDispute` (`:1573`), `reassignAuditTestDataset` (`:1617`) | **Decision 4: in this task.** Built against the PR #215 shape: only batch auditors open a dispute, only the owner resolves, an unanswered dispute is upheld on expiry, there's no penalty after settlement, and `TA_RewardsAlreadySettled` applies. Bond approval comes first | | `dincli aggregator dispute`, `dincli model-owner disputes resolve-aggregation / settle-recomputation`, `… expire`, `… claim-bond` | `openDispute` (`DINTaskCoordinator.sol:1368`), `resolveDispute` (`:1535`), `settleRecomputation` (`:1609`), `expireDispute` (`:1671`), `claimDisputeBond` (`:1583`) | S4 | **Fail fast instead of reverting:** @@ -204,7 +206,7 @@ Each new state is **set by the function that already does the step**, so a GI ne - `create-testdataset --submit` checks every batch auditor's key and names any that are missing. **Reuse:** -- `build_and_send_tx` (`dincli/cli/utils.py:947`) +- `build_and_send_tx` (`dincli/cli/utils.py:978`) - the `ctx.obj.get_deployed_din_task_*_contract(model_id)` lookups (`cli/context.py:339`, `:357`) - a new `approve_din(ctx, spender, amount)` helper, taken from the inline approve in `dintoken.stake_dintokens` (`dincli/cli/dintoken.py:71-128`) diff --git a/Documentation/technical/contracts/DINShared.md b/Documentation/technical/contracts/DINShared.md index 474712c8..5cf00cc2 100644 --- a/Documentation/technical/contracts/DINShared.md +++ b/Documentation/technical/contracts/DINShared.md @@ -38,24 +38,27 @@ Value State Name Description 8 DINauditorsRegistrationStarted Auditor registration window is open. 9 DINauditorsRegistrationClosed Auditor registration window is closed. 10 LMSstarted Local Model Submission window is open. - 11 LMSclosed Local Model Submission window is closed. - 12 AuditorsBatchesCreated Audit batches have been formed. - 13 LMSevaluationStarted Commit phase: auditors submit hidden score/vote commitments via `commitAuditScore`. - 14 LMSevaluationRevealStarted Reveal phase: auditors reveal (score, vote, salt) via `revealAuditScore`; eligibility and median scoring are computed from revealed values only. - 15 LMSevaluationClosed Evaluation finalized; approved models identified. - 16 T1nT2Bcreated Tier-1 and Tier-2 aggregation batches formed. - 17 T1AggregationStarted Commit phase: Tier-1 aggregators submit hidden CID commitments via `commitT1Aggregation`. - 18 T1AggregationRevealStarted Reveal phase: Tier-1 aggregators reveal (cid, salt) via `revealT1Aggregation`; only revealed CIDs count toward finalization. - 19 T1AggregationDone Tier-1 finalized; winning CIDs per batch recorded. - 20 T2AggregationStarted Commit phase: Tier-2 aggregators submit hidden CID commitments via `commitT2Aggregation`. - 21 T2AggregationRevealStarted Reveal phase: Tier-2 aggregators reveal (cid, salt) via `revealT2Aggregation`; only revealed CIDs count toward finalization. - 22 T2AggregationDone Tier-2 finalized; global winning CID recorded. - 23 AuditorsSlashed Auditor slashing phase executed. - 24 AggregatorsSlashed Aggregator slashing phase executed. - 25 GIended GI is complete; system is ready for next GI. + 11 LMSclosed Local Model Submission window is closed; audit seed block anchored. + 12 AuditSeedLocked Audit batch seed locked (`lockAuditSeed`, permissionless). + 13 AuditorsBatchesCreated Audit batches have been formed. + 14 AuditTestDataAssigned Every audit batch has a test-data commitment; `setTestDataAssignedFlag` done. + 15 LMSevaluationStarted Commit phase: auditors submit hidden score/vote commitments via `commitAuditScore`. + 16 LMSevaluationRevealStarted Reveal phase: auditors reveal (score, vote, salt) via `revealAuditScore`; eligibility and median scoring are computed from revealed values only. + 17 LMSevaluationClosed Evaluation finalized; approved models identified; aggregation seed block anchored. + 18 AggSeedLocked T1/T2 batch seed locked (`lockAggSeed`, permissionless). + 19 T1nT2Bcreated Tier-1 and Tier-2 aggregation batches formed. + 20 T1AggregationStarted Commit phase: Tier-1 aggregators submit hidden CID commitments via `commitT1Aggregation`. + 21 T1AggregationRevealStarted Reveal phase: Tier-1 aggregators reveal (cid, salt) via `revealT1Aggregation`; only revealed CIDs count toward finalization. + 22 T1AggregationDone Tier-1 finalized; winning CIDs per batch recorded. + 23 T2AggregationStarted Commit phase: Tier-2 aggregators submit hidden CID commitments via `commitT2Aggregation`. + 24 T2AggregationRevealStarted Reveal phase: Tier-2 aggregators reveal (cid, salt) via `revealT2Aggregation`; only revealed CIDs count toward finalization. + 25 T2AggregationDone Tier-2 finalized; global winning CID recorded. + 26 AuditorsSlashed Auditor slashing phase executed. + 27 AggregatorsSlashed Aggregator slashing phase executed. + 28 GIended GI is complete; system is ready for next GI. ``` -> **Ordinal note:** `LMSevaluationRevealStarted` (commit-then-reveal auditor scoring, task_210726_6 §2a) sits between `LMSevaluationStarted` and `LMSevaluationClosed` at ordinal 14. `T1AggregationRevealStarted` and `T2AggregationRevealStarted` (commit-then-reveal T1/T2 aggregation, issue #156 M-1, task_240926_18 Part C) sit at ordinals 18 and 21 respectively, immediately after their corresponding commit-phase state — same insert-in-lifecycle-position precedent, not appended. Each insertion shifts every later ordinal by +1 relative to the prior numbering. `dincli/cli/utils.py`'s `states`/`stateDescription` positional mirrors (indexed by this same raw ordinal) have been updated to match — see `dincli/cli/utils.py`'s `states`/`stateDescription` lists. +> **Ordinal note:** `LMSevaluationRevealStarted` (commit-then-reveal auditor scoring, task_210726_6 §2a) sits between `LMSevaluationStarted` and `LMSevaluationClosed` at ordinal 16. `T1AggregationRevealStarted` and `T2AggregationRevealStarted` (commit-then-reveal T1/T2 aggregation, issue #156 M-1, task_240926_18 Part C) sit at ordinals 21 and 24 respectively, immediately after their corresponding commit-phase state — same insert-in-lifecycle-position precedent, not appended. Each insertion shifts every later ordinal by +1 relative to the prior numbering. The three progress states `AuditSeedLocked` (12), `AuditTestDataAssigned` (14) and `AggSeedLocked` (18) (task_061026_21 Part B) follow the same precedent; with them `GIended` moved from 25 to 28. Each is set by the function that already does the step, so a GI needs no extra owner transaction. `dincli/cli/utils.py`'s `states`/`stateDescription` positional mirrors (indexed by this same raw ordinal) have been updated to match — see `dincli/cli/utils.py`'s `states`/`stateDescription` lists. ### 2.2 State Transition Diagram @@ -72,41 +75,44 @@ Value State Name Description [3] AwaitingGenesisModel │ setGenesisModelIpfsHash() ▼ -[4] GenesisModelCreated ◄──────────────────────────────── [25] GIended - │ startGI() ▲ - ▼ │ endGI() -[5] GIstarted [24] AggregatorsSlashed - │ startDINaggregatorsRegistration() ▲ - ▼ │ slashAggregators() -[6] DINaggregatorsRegistrationStarted [23] AuditorsSlashed - │ closeDINaggregatorsRegistration() ▲ - ▼ │ slashAuditors() -[7] DINaggregatorsRegistrationClosed [22] T2AggregationDone - │ startDINauditorsRegistration() ▲ - ▼ │ finalizeT2Aggregation() -[8] DINauditorsRegistrationStarted [21] T2AggregationRevealStarted - │ closeDINauditorsRegistration() ▲ - ▼ │ startT2AggregationReveal() -[9] DINauditorsRegistrationClosed [20] T2AggregationStarted - │ startLMsubmissions() ▲ - ▼ │ startT2Aggregation() -[10] LMSstarted [19] T1AggregationDone - │ closeLMsubmissions() ▲ - ▼ │ finalizeT1Aggregation() -[11] LMSclosed [18] T1AggregationRevealStarted - │ createAuditorsBatches() ▲ - ▼ │ startT1AggregationReveal() -[12] AuditorsBatchesCreated [17] T1AggregationStarted - │ startLMsubmissionsEvaluation() ▲ - ▼ │ startT1Aggregation() -[13] LMSevaluationStarted [16] T1nT2Bcreated - │ (auditors: commitAuditScore, commit phase) ▲ - └──────────── startLMsubmissionsEvaluationReveal() ────────┤ autoCreateTier1AndTier2() - (auditors: revealAuditScore, reveal phase) │ - [15] LMSevaluationClosed - ▲ - │ closeLMsubmissionsEvaluation() - [14] LMSevaluationRevealStarted +[4] GenesisModelCreated ◄───────────────────────────[28] GIended + │ startGI() ▲ + ▼ │ endGI() +[5] GIstarted [27] AggregatorsSlashed + │ startDINaggregatorsRegistration() ▲ + ▼ │ slashAggregators() +[6] DINaggregatorsRegistrationStarted [26] AuditorsSlashed + │ closeDINaggregatorsRegistration() ▲ + ▼ │ slashAuditors() +[7] DINaggregatorsRegistrationClosed [25] T2AggregationDone + │ startDINauditorsRegistration() ▲ + ▼ │ finalizeT2Aggregation() +[8] DINauditorsRegistrationStarted [24] T2AggregationRevealStarted + │ closeDINauditorsRegistration() ▲ + ▼ │ startT2AggregationReveal() +[9] DINauditorsRegistrationClosed [23] T2AggregationStarted + │ startLMsubmissions() ▲ + ▼ │ startT2Aggregation() +[10] LMSstarted [22] T1AggregationDone + │ closeLMsubmissions() ▲ + ▼ │ finalizeT1Aggregation() +[11] LMSclosed [21] T1AggregationRevealStarted + │ lockAuditSeed() (anyone) ▲ + ▼ │ startT1AggregationReveal() +[12] AuditSeedLocked [20] T1AggregationStarted + │ createAuditorsBatches() ▲ + ▼ │ startT1Aggregation() +[13] AuditorsBatchesCreated [19] T1nT2Bcreated + │ setTestDataAssignedFlag() ▲ + ▼ │ autoCreateTier1AndTier2() +[14] AuditTestDataAssigned [18] AggSeedLocked + │ startLMsubmissionsEvaluation() ▲ + ▼ │ lockAggSeed() (anyone) +[15] LMSevaluationStarted [17] LMSevaluationClosed + │ (auditors: commitAuditScore) ▲ + │ │ closeLMsubmissionsEvaluation() + └── startLMsubmissionsEvaluationReveal() ─► [16] LMSevaluationRevealStarted + (auditors: revealAuditScore) ``` T1/T2 aggregation submissions follow the same commit-then-reveal shape as LMS evaluation above: `commitT1Aggregation`/`commitT2Aggregation` during the `*AggregationStarted` (commit) state, then the model owner calls `startT1AggregationReveal`/`startT2AggregationReveal` to open `*AggregationRevealStarted`, during which aggregators call `revealT1Aggregation`/`revealT2Aggregation`. Only revealed CIDs are counted by `finalizeT1Aggregation`/`finalizeT2Aggregation`; a committed-but-never-revealed aggregator is excluded from finalization and remains slashable via `slashAggregators`' existing "no submission" (S2) check (issue #156 M-1, task_240926_18 Part C). @@ -192,7 +198,7 @@ Used by: `DINTaskCoordinator` | Method | Purpose | |--------|---------| -| `createAuditorsBatches` | Batch formation, triggered by the coordinator in `LMSclosed`. `seed` is the coordinator's locked `auditSeed[_GI]`; a zero seed reverts `TA_AuditSeedNotLocked` | +| `createAuditorsBatches` | Batch formation, triggered by the coordinator in `AuditSeedLocked`. `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` | @@ -234,12 +240,12 @@ Used by: `DINTaskCoordinator` | `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_CannotCreateAuditorsBatches` | State is not `AuditSeedLocked` | | `TA_BatchDoesNotExist` | Batch ID >= batch array length | | `TA_BatchIDMismatch` | Internal sanity check failure on batch ID | | `TA_CannotSetTestDataAssignedFlag` | State is not `AuditorsBatchesCreated` | | `TA_FlagMustBeTrue` | `setTestDataAssignedFlag` called with `flag = false` | -| `TA_FlagAlreadySet` | Flag was already set for this GI | +| `TA_TestDataNotAssigned` | `setTestDataAssignedFlag`: some audit batch of the GI has no stored test-data commitment (task_061026_21 Part B). A second call is rejected by the state gate (`TA_CannotSetTestDataAssignedFlag` / `TC_CannotSetTestDataAssignedFlag`), so `TA_FlagAlreadySet` is gone | | `TA_NotAssignedAuditor` | Score commit/reveal, or `openTestDataDispute`, from an auditor not assigned to the batch | | `TA_InvalidModelIndex` | Model index not assigned to this batch | | `TA_CannotSetAuditScore` | Declared but unused — left over from the pre-commit-reveal `setAuditScorenEligibility`. | @@ -279,11 +285,12 @@ Used by: `DINTaskCoordinator` | `TC_AuditorsRegistrationCannotBeFinished` | Auditor registration close in wrong state | | `TC_LMSubmissionsCannotBeStarted` | LM submission window start in wrong state | | `TC_LMSubmissionsNotStarted` | LM submission close when not started | -| `TC_LMEvalCannotBeStarted` | Reused by two functions: `createAuditorsBatches` when state is not `LMSclosed`, and `startLMsubmissionsEvaluation` (opens the commit phase) when state is not `AuditorsBatchesCreated` | +| `TC_LMEvalCannotBeStarted` | `startLMsubmissionsEvaluation` (opens the commit phase) when state is not `AuditTestDataAssigned` | | `TC_LMEvalCannotBeFinished` | `closeLMsubmissionsEvaluation` called when state is not `LMSevaluationRevealStarted` | | `TC_FailedToCreateAuditorsBatches` | `createAuditorsBatches` returned false | | `TC_CannotSetTestDataAssignedFlag` | Test data flag set in wrong state | -| `TC_EvalPhaseNotClosed` | T1/T2 batch creation before evaluation close | +| `TC_GINotEnded` | `releaseGIRegistrationSlots` for a GI that hasn't reached `GIended` | +| `TC_SlotsAlreadyReleased` | `releaseGIRegistrationSlots` called twice for the same GI | | `TC_NotEnoughValidators` | Too few aggregators for T1 batches | | `TC_NotEnoughApprovedModels` | Fewer than `T1_MODELS_PER_BATCH` models approved | | `TC_BatchNotFound` | `getTier1Batch` with a batch ID out of bounds | @@ -366,11 +373,11 @@ One set per seed, mirroring the dispute-seed errors in §4.6. See [DINTaskCoordi | `TC_AggSeedNotAnchored` | `lockAggSeed` called before `aggSeedBlock[_GI]` has been set (i.e. before `closeLMsubmissionsEvaluation`) | | `TC_AggSeedBlockNotMined` | `lockAggSeed` called before `block.number > aggSeedBlock[_GI]` | | `TC_AggSeedAlreadyLocked` | `lockAggSeed` called when `aggSeed[_GI]` is already non-zero | -| `TC_AggSeedNotLocked` | `autoCreateTier1AndTier2` called while `aggSeed[_GI] == bytes32(0)` | +| `TC_AggSeedNotLocked` | `autoCreateTier1AndTier2` called when state is not `AggSeedLocked` (the seed isn't locked yet, or the GI is elsewhere) | | `TC_AuditSeedNotAnchored` | `lockAuditSeed` called before `auditSeedBlock[_GI]` has been set (i.e. before `closeLMsubmissions`) | | `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)` | +| `TC_AuditSeedNotLocked` | `createAuditorsBatches` called when state is not `AuditSeedLocked` (the seed isn't locked yet, or the GI is elsewhere) | | `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) diff --git a/Documentation/technical/contracts/DINTaskAuditor.md b/Documentation/technical/contracts/DINTaskAuditor.md index 1b6b5757..e32bbfe5 100644 --- a/Documentation/technical/contracts/DINTaskAuditor.md +++ b/Documentation/technical/contracts/DINTaskAuditor.md @@ -83,7 +83,7 @@ Mappings marked *(internal)* have no auto-getter; read them through the view nam - **Submissions:** `lmSubmissions[gi]` (`LMSubmission`: client, modelCID, submittedAt, eligible, evaluated, approved, finalMedianScore), `clientHasSubmitted`, `clientSubmissionIndex`. - **Batches:** `auditBatches[gi]` *(internal; read with `getAuditorsBatch(gi, batchId)`)* (`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]` *(internal; only guards `setTestDataAssignedFlag` against a second call)*, `testDataDisputes[gi][batchId]`. +- **Test data:** `encryptedTestDataKey[gi][batchId][auditor]`, `testDataCommitments[gi][batchId]`, `testDataDisputes[gi][batchId]`. - **Rewards:** `giRewardPool[gi]`, `giRewardSnapshot[gi]`, `giTotalApprovedScore[gi]`, `giTotalAuditWeight[gi]`, `auditorGIWeight[gi][addr]` *(internal; feeds `claimReward`)*, `rewardClaimed[gi][addr]`, `claimable[addr]`, `treasuryAccrued`. --- @@ -110,14 +110,14 @@ Mappings marked *(internal)* have no auto-getter; read them through the view nam ## 6. Batches & Test Data -- **`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: +- **`createAuditorsBatches(gi, seed)`** (from the coordinator, state `AuditSeedLocked`) — `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. 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. +- **`setTestDataAssignedFlag(gi, true)`** (from the coordinator, state `AuditorsBatchesCreated`) — reverts `TA_TestDataNotAssigned` unless every batch of the GI has a stored `testDataCommitments` entry. On success the coordinator moves to `AuditTestDataAssigned`, which `startLMsubmissionsEvaluation` requires; leaving `AuditorsBatchesCreated` is what makes the call one-shot. --- @@ -204,7 +204,7 @@ Read alongside the [foundry/src security review](../audits/foundry-src-security- - **No. 4 — Unclaimable remainders.** Two distinct cases, both accepted by design (issue No. 250): - *Zero-participant case:* if no model is approved (`giTotalApprovedScore == 0`), or nobody reveals, or no aggregator weight exists, that role's entire pool share stays in the contract with no reclaim path. - *Per-participant rounding case:* `claimReward` floor-divides each role's pool proportionally per participant (`clientPool * finalMedianScore / totalApproved`, and the equivalent for `auditorPool`/`aggregatorPool`). Each participant's share is rounded down independently, so up to `participants − 1` wei per role bucket per GI is left behind, untracked (no "distributed so far" counter exists, and `claimReward` emits no per-GI event — `RewardsClaimed` in `claimRewards` carries no `gi` — so the per-GI total can only be recomputed off-chain from state) and unrecoverable. Distinct from, and not fixed by, [BL-14](../../../Developer/BACK_LOG.md) — BL-14's fix only swept the *top-level* 4-way bps split's remainder to treasury in `settleRewards`; it never touched this second, per-participant layer. Economically negligible at 18 decimals (bounded by floor-division error), but worth naming explicitly: a sweep would need a per-bucket participant-count tracked incrementally (the BL-10 refactor deliberately removed a settlement-time loop that could have supplied one), and [MECHANISM_DESIGN.md](../../../Developer/design/MECHANISM_DESIGN.md)'s "Unclaimed rewards: claimable indefinitely; no expiry for 2.0" rules out a deadline-based sweep anyway — so this is documented as permanent, accepted dust rather than fixed in code. -- **No. 5 — `setTestDataAssignedFlag` gates nothing:** scoring can open before any test data is assigned. +- **No. 5 — Fixed: scoring can't open before test data is assigned.** `setTestDataAssignedFlag` now checks every batch has a commitment and moves the coordinator to `AuditTestDataAssigned`, which `startLMsubmissionsEvaluation` requires (task_061026_21 Part B). - **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`). @@ -227,3 +227,4 @@ Read alongside the [foundry/src security review](../audits/foundry-src-security- - Test-data disputes (issue No. 205): only an auditor of the batch can open one; `resolveTestDataDispute` is owner-only; `closeExpiredDispute` now upholds an unanswered dispute instead of forfeiting the bond; `disputeBondAmount` defaults to 100 DIN. `DisputeExpired` drops its `bondForfeited` field. Disputes can't be opened once the GI is settled (`TA_RewardsAlreadySettled`), and one upheld after settlement takes no pool penalty, so settled claims stay funded (PR No. 215 review). - Size review (issue No. 201 Part A item 4): `auditBatches`, `dinAuditors`, `Is_testdataCIDs_Assigned` and `auditorGIWeight` lose their auto-getters (nothing outside the contract read them; `getAuditorsBatch` / `getDINtaskAuditors` cover the first two). `assignAuditTestDataset` and `reassignAuditTestDataset` share `_storeTestData` and `_batchAt`, and `_burnAndForward` forwards its half through `_forwardToTreasury`. Behaviour and events are unchanged; runtime size drops from 22,718 B to 21,188 B. - Dual-role guard (issue No. 180): `registerDINAuditor` reverts with `TA_DualRoleNotAllowed` when the caller is already an aggregator for the same GI. The check is on the auditor side because aggregators always register first; it is per address, so a second address with its own stake can still take the auditor role. `IDINTaskCoordinator` gains `isDINAggregator`. +- Every-batch test-data check (task_061026_21 Part B): `setTestDataAssignedFlag` reverts `TA_TestDataNotAssigned` while any batch has no commitment; `createAuditorsBatches` now requires the coordinator's new `AuditSeedLocked` state. `Is_testdataCIDs_Assigned` and `TA_FlagAlreadySet` are removed, since the coordinator state now rejects a second call. diff --git a/Documentation/technical/contracts/DINTaskCoordinator.md b/Documentation/technical/contracts/DINTaskCoordinator.md index 1b05b7ca..495203e5 100644 --- a/Documentation/technical/contracts/DINTaskCoordinator.md +++ b/Documentation/technical/contracts/DINTaskCoordinator.md @@ -127,24 +127,24 @@ The numbers are `GIstates` ordinals (see [DINShared §2.1](DINShared.md)). | Step | Function (owner unless noted) | From → To state | |------|-------------------------------|-----------------| -| Start | `startGI(gi)` / `startGI(gi, passScore)` | `GenesisModelCreated` (4) or `GIended` (25) → `GIstarted` (5) | +| Start | `startGI(gi)` / `startGI(gi, passScore)` | `GenesisModelCreated` (4) or `GIended` (28) → `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 | +| Audit seed | *anyone:* `lockAuditSeed` (a re-anchor leaves the state unchanged) | → 12 | +| Audit batches | `createAuditorsBatches` (delegates to the auditor, passing the seed) | → 13 | +| Test data | `DINTaskAuditor.assignAuditTestDataset` per batch; then `setTestDataAssignedFlag` (the auditor checks every batch has a commitment) | → 14 | +| Score commit | `startLMsubmissionsEvaluation` → *auditors commit on the auditor contract* | → 15 | +| Score reveal | `startLMsubmissionsEvaluationReveal` → *auditors reveal* | → 16 | +| Finalize evaluation | `closeLMsubmissionsEvaluation` (calls `finalizeEvaluation`; anchors the aggregation seed) | → 17 | +| Aggregation seed | *anyone:* `lockAggSeed` (a re-anchor leaves the state unchanged) | → 18 | +| Batches | `autoCreateTier1AndTier2` | → 19 | +| T1 commit | `startT1Aggregation` → *aggregators:* `commitT1Aggregation` | → 20 | +| T1 reveal | `startT1AggregationReveal` → *aggregators:* `revealT1Aggregation` → `finalizeT1Aggregation` | → 21 → 22 | +| T2 commit | `startT2Aggregation` → *aggregators:* `commitT2Aggregation` | → 23 | +| T2 reveal | `startT2AggregationReveal` → *aggregators:* `revealT2Aggregation` → `finalizeT2Aggregation` | → 24 → 25 | +| Slash | `slashAuditors` → `slashAggregators` | → 26 → 27 | +| End | `endGI` (settles rewards) | → 28 | | Housekeeping | `releaseGIRegistrationSlots(gi)` | no state change | 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)`. @@ -166,7 +166,8 @@ Both batch-creation steps shuffle with a seed that must be locked first (issue # | 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) | +| State on lock | `LMSclosed` → `AuditSeedLocked` | `LMSevaluationClosed` → `AggSeedLocked` | +| Consumer | `createAuditorsBatches`, gated on `AuditSeedLocked` (`TC_AuditSeedNotLocked` otherwise; the auditor contract also rejects a zero seed with `TA_AuditSeedNotLocked`) | `autoCreateTier1AndTier2`, gated on `AggSeedLocked` (`TC_AggSeedNotLocked` otherwise) | | Events | `AuditSeedLocked`, `AuditSeedReanchored` | `AggSeedLocked`, `AggSeedReanchored` | 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. @@ -177,7 +178,7 @@ A lock call reverts if the seed isn't anchored yet (`TC_*SeedNotAnchored`), is a - **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. ### 6.4 `autoCreateTier1AndTier2` -Requires `LMSevaluationClosed` and a locked `aggSeed[gi]`. +Requires `AggSeedLocked`, i.e. 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). @@ -277,7 +278,7 @@ Earlier findings from the [foundry/src security review](../audits/foundry-src-se - **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:** 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. 8 — Leftovers:** `networkFeeFloor` is stored but not enforced. - **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` used to name its working directory, worker job and container after the last T1 batch id; fixed in issue No. 202 Part 2.) - **No. 11 — Fixed: `registerDINaggregator` now checks the GI.** It used to have no `onlyCurrentGI`, so during GI N's window a validator could register for GI N+1. Up to 300 addresses could fill that list, which locked out honest registrants and handed the attacker every T1/T2 batch. Registering for a released past GI also leaked the caller's own slot. Fixed by adding `onlyCurrentGI` (issue #206); it cost 9 bytes inside the No. 1 budget. @@ -297,6 +298,7 @@ Earlier findings from the [foundry/src security review](../audits/foundry-src-se - 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. +- Progress states (task_061026_21 Part B): `lockAuditSeed` → `AuditSeedLocked`, `setTestDataAssignedFlag` → `AuditTestDataAssigned`, `lockAggSeed` → `AggSeedLocked`. `createAuditorsBatches`, `startLMsubmissionsEvaluation` and `autoCreateTier1AndTier2` are gated on them, so evaluation can no longer start before every batch has test data. `GIended` moved from 25 to 28; `TC_EvalPhaseNotClosed` is gone. To fit the coordinator's size budget, `releaseGIRegistrationSlots`' two string `require`s became `TC_GINotEnded` / `TC_SlotsAlreadyReleased` (−88 B). - 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. - Back under EIP-170 (issue #201 Part A). The `slashAggregators` T1/T2 loops are folded into `_slashBatch`, with unchanged slashes and events. `tier1Batches` / `tier2Batches` and the ten `t1*`/`t2*` per-aggregator maps are now `internal`, and the new `getAggregatorSubmission` view replaces their getters. This is a view-ABI change; dincli and the bundled ABI were updated with it. diff --git a/dincli/abis/DINTaskAuditor.json b/dincli/abis/DINTaskAuditor.json index 2a6bb6c9..92ce5452 100644 --- a/dincli/abis/DINTaskAuditor.json +++ b/dincli/abis/DINTaskAuditor.json @@ -2483,11 +2483,6 @@ "name": "TA_EncryptedKeyCountMismatch", "inputs": [] }, - { - "type": "error", - "name": "TA_FlagAlreadySet", - "inputs": [] - }, { "type": "error", "name": "TA_FlagMustBeTrue", @@ -2623,6 +2618,11 @@ "name": "TA_StakeBelowModelFloor", "inputs": [] }, + { + "type": "error", + "name": "TA_TestDataNotAssigned", + "inputs": [] + }, { "type": "error", "name": "TA_WrongGI", diff --git a/dincli/abis/DINTaskCoordinator.json b/dincli/abis/DINTaskCoordinator.json index 20af9c1e..66c9ee7b 100644 --- a/dincli/abis/DINTaskCoordinator.json +++ b/dincli/abis/DINTaskCoordinator.json @@ -2412,11 +2412,6 @@ "name": "TC_DisputeWindowClosed", "inputs": [] }, - { - "type": "error", - "name": "TC_EvalPhaseNotClosed", - "inputs": [] - }, { "type": "error", "name": "TC_FailedToCreateAuditorsBatches", @@ -2437,6 +2432,11 @@ "name": "TC_GICannotBeStarted", "inputs": [] }, + { + "type": "error", + "name": "TC_GINotEnded", + "inputs": [] + }, { "type": "error", "name": "TC_GIRewardPoolNotFunded", @@ -2577,6 +2577,11 @@ "name": "TC_RevealCannotBeStarted", "inputs": [] }, + { + "type": "error", + "name": "TC_SlotsAlreadyReleased", + "inputs": [] + }, { "type": "error", "name": "TC_StakeBelowModelFloor", diff --git a/dincli/cli/modelownerd/aggregation.py b/dincli/cli/modelownerd/aggregation.py index 5ffc0c95..bd42c87c 100644 --- a/dincli/cli/modelownerd/aggregation.py +++ b/dincli/cli/modelownerd/aggregation.py @@ -2,7 +2,7 @@ import typer from rich.table import Table -from dincli.cli.utils import (build_and_send_tx, ensure_batch_seed_locked, +from dincli.cli.utils import (GIstateToStr, build_and_send_tx, ensure_batch_seed_locked, get_manifest_key, require_custom_manifest_service, resolve_manifest_path) from dincli.services.cid_utils import get_cid_from_bytes32 @@ -27,16 +27,18 @@ def create_tier1_tier2_batches( curr_GI, GIstate = ctx.obj.get_current_gi_and_state(task_coordinator_Contract) ref_gi = ctx.obj.validate_gi_ET_curr_GI(gi, curr_GI) - ctx.obj.validate_GIstate_ET_given_GIstate(GIstate, "LMSevaluationClosed","Can not create Tier 1 & Tier 2 batches at this time.") - # issue #156 H-2: autoCreateTier1AndTier2 now requires an ungrindable, # future-block-anchored seed to already be locked. Lock it here (or # skip if someone else already has) before attempting the real call. - ensure_batch_seed_locked( - ctx, task_coordinator_Contract, ref_gi, - seed_getter="aggSeed", seed_block_getter="aggSeedBlock", - lock_fn="lockAggSeed", label="T1/T2 batch", - ) + # A successful lock moves the GI to AggSeedLocked (task_061026_21 + # Part B), so only LMSevaluationClosed still needs it. + if GIstateToStr(GIstate) != "AggSeedLocked": + ctx.obj.validate_GIstate_ET_given_GIstate(GIstate, "LMSevaluationClosed","Can not create Tier 1 & Tier 2 batches at this time.") + ensure_batch_seed_locked( + ctx, task_coordinator_Contract, ref_gi, + seed_getter="aggSeed", seed_block_getter="aggSeedBlock", + lock_fn="lockAggSeed", label="T1/T2 batch", + ) console.print(f"[bold green]Creating Tier 1 & Tier 2 batches[/bold green]") diff --git a/dincli/cli/modelownerd/auditor_batches.py b/dincli/cli/modelownerd/auditor_batches.py index 7babe820..d2ddc6cf 100644 --- a/dincli/cli/modelownerd/auditor_batches.py +++ b/dincli/cli/modelownerd/auditor_batches.py @@ -10,7 +10,7 @@ from eth_account.messages import encode_defunct from cryptography.hazmat.primitives.ciphers.aead import AESGCM -from dincli.cli.utils import CONFIG_DIR, build_and_send_tx, ensure_batch_seed_locked, get_manifest_key, require_custom_manifest_service, resolve_manifest_path +from dincli.cli.utils import CONFIG_DIR, GIstateToStr, build_and_send_tx, ensure_batch_seed_locked, get_manifest_key, require_custom_manifest_service, resolve_manifest_path from dincli.services.cid_utils import get_bytes32_from_cid, get_cid_from_bytes32 auditor_batches_app = typer.Typer(help="Auditor Batches commands") @@ -29,16 +29,18 @@ def create( ref_gi = ctx.obj.validate_gi_ET_curr_GI(gi, curr_GI) - ctx.obj.validate_GIstate_ET_given_GIstate(GIstate, "LMSclosed", "Can not create auditor batches at this time") - # issue #156 H-2: createAuditorsBatches now requires an ungrindable, # future-block-anchored seed to already be locked. Lock it here (or # skip if someone else already has) before attempting the real call. - ensure_batch_seed_locked( - ctx, taskCoordinator_contract, ref_gi, - seed_getter="auditSeed", seed_block_getter="auditSeedBlock", - lock_fn="lockAuditSeed", label="auditor-batch", - ) + # A successful lock moves the GI to AuditSeedLocked (task_061026_21 + # Part B), so only LMSclosed still needs it. + if GIstateToStr(GIstate) != "AuditSeedLocked": + ctx.obj.validate_GIstate_ET_given_GIstate(GIstate, "LMSclosed", "Can not create auditor batches at this time") + ensure_batch_seed_locked( + ctx, taskCoordinator_contract, ref_gi, + seed_getter="auditSeed", seed_block_getter="auditSeedBlock", + lock_fn="lockAuditSeed", label="auditor-batch", + ) console.print(f"[bold green]Creating auditor batches [/bold green]") diff --git a/dincli/cli/modelownerd/lms_evaluation.py b/dincli/cli/modelownerd/lms_evaluation.py index 7f285753..8c2c0a47 100644 --- a/dincli/cli/modelownerd/lms_evaluation.py +++ b/dincli/cli/modelownerd/lms_evaluation.py @@ -21,7 +21,7 @@ def start( curr_GI, GIstate = ctx.obj.get_current_gi_and_state(task_coordinator_Contract) ref_gi = ctx.obj.validate_gi_ET_curr_GI(gi, curr_GI) - ctx.obj.validate_GIstate_ET_given_GIstate(GIstate, "AuditorsBatchesCreated", "Can not start LMS evaluation at this time") + ctx.obj.validate_GIstate_ET_given_GIstate(GIstate, "AuditTestDataAssigned", "Can not start LMS evaluation at this time (run `auditor-batches create-testdataset --submit` first)") console.print(f"[bold green]Starting LMS evaluation[/bold green]") diff --git a/dincli/cli/utils.py b/dincli/cli/utils.py index 8f9b51ea..717ea0b2 100644 --- a/dincli/cli/utils.py +++ b/dincli/cli/utils.py @@ -681,10 +681,13 @@ def save_din_info(data: dict): "DIN auditors registration closed", "LM submissions started", "LM submissions closed", + "Audit seed locked", "Auditors batches created", + "Audit test data assigned", "LM submissions evaluation started", "LM submissions evaluation reveal started", "LM submissions evaluation closed", + "Aggregation seed locked", "T1nT2B created", "T1B aggregation started", "T1B aggregation reveal started", @@ -693,7 +696,7 @@ def save_din_info(data: dict): "T2B aggregation reveal started", "T2B aggregation done", "Auditors slashed", - "Validators slashed", + "Aggregators slashed", "GI ended", ] @@ -710,10 +713,15 @@ def save_din_info(data: dict): "DINauditorsRegistrationClosed", "LMSstarted", "LMSclosed", + # task_061026_21 Part B: progress states, inserted in lifecycle + # position (same precedent as the reveal states below). + "AuditSeedLocked", "AuditorsBatchesCreated", + "AuditTestDataAssigned", "LMSevaluationStarted", "LMSevaluationRevealStarted", "LMSevaluationClosed", + "AggSeedLocked", "T1nT2Bcreated", "T1AggregationStarted", # issue #156 M-1 (task_240926_18 Part C): inserted in lifecycle diff --git a/foundry/src/DINShared.sol b/foundry/src/DINShared.sol index a27f1e81..0d3b3610 100644 --- a/foundry/src/DINShared.sol +++ b/foundry/src/DINShared.sol @@ -20,8 +20,17 @@ enum GIstates { DINauditorsRegistrationClosed, // 9 LMSstarted, // 10 LMSclosed, // 11 - AuditorsBatchesCreated, // 12 - LMSevaluationStarted, // 13 + // Progress states (task_061026_21 Part B). Each is set by the function + // that already does the step -- lockAuditSeed, setTestDataAssignedFlag, + // lockAggSeed -- so a GI needs no extra owner transaction. Inserted at + // their lifecycle positions (same 2026-08-27 precedent as the reveal + // states below), shifting every later ordinal; dincli/cli/utils.py's + // `states`/`stateDescription` mirrors and + // Documentation/technical/contracts/DINShared.md §2.1 match. + AuditSeedLocked, // 12 + AuditorsBatchesCreated, // 13 + AuditTestDataAssigned, // 14 + LMSevaluationStarted, // 15 // Reveal phase for commit-then-reveal auditor scoring (task_210726_6 §2a). // Inserted here, immediately after the commit phase, rather than // appended at the end as PR #63's own diff originally did (see git @@ -31,11 +40,12 @@ enum GIstates { // (indexed by this enum's raw ordinals) have been updated to match, // shifting every subsequent entry by +1; see that file and // Documentation/technical/contracts/DINShared.md §2.1 for the full - // 24-member table. - LMSevaluationRevealStarted, // 14 - LMSevaluationClosed, // 15 - T1nT2Bcreated, // 16 - T1AggregationStarted, // 17 + // 29-member table. + LMSevaluationRevealStarted, // 16 + LMSevaluationClosed, // 17 + AggSeedLocked, // 18 + T1nT2Bcreated, // 19 + T1AggregationStarted, // 20 // Reveal phase for commit-then-reveal T1/T2 aggregation submissions // (issue #156 M-1, task_240926_18 Part C). Same 2026-08-27 precedent as // LMSevaluationRevealStarted above: inserted immediately after each @@ -43,14 +53,14 @@ enum GIstates { // `stateDescription` mirrors and Documentation/technical/contracts/ // DINShared.md §2.1 have been updated to match, shifting every // subsequent entry by +1 (T1) then +1 again (T2). - T1AggregationRevealStarted, // 18 - T1AggregationDone, // 19 - T2AggregationStarted, // 20 - T2AggregationRevealStarted, // 21 - T2AggregationDone, // 22 - AuditorsSlashed, // 23 - AggregatorsSlashed, // 24 - GIended // 25 + T1AggregationRevealStarted, // 21 + T1AggregationDone, // 22 + T2AggregationStarted, // 23 + T2AggregationRevealStarted, // 24 + T2AggregationDone, // 25 + AuditorsSlashed, // 26 + AggregatorsSlashed, // 27 + GIended // 28 } // ───────────────────────────────────────────────────────────────────────────── @@ -213,8 +223,8 @@ error TA_BatchIDMismatch(); error TA_CannotSetTestDataAssignedFlag(); /// @dev The flag value supplied must be true; false is not accepted here. error TA_FlagMustBeTrue(); -/// @dev The test data assigned flag for this batch has already been set. -error TA_FlagAlreadySet(); +/// @dev setTestDataAssignedFlag: some audit batch of the GI has no stored test-data commitment yet. +error TA_TestDataNotAssigned(); /// @dev Caller is not assigned to the target auditor batch. error TA_NotAssignedAuditor(); /// @dev The supplied model index is outside the range of submitted models. @@ -301,8 +311,10 @@ error TC_LMEvalCannotBeFinished(); error TC_FailedToCreateAuditorsBatches(); /// @dev GI state does not permit setting the test data assigned flag. error TC_CannotSetTestDataAssignedFlag(); -/// @dev LMS evaluation phase has not been closed yet. -error TC_EvalPhaseNotClosed(); +/// @dev releaseGIRegistrationSlots: the GI has not ended yet. +error TC_GINotEnded(); +/// @dev releaseGIRegistrationSlots: this GI's slots were already released. +error TC_SlotsAlreadyReleased(); /// @dev Fewer active validators registered than required to form T1 batches. error TC_NotEnoughValidators(); /// @dev Fewer models passed evaluation than the minimum required for T2 aggregation. diff --git a/foundry/src/DINTaskAuditor.sol b/foundry/src/DINTaskAuditor.sol index 342222a0..ea11dfb7 100644 --- a/foundry/src/DINTaskAuditor.sol +++ b/foundry/src/DINTaskAuditor.sol @@ -259,8 +259,6 @@ contract DINTaskAuditor is Ownable, ReentrancyGuardTransient { mapping(uint256 => mapping(uint => mapping(address => mapping(uint => bool)))) // GI // batchId // auditor // modelIndex // has committed public hasCommittedLM; - mapping(uint256 => bool) internal Is_testdataCIDs_Assigned; - // §2b: per-validator encrypted test-data key mapping. GI => batchId => // auditor => that auditor's copy of the test-data decryption key, // encrypted to their public key off-chain. Populated in @@ -872,7 +870,7 @@ contract DINTaskAuditor is Ownable, ReentrancyGuardTransient { uint _GI, bytes32 seed ) external onlyTaskCoordinator onlyCurrentGI(_GI) returns (bool) { - if (dintaskcoordinatorContract.GIstate() != GIstates.LMSclosed) + if (dintaskcoordinatorContract.GIstate() != GIstates.AuditSeedLocked) revert TA_CannotCreateAuditorsBatches(); if (seed == bytes32(0)) revert TA_AuditSeedNotLocked(); @@ -1025,8 +1023,12 @@ contract DINTaskAuditor is Ownable, ReentrancyGuardTransient { } /// @notice Marks test dataset distribution as complete for the given GI. - /// @dev Restricted to the paired DINTaskCoordinator. flag must be true; - /// can only be set once per GI. + /// @dev Restricted to the paired DINTaskCoordinator. flag must be true, + /// and every audit batch of the GI must have a stored test-data + /// commitment (written by assignAuditTestDataset), else + /// TA_TestDataNotAssigned. One-way: the coordinator then moves to + /// AuditTestDataAssigned, so the AuditorsBatchesCreated gate below + /// rejects a second call (task_061026_21 Part B). /// @param _GI Current GI index. /// @param flag Must be true. /// @return True on success. @@ -1039,9 +1041,11 @@ contract DINTaskAuditor is Ownable, ReentrancyGuardTransient { GIstates.AuditorsBatchesCreated ) revert TA_CannotSetTestDataAssignedFlag(); if (!flag) revert TA_FlagMustBeTrue(); - if (Is_testdataCIDs_Assigned[_GI]) revert TA_FlagAlreadySet(); - - Is_testdataCIDs_Assigned[_GI] = flag; + uint256 n = auditBatches[_GI].length; + for (uint256 b = 0; b < n; b++) { + if (testDataCommitments[_GI][b] == bytes32(0)) + revert TA_TestDataNotAssigned(); + } return true; } diff --git a/foundry/src/DINTaskCoordinator.sol b/foundry/src/DINTaskCoordinator.sol index d3675535..4a0297a7 100644 --- a/foundry/src/DINTaskCoordinator.sol +++ b/foundry/src/DINTaskCoordinator.sol @@ -494,26 +494,29 @@ contract DINTaskCoordinator is Ownable, ReentrancyGuardTransient { /// @notice Delegates auditor batch creation to DINTaskAuditor and advances GI state. /// @dev Reverts if the auditor contract returns false (e.g. insufficient auditors). - /// Requires auditSeed[_GI] to already be locked via lockAuditSeed - /// (issue #156 H-2) -- the locked seed is passed across the interface - /// so DINTaskAuditor's shuffles are ungrindable without duplicating - /// the seed-anchoring logic in that contract. + /// Requires the AuditSeedLocked state, i.e. auditSeed[_GI] already + /// locked via lockAuditSeed (issue #156 H-2) -- the locked seed is + /// passed across the interface so DINTaskAuditor's shuffles are + /// ungrindable without duplicating the seed-anchoring logic in that + /// contract. /// @param _GI Current GI index. function createAuditorsBatches( uint _GI ) public onlyOwner onlyCurrentGI(_GI) { - if (GIstate != GIstates.LMSclosed) revert TC_LMEvalCannotBeStarted(); - bytes32 seed = auditSeed[_GI]; - if (seed == bytes32(0)) revert TC_AuditSeedNotLocked(); + if (GIstate != GIstates.AuditSeedLocked) revert TC_AuditSeedNotLocked(); - bool success = dinTaskAuditorContract.createAuditorsBatches(_GI, seed); + bool success = dinTaskAuditorContract.createAuditorsBatches(_GI, auditSeed[_GI]); if (!success) revert TC_FailedToCreateAuditorsBatches(); _setGIstate(GIstates.AuditorsBatchesCreated); } - /// @notice Propagates the test data assignment flag to DINTaskAuditor. - /// @dev Must be called while GI state is AuditorsBatchesCreated. + /// @notice Propagates the test data assignment flag to DINTaskAuditor and + /// advances to AuditTestDataAssigned. + /// @dev Must be called while GI state is AuditorsBatchesCreated. The + /// auditor side reverts TA_TestDataNotAssigned unless every batch of + /// the GI has a stored test-data commitment. Leaving + /// AuditorsBatchesCreated is what makes the flag one-way. /// @param _GI Current GI index. /// @param flag True once test datasets have been distributed to auditors. function setTestDataAssignedFlag( @@ -524,15 +527,18 @@ contract DINTaskCoordinator is Ownable, ReentrancyGuardTransient { revert TC_CannotSetTestDataAssignedFlag(); dinTaskAuditorContract.setTestDataAssignedFlag(_GI, flag); + _setGIstate(GIstates.AuditTestDataAssigned); } /// @notice Opens the LMS evaluation COMMIT phase so auditors can begin /// committing (hidden) scores via DINTaskAuditor.commitAuditScore. + /// @dev Requires AuditTestDataAssigned, so evaluation can't start before + /// every batch has test data (task_061026_21 Part B). /// @param _GI Current GI index. function startLMsubmissionsEvaluation( uint _GI ) public onlyOwner onlyCurrentGI(_GI) { - if (GIstate != GIstates.AuditorsBatchesCreated) + if (GIstate != GIstates.AuditTestDataAssigned) revert TC_LMEvalCannotBeStarted(); _setGIstate(GIstates.LMSevaluationStarted); } @@ -577,15 +583,13 @@ contract DINTaskCoordinator is Ownable, ReentrancyGuardTransient { /// choosing when to call this. Reverts if fewer than /// T1_AGGREGATORS_PER_BATCH active validators remain, or if fewer than /// T1_MODELS_PER_BATCH models passed, or if the seed isn't locked yet - /// (call lockAggSeed first). + /// (call lockAggSeed first, which moves the GI to AggSeedLocked). /// @param _GI Current GI index. function autoCreateTier1AndTier2( uint _GI ) external onlyOwner onlyCurrentGI(_GI) { - if (GIstate != GIstates.LMSevaluationClosed) - revert TC_EvalPhaseNotClosed(); + if (GIstate != GIstates.AggSeedLocked) revert TC_AggSeedNotLocked(); bytes32 seed = aggSeed[_GI]; - if (seed == bytes32(0)) revert TC_AggSeedNotLocked(); // Filter the historical registration list down to currently active validators. address[] memory valPool = _activeAggregatorPool(_GI); @@ -1287,8 +1291,8 @@ contract DINTaskCoordinator is Ownable, ReentrancyGuardTransient { function releaseGIRegistrationSlots(uint _GI) external onlyOwner { bool giEnded = (_GI < GI) || (_GI == GI && GIstate == GIstates.GIended); - require(giEnded, "GI has not ended"); - require(!registrationSlotsReleased[_GI], "slots already released"); + if (!giEnded) revert TC_GINotEnded(); + if (registrationSlotsReleased[_GI]) revert TC_SlotsAlreadyReleased(); registrationSlotsReleased[_GI] = true; address[] storage aggs = dinAggregators[_GI]; @@ -1471,6 +1475,9 @@ contract DINTaskCoordinator is Ownable, ReentrancyGuardTransient { aggSeed[_GI] = keccak256(abi.encodePacked(bh, _GI, "AGG")); emit AggSeedLocked(_GI, aggSeed[_GI]); + // aggSeedBlock is only anchored by closeLMsubmissionsEvaluation and + // the seed locks once, so the GI is in LMSevaluationClosed here. + _setGIstate(GIstates.AggSeedLocked); } /// @notice Permissionless: locks the entropy seed for the current GI's @@ -1495,6 +1502,9 @@ contract DINTaskCoordinator is Ownable, ReentrancyGuardTransient { auditSeed[_GI] = keccak256(abi.encodePacked(bh, _GI, "AUD")); emit AuditSeedLocked(_GI, auditSeed[_GI]); + // auditSeedBlock is only anchored by closeLMsubmissions and the seed + // locks once, so the GI is in LMSclosed here. + _setGIstate(GIstates.AuditSeedLocked); } /// @dev Burns 50% of `amount` and forwards 50% to the platform slash-treasury diff --git a/foundry/test/AggregatorCommitReveal.t.sol b/foundry/test/AggregatorCommitReveal.t.sol index dff98776..892954de 100644 --- a/foundry/test/AggregatorCommitReveal.t.sol +++ b/foundry/test/AggregatorCommitReveal.t.sol @@ -38,6 +38,7 @@ import { TC_T2RevealPhaseNotOpen } from "../src/DINShared.sol"; import {auditCommitHash} from "./utils/AuditCommitHash.sol"; +import {assignPlaceholderTestData} from "./utils/PlaceholderTestData.sol"; contract AggregatorCommitRevealTest is Test { DinToken tokenImpl; @@ -215,6 +216,7 @@ contract AggregatorCommitRevealTest is Test { tc.closeLMsubmissions(1); _lockAuditSeedNow(1); // issue #156 H-2 tc.createAuditorsBatches(1); + assignPlaceholderTestData(ta, address(stake), 1); tc.setTestDataAssignedFlag(1, true); tc.startLMsubmissionsEvaluation(1); vm.stopPrank(); diff --git a/foundry/test/AuditorCommitReveal.t.sol b/foundry/test/AuditorCommitReveal.t.sol index d1963ce1..b682ae30 100644 --- a/foundry/test/AuditorCommitReveal.t.sol +++ b/foundry/test/AuditorCommitReveal.t.sol @@ -19,6 +19,7 @@ import {DINTaskCoordinator} from "../src/DINTaskCoordinator.sol"; import {DINTaskAuditor} from "../src/DINTaskAuditor.sol"; import {GIstates, TA_RevealHashMismatch} from "../src/DINShared.sol"; import {auditCommitHash} from "./utils/AuditCommitHash.sol"; +import {assignPlaceholderTestData} from "./utils/PlaceholderTestData.sol"; contract AuditorCommitRevealTest is Test { DinToken tokenImpl; @@ -189,6 +190,7 @@ contract AuditorCommitRevealTest is Test { vm.roll(block.number + tc.disputeSeedDelay() + 1); // issue #156 H-2: seed lock tc.lockAuditSeed(1); tc.createAuditorsBatches(1); + assignPlaceholderTestData(ta, address(stake), 1); tc.setTestDataAssignedFlag(1, true); tc.startLMsubmissionsEvaluation(1); vm.stopPrank(); diff --git a/foundry/test/DisputeResolution.t.sol b/foundry/test/DisputeResolution.t.sol index 7af9d894..08904e54 100644 --- a/foundry/test/DisputeResolution.t.sol +++ b/foundry/test/DisputeResolution.t.sol @@ -21,6 +21,7 @@ import {DINTaskCoordinator} from "../src/DINTaskCoordinator.sol"; import {DINTaskAuditor} from "../src/DINTaskAuditor.sol"; import "../src/DINShared.sol" as Shared; import {auditCommitHash} from "./utils/AuditCommitHash.sol"; +import {assignPlaceholderTestData} from "./utils/PlaceholderTestData.sol"; // Pull the custom errors into scope so vm.expectRevert(Error.selector) compiles. error TC_DisputeNotAwaitingRecomputation(); @@ -238,6 +239,7 @@ contract DisputeResolutionTest is Test { tc.closeLMsubmissions(1); _lockAuditSeedNow(1); // issue #156 H-2 tc.createAuditorsBatches(1); + assignPlaceholderTestData(ta, address(stake), 1); tc.setTestDataAssignedFlag(1, true); tc.startLMsubmissionsEvaluation(1); vm.stopPrank(); diff --git a/foundry/test/GasSimulation.t.sol b/foundry/test/GasSimulation.t.sol index 32b1c8c1..d7d9dc26 100644 --- a/foundry/test/GasSimulation.t.sol +++ b/foundry/test/GasSimulation.t.sol @@ -12,6 +12,7 @@ import {DinTreasury} from "../src/DinTreasury.sol"; import {DINTaskCoordinator} from "../src/DINTaskCoordinator.sol"; import {DINTaskAuditor} from "../src/DINTaskAuditor.sol"; import {auditCommitHash} from "./utils/AuditCommitHash.sol"; +import {assignPlaceholderTestData} from "./utils/PlaceholderTestData.sol"; /// @notice Gas simulation for issue #78 — validator network-fee sizing. /// @@ -154,6 +155,7 @@ contract GasSimulationTest is Test { vm.roll(block.number + tc.disputeSeedDelay() + 1); // issue #156 H-2: seed lock tc.lockAuditSeed(gi); tc.createAuditorsBatches(gi); + assignPlaceholderTestData(ta, address(stake), gi); tc.setTestDataAssignedFlag(gi, true); tc.startLMsubmissionsEvaluation(gi); } diff --git a/foundry/test/LifecycleEvents.t.sol b/foundry/test/LifecycleEvents.t.sol index 75f443f7..1d72cc5c 100644 --- a/foundry/test/LifecycleEvents.t.sol +++ b/foundry/test/LifecycleEvents.t.sol @@ -11,6 +11,8 @@ pragma solidity ^0.8.28; // - T2AggregationSubmitted emitted by revealT2Aggregation (same split) // - T1BatchFinalized emitted per batch in finalizeT1Aggregation // - T2Finalized emitted in finalizeT2Aggregation +// - task_061026_21 Part B progress states: AuditSeedLocked, +// AuditTestDataAssigned, AggSeedLocked, and the gates that need them // Run: forge test --match-contract LifecycleEventsTest -vv // ───────────────────────────────────────────────────────────────────────────── @@ -22,8 +24,16 @@ import {DinCoordinator} from "../src/DinCoordinator.sol"; import {DinValidatorStake} from "../src/DinValidatorStake.sol"; import {DINTaskCoordinator} from "../src/DINTaskCoordinator.sol"; import {DINTaskAuditor} from "../src/DINTaskAuditor.sol"; -import {GIstates} from "../src/DINShared.sol"; +import { + GIstates, + TA_TestDataNotAssigned, + TC_AggSeedNotLocked, + TC_AuditSeedNotLocked, + TC_CannotSetTestDataAssignedFlag, + TC_LMEvalCannotBeStarted +} from "../src/DINShared.sol"; import {auditCommitHash} from "./utils/AuditCommitHash.sol"; +import {assignPlaceholderTestData} from "./utils/PlaceholderTestData.sol"; contract LifecycleEventsTest is Test { DinToken token; @@ -66,20 +76,23 @@ contract LifecycleEventsTest is Test { uint8 constant GI_AUD_REG_CLOSED = 9; uint8 constant GI_LMS_STARTED = 10; uint8 constant GI_LMS_CLOSED = 11; - uint8 constant GI_AUDITOR_BATCHES_CREATED = 12; - uint8 constant GI_LMS_EVAL_STARTED = 13; - uint8 constant GI_LMS_EVAL_REVEAL_STARTED = 14; - uint8 constant GI_LMS_EVAL_CLOSED = 15; - uint8 constant GI_T1T2_CREATED = 16; - uint8 constant GI_T1_AGG_STARTED = 17; - uint8 constant GI_T1_AGG_REVEAL_STARTED = 18; - uint8 constant GI_T1_AGG_DONE = 19; - uint8 constant GI_T2_AGG_STARTED = 20; - uint8 constant GI_T2_AGG_REVEAL_STARTED = 21; - uint8 constant GI_T2_AGG_DONE = 22; - uint8 constant GI_AUDITORS_SLASHED = 23; - uint8 constant GI_AGGREGATORS_SLASHED = 24; - uint8 constant GI_ENDED = 25; + uint8 constant GI_AUDIT_SEED_LOCKED = 12; + uint8 constant GI_AUDITOR_BATCHES_CREATED = 13; + uint8 constant GI_TEST_DATA_ASSIGNED = 14; + uint8 constant GI_LMS_EVAL_STARTED = 15; + uint8 constant GI_LMS_EVAL_REVEAL_STARTED = 16; + uint8 constant GI_LMS_EVAL_CLOSED = 17; + uint8 constant GI_AGG_SEED_LOCKED = 18; + uint8 constant GI_T1T2_CREATED = 19; + uint8 constant GI_T1_AGG_STARTED = 20; + uint8 constant GI_T1_AGG_REVEAL_STARTED = 21; + uint8 constant GI_T1_AGG_DONE = 22; + uint8 constant GI_T2_AGG_STARTED = 23; + uint8 constant GI_T2_AGG_REVEAL_STARTED = 24; + uint8 constant GI_T2_AGG_DONE = 25; + uint8 constant GI_AUDITORS_SLASHED = 26; + uint8 constant GI_AGGREGATORS_SLASHED = 27; + uint8 constant GI_ENDED = 28; function setUp() public { vm.startPrank(admin); @@ -208,6 +221,19 @@ contract LifecycleEventsTest is Test { /// T1AggregationStarted, committing/revealing in every auditor batch. /// 6 approved models + 6 aggregators yield two Tier-1 batches. function _advanceToT1(uint256 giIndex, uint256 nClients) internal { + _advanceToEvalClosed(giIndex, nClients); + vm.roll(block.number + tc.disputeSeedDelay() + 1); // issue #156 H-2: seed lock + tc.lockAggSeed(giIndex); + + vm.startPrank(modelOwner); + tc.autoCreateTier1AndTier2(giIndex); + tc.startT1Aggregation(giIndex); + vm.stopPrank(); + } + + /// @dev Submits `nClients` (3 or 6) local models and closes LMS, which + /// anchors the audit seed. + function _submitAndCloseLMS(uint256 giIndex, uint256 nClients) internal { address[6] memory clients = [client1, client2, client3, client4, client5, client6]; for (uint i = 0; i < nClients; i++) { vm.prank(clients[i]); ta.submitLocalModel(bytes32((i + 1) * 100), giIndex); @@ -216,11 +242,18 @@ contract LifecycleEventsTest is Test { vm.startPrank(modelOwner); tc.closeLMsubmissions(giIndex); vm.stopPrank(); + } + + /// @dev Same as _advanceToT1 up to LMSevaluationClosed (aggregation seed + /// anchored, not locked). + function _advanceToEvalClosed(uint256 giIndex, uint256 nClients) internal { + _submitAndCloseLMS(giIndex, nClients); vm.roll(block.number + tc.disputeSeedDelay() + 1); // issue #156 H-2: seed lock tc.lockAuditSeed(giIndex); vm.startPrank(modelOwner); tc.createAuditorsBatches(giIndex); + assignPlaceholderTestData(ta, address(stake), giIndex); tc.setTestDataAssignedFlag(giIndex, true); tc.startLMsubmissionsEvaluation(giIndex); vm.stopPrank(); @@ -252,13 +285,6 @@ contract LifecycleEventsTest is Test { vm.startPrank(modelOwner); tc.closeLMsubmissionsEvaluation(giIndex); vm.stopPrank(); - vm.roll(block.number + tc.disputeSeedDelay() + 1); // issue #156 H-2: seed lock - tc.lockAggSeed(giIndex); - - vm.startPrank(modelOwner); - tc.autoCreateTier1AndTier2(giIndex); - tc.startT1Aggregation(giIndex); - vm.stopPrank(); } /// @dev issue #156 M-1: commits `who` to `cid` for T1/T2 batch `batchId`. @@ -559,9 +585,11 @@ contract LifecycleEventsTest is Test { emit DINTaskCoordinator.GIStateChanged(1, GI_LMS_CLOSED); vm.prank(modelOwner); tc.closeLMsubmissions(1); - // issue #156 H-2: seed lock (emits its own event, so this must land - // before the expectEmit below rather than between it and the call) + // AuditSeedLocked (task_061026_21 Part B): the permissionless seed + // lock (issue #156 H-2) now also advances the state vm.roll(block.number + tc.disputeSeedDelay() + 1); + vm.expectEmit(true, true, false, false, address(tc)); + emit DINTaskCoordinator.GIStateChanged(1, GI_AUDIT_SEED_LOCKED); tc.lockAuditSeed(1); // AuditorsBatchesCreated @@ -569,7 +597,13 @@ contract LifecycleEventsTest is Test { emit DINTaskCoordinator.GIStateChanged(1, GI_AUDITOR_BATCHES_CREATED); vm.prank(modelOwner); tc.createAuditorsBatches(1); - vm.prank(modelOwner); tc.setTestDataAssignedFlag(1, true); + // AuditTestDataAssigned (task_061026_21 Part B) + vm.startPrank(modelOwner); + assignPlaceholderTestData(ta, address(stake), 1); + vm.expectEmit(true, true, false, false, address(tc)); + emit DINTaskCoordinator.GIStateChanged(1, GI_TEST_DATA_ASSIGNED); + tc.setTestDataAssignedFlag(1, true); + vm.stopPrank(); // LMSevaluationStarted vm.expectEmit(true, true, false, false, address(tc)); @@ -603,9 +637,10 @@ contract LifecycleEventsTest is Test { emit DINTaskCoordinator.GIStateChanged(1, GI_LMS_EVAL_CLOSED); vm.prank(modelOwner); tc.closeLMsubmissionsEvaluation(1); - // issue #156 H-2: seed lock (emits its own event, so this must land - // before the expectEmit below rather than between it and the call) + // AggSeedLocked (task_061026_21 Part B) vm.roll(block.number + tc.disputeSeedDelay() + 1); + vm.expectEmit(true, true, false, false, address(tc)); + emit DINTaskCoordinator.GIStateChanged(1, GI_AGG_SEED_LOCKED); tc.lockAggSeed(1); // T1nT2Bcreated @@ -684,4 +719,122 @@ contract LifecycleEventsTest is Test { emit DINTaskCoordinator.GIStateChanged(1, GI_ENDED); vm.prank(modelOwner); tc.endGI(1); } + + // ── task_061026_21 Part B: progress states ─────────────────────────────── + + function test_progressStates_createAuditorsBatchesRevertsBeforeAuditSeedLock() public { + _setupToLMS(1, 1_000 ether); + _submitAndCloseLMS(1, 3); + + vm.expectRevert(TC_AuditSeedNotLocked.selector); + vm.prank(modelOwner); + tc.createAuditorsBatches(1); + assertEq(uint8(tc.GIstate()), GI_LMS_CLOSED); + } + + function test_progressStates_auditSeedReanchorLeavesStateUnchanged() public { + _setupToLMS(1, 1_000 ether); + _submitAndCloseLMS(1, 3); + + vm.roll(uint256(tc.auditSeedBlock(1)) + 300); // > 256 blocks: re-anchors + tc.lockAuditSeed(1); + assertEq(tc.auditSeed(1), bytes32(0)); + assertEq(uint8(tc.GIstate()), GI_LMS_CLOSED); + + vm.roll(uint256(tc.auditSeedBlock(1)) + 1); + tc.lockAuditSeed(1); + assertTrue(tc.auditSeed(1) != bytes32(0)); + assertEq(uint8(tc.GIstate()), GI_AUDIT_SEED_LOCKED); + } + + /// @dev Regression for the gap task_061026_21 closes: evaluation used to + /// open straight from AuditorsBatchesCreated, with no test data. + function test_progressStates_evaluationCannotStartBeforeTestDataAssigned() public { + _setupToLMS(1, 1_000 ether); + _submitAndCloseLMS(1, 3); + vm.roll(block.number + tc.disputeSeedDelay() + 1); + tc.lockAuditSeed(1); + + vm.startPrank(modelOwner); + tc.createAuditorsBatches(1); + vm.expectRevert(TC_LMEvalCannotBeStarted.selector); + tc.startLMsubmissionsEvaluation(1); + vm.stopPrank(); + assertEq(uint8(tc.GIstate()), GI_AUDITOR_BATCHES_CREATED); + } + + function test_progressStates_oneUnassignedBatchBlocksFlagAndEvaluation() public { + _setupToLMS(1, 1_000 ether, 6); + _submitAndCloseLMS(1, 6); + vm.roll(block.number + tc.disputeSeedDelay() + 1); + tc.lockAuditSeed(1); + vm.prank(modelOwner); + tc.createAuditorsBatches(1); + assertEq(ta.AuditorsBatchCount(1), 2); + + vm.mockCall( + address(stake), abi.encodeWithSelector(stake.getEncryptionKey.selector), abi.encode(hex"01") + ); + (, address[] memory auds0, , ) = ta.getAuditorsBatch(1, 0); + (, address[] memory auds1, , ) = ta.getAuditorsBatch(1, 1); + + vm.startPrank(modelOwner); + ta.assignAuditTestDataset(1, 0, hex"01", new bytes[](auds0.length), keccak256("batch0")); + + vm.expectRevert(TA_TestDataNotAssigned.selector); + tc.setTestDataAssignedFlag(1, true); + vm.expectRevert(TC_LMEvalCannotBeStarted.selector); + tc.startLMsubmissionsEvaluation(1); + assertEq(uint8(tc.GIstate()), GI_AUDITOR_BATCHES_CREATED); + + ta.assignAuditTestDataset(1, 1, hex"01", new bytes[](auds1.length), keccak256("batch1")); + tc.setTestDataAssignedFlag(1, true); + assertEq(uint8(tc.GIstate()), GI_TEST_DATA_ASSIGNED); + + // One-way: leaving AuditorsBatchesCreated rejects a second call. + vm.expectRevert(TC_CannotSetTestDataAssignedFlag.selector); + tc.setTestDataAssignedFlag(1, true); + + tc.startLMsubmissionsEvaluation(1); + vm.stopPrank(); + assertEq(uint8(tc.GIstate()), GI_LMS_EVAL_STARTED); + } + + function test_progressStates_autoCreateRevertsBeforeAggSeedLock() public { + _setupToLMS(1, 1_000 ether); + _advanceToEvalClosed(1, 3); + + vm.expectRevert(TC_AggSeedNotLocked.selector); + vm.prank(modelOwner); + tc.autoCreateTier1AndTier2(1); + assertEq(uint8(tc.GIstate()), GI_LMS_EVAL_CLOSED); + } + + function test_progressStates_aggSeedReanchorLeavesStateUnchanged() public { + _setupToLMS(1, 1_000 ether); + _advanceToEvalClosed(1, 3); + + vm.roll(uint256(tc.aggSeedBlock(1)) + 300); + tc.lockAggSeed(1); + assertEq(tc.aggSeed(1), bytes32(0)); + assertEq(uint8(tc.GIstate()), GI_LMS_EVAL_CLOSED); + + vm.roll(uint256(tc.aggSeedBlock(1)) + 1); + tc.lockAggSeed(1); + assertTrue(tc.aggSeed(1) != bytes32(0)); + assertEq(uint8(tc.GIstate()), GI_AGG_SEED_LOCKED); + + vm.prank(modelOwner); + tc.autoCreateTier1AndTier2(1); + assertEq(uint8(tc.GIstate()), GI_T1T2_CREATED); + } + + /// @dev The dincli `states` mirror and DINShared.md §2.1 are indexed by + /// these raw ordinals; pin the three inserts and the shifted end. + function test_progressStates_ordinals() public pure { + assertEq(uint8(GIstates.AuditSeedLocked), GI_AUDIT_SEED_LOCKED); + assertEq(uint8(GIstates.AuditTestDataAssigned), GI_TEST_DATA_ASSIGNED); + assertEq(uint8(GIstates.AggSeedLocked), GI_AGG_SEED_LOCKED); + assertEq(uint8(GIstates.GIended), GI_ENDED); + } } diff --git a/foundry/test/PR146SlashingRegression.t.sol b/foundry/test/PR146SlashingRegression.t.sol index dfb2c587..bfcafe17 100644 --- a/foundry/test/PR146SlashingRegression.t.sol +++ b/foundry/test/PR146SlashingRegression.t.sol @@ -22,6 +22,7 @@ import {DinFeeRouter} from "../src/DinFeeRouter.sol"; import {DINTaskCoordinator} from "../src/DINTaskCoordinator.sol"; import {DINTaskAuditor} from "../src/DINTaskAuditor.sol"; import {auditCommitHash} from "./utils/AuditCommitHash.sol"; +import {assignPlaceholderTestData} from "./utils/PlaceholderTestData.sol"; contract PR146SlashingRegressionTest is Test { // ───────────────────────────────────────────────────────────────────── @@ -189,6 +190,7 @@ contract PR146SlashingRegressionTest is Test { vm.roll(block.number + tc.disputeSeedDelay() + 1); // issue #156 H-2: seed lock tc.lockAuditSeed(1); tc.createAuditorsBatches(1); + assignPlaceholderTestData(ta, address(stake), 1); tc.setTestDataAssignedFlag(1, true); tc.startLMsubmissionsEvaluation(1); vm.stopPrank(); diff --git a/foundry/test/RewardEngine.t.sol b/foundry/test/RewardEngine.t.sol index 572b98f9..6efc2f5d 100644 --- a/foundry/test/RewardEngine.t.sol +++ b/foundry/test/RewardEngine.t.sol @@ -20,6 +20,7 @@ import {DINTaskCoordinator} from "../src/DINTaskCoordinator.sol"; import {DINTaskAuditor} from "../src/DINTaskAuditor.sol"; import {GIstates} from "../src/DINShared.sol"; import {auditCommitHash} from "./utils/AuditCommitHash.sol"; +import {assignPlaceholderTestData} from "./utils/PlaceholderTestData.sol"; contract RewardEngineTest is Test { using stdStorage for StdStorage; @@ -212,6 +213,7 @@ contract RewardEngineTest is Test { vm.roll(block.number + tc.disputeSeedDelay() + 1); // issue #156 H-2: seed lock tc.lockAuditSeed(1); tc.createAuditorsBatches(1); + assignPlaceholderTestData(ta, address(stake), 1); tc.setTestDataAssignedFlag(1, true); tc.startLMsubmissionsEvaluation(1); vm.stopPrank(); @@ -951,6 +953,7 @@ contract RewardEngineTest is Test { vm.roll(block.number + tc.disputeSeedDelay() + 1); // issue #156 H-2: seed lock tc.lockAuditSeed(1); tc.createAuditorsBatches(1); + assignPlaceholderTestData(ta, address(stake), 1); tc.setTestDataAssignedFlag(1, true); tc.startLMsubmissionsEvaluation(1); vm.stopPrank(); diff --git a/foundry/test/ScoringValidation.t.sol b/foundry/test/ScoringValidation.t.sol index 1b4444d9..4d089011 100644 --- a/foundry/test/ScoringValidation.t.sol +++ b/foundry/test/ScoringValidation.t.sol @@ -19,6 +19,7 @@ import {DINTaskCoordinator} from "../src/DINTaskCoordinator.sol"; import {DINTaskAuditor} from "../src/DINTaskAuditor.sol"; import {GIstates} from "../src/DINShared.sol"; import {auditCommitHash} from "./utils/AuditCommitHash.sol"; +import {assignPlaceholderTestData} from "./utils/PlaceholderTestData.sol"; contract ScoringValidationTest is Test { DinToken tokenImpl; @@ -185,6 +186,7 @@ contract ScoringValidationTest is Test { vm.roll(block.number + tc.disputeSeedDelay() + 1); // issue #156 H-2: seed lock tc.lockAuditSeed(1); tc.createAuditorsBatches(1); + assignPlaceholderTestData(ta, address(stake), 1); tc.setTestDataAssignedFlag(1, true); tc.startLMsubmissionsEvaluation(1); vm.stopPrank(); diff --git a/foundry/test/SecurityFindings.t.sol b/foundry/test/SecurityFindings.t.sol index a36626f1..655c7f43 100644 --- a/foundry/test/SecurityFindings.t.sol +++ b/foundry/test/SecurityFindings.t.sol @@ -35,6 +35,7 @@ import { TA_AuditSeedNotLocked } from "../src/DINShared.sol"; import {auditCommitHash} from "./utils/AuditCommitHash.sol"; +import {assignPlaceholderTestData} from "./utils/PlaceholderTestData.sol"; contract SecurityFindingsTest is Test { // ───────────────────────────────────────────────────────────────────── @@ -297,6 +298,7 @@ contract SecurityFindingsTest is Test { vm.roll(block.number + tc.disputeSeedDelay() + 1); // issue #156 H-2: seed lock tc.lockAuditSeed(1); tc.createAuditorsBatches(1); + assignPlaceholderTestData(ta, address(stake), 1); tc.setTestDataAssignedFlag(1, true); tc.startLMsubmissionsEvaluation(1); vm.stopPrank(); @@ -434,6 +436,7 @@ contract SecurityFindingsTest is Test { vm.roll(block.number + tc.disputeSeedDelay() + 1); // issue #156 H-2: seed lock tc.lockAuditSeed(1); tc.createAuditorsBatches(1); + assignPlaceholderTestData(ta, address(stake), 1); tc.setTestDataAssignedFlag(1, true); tc.startLMsubmissionsEvaluation(1); vm.stopPrank(); @@ -906,6 +909,7 @@ contract SecurityFindingsTest is Test { vm.prank(modelOwner); tc.createAuditorsBatches(1); vm.startPrank(modelOwner); + assignPlaceholderTestData(ta, address(stake), 1); tc.setTestDataAssignedFlag(1, true); tc.startLMsubmissionsEvaluation(1); vm.stopPrank(); @@ -960,6 +964,7 @@ contract SecurityFindingsTest is Test { vm.prank(modelOwner); tc.createAuditorsBatches(1); vm.startPrank(modelOwner); + assignPlaceholderTestData(ta, address(stake), 1); tc.setTestDataAssignedFlag(1, true); tc.startLMsubmissionsEvaluation(1); vm.stopPrank(); @@ -1005,6 +1010,7 @@ contract SecurityFindingsTest is Test { vm.prank(modelOwner); tc.createAuditorsBatches(1); vm.startPrank(modelOwner); + assignPlaceholderTestData(ta, address(stake), 1); tc.setTestDataAssignedFlag(1, true); tc.startLMsubmissionsEvaluation(1); vm.stopPrank(); @@ -1042,6 +1048,7 @@ contract SecurityFindingsTest is Test { vm.prank(modelOwner); tc.createAuditorsBatches(1); vm.startPrank(modelOwner); + assignPlaceholderTestData(ta, address(stake), 1); tc.setTestDataAssignedFlag(1, true); tc.startLMsubmissionsEvaluation(1); vm.stopPrank(); diff --git a/foundry/test/StakingEnforcement.t.sol b/foundry/test/StakingEnforcement.t.sol index 38cf069d..f0d1cdbc 100644 --- a/foundry/test/StakingEnforcement.t.sol +++ b/foundry/test/StakingEnforcement.t.sol @@ -22,11 +22,13 @@ import { TC_StakeBelowModelFloor, TC_ConcurrentRegistrationCapReached, TC_WrongGI, + TC_SlotsAlreadyReleased, TA_StakeBelowModelFloor, TA_ConcurrentRegistrationCapReached, TA_DualRoleNotAllowed } from "../src/DINShared.sol"; import {auditCommitHash} from "./utils/AuditCommitHash.sol"; +import {assignPlaceholderTestData} from "./utils/PlaceholderTestData.sol"; contract StakingEnforcementTest is Test { DinToken token; @@ -394,6 +396,7 @@ contract StakingEnforcementTest is Test { vm.roll(block.number + tc.disputeSeedDelay() + 1); // issue #156 H-2: seed lock tc.lockAuditSeed(1); tc.createAuditorsBatches(1); + assignPlaceholderTestData(ta, address(stake), 1); tc.setTestDataAssignedFlag(1, true); tc.startLMsubmissionsEvaluation(1); vm.stopPrank(); @@ -482,7 +485,7 @@ contract StakingEnforcementTest is Test { vm.startPrank(modelOwner); tc.releaseGIRegistrationSlots(1); - vm.expectRevert(bytes("slots already released")); + vm.expectRevert(TC_SlotsAlreadyReleased.selector); tc.releaseGIRegistrationSlots(1); vm.stopPrank(); } diff --git a/foundry/test/TreasuryForwarding.t.sol b/foundry/test/TreasuryForwarding.t.sol index 1674341e..783175d0 100644 --- a/foundry/test/TreasuryForwarding.t.sol +++ b/foundry/test/TreasuryForwarding.t.sol @@ -22,6 +22,7 @@ import {DINTaskCoordinator} from "../src/DINTaskCoordinator.sol"; import {DINTaskAuditor} from "../src/DINTaskAuditor.sol"; import {GIstates} from "../src/DINShared.sol"; import {auditCommitHash} from "./utils/AuditCommitHash.sol"; +import {assignPlaceholderTestData} from "./utils/PlaceholderTestData.sol"; contract TreasuryForwardingTest is Test { DinToken token; @@ -173,6 +174,7 @@ contract TreasuryForwardingTest is Test { vm.roll(block.number + tc.disputeSeedDelay() + 1); // issue #156 H-2: seed lock tc.lockAuditSeed(1); tc.createAuditorsBatches(1); + assignPlaceholderTestData(ta, address(stake), 1); tc.setTestDataAssignedFlag(1, true); tc.startLMsubmissionsEvaluation(1); vm.stopPrank(); diff --git a/foundry/test/utils/PlaceholderTestData.sol b/foundry/test/utils/PlaceholderTestData.sol new file mode 100644 index 00000000..e4d43123 --- /dev/null +++ b/foundry/test/utils/PlaceholderTestData.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {Vm} from "forge-std/Vm.sol"; +import {DINTaskAuditor} from "../../src/DINTaskAuditor.sol"; +import {IDinValidatorStake} from "../../src/DINShared.sol"; + +Vm constant VM = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + +/// @dev Assigns placeholder test data to every audit batch of `gi`, so +/// setTestDataAssignedFlag's every-batch commitment check +/// (task_061026_21 Part B) passes in tests that don't exercise the +/// encrypted test-data path (EncryptedTestData.t.sol does). Auditor +/// X25519 keys are mocked on `stake` for the duration of the call, not +/// registered. Every call is made as the current caller, so run it as +/// the model owner (under vm.startPrank, or as the owning test contract). +function assignPlaceholderTestData(DINTaskAuditor ta, address stake, uint256 gi) { + VM.mockCall( + stake, + abi.encodeWithSelector(IDinValidatorStake.getEncryptionKey.selector), + abi.encode(hex"01") + ); + uint256 n = ta.AuditorsBatchCount(gi); + for (uint256 b = 0; b < n; b++) { + (, address[] memory auditors, , ) = ta.getAuditorsBatch(gi, b); + ta.assignAuditTestDataset(gi, b, hex"01", new bytes[](auditors.length), keccak256(abi.encode(gi, b))); + } + VM.clearMockedCalls(); +} From ade45a6759b02659a950fe6d5994ccd4a9092f5d Mon Sep 17 00:00:00 2001 From: robertocarlous Date: Sat, 24 Oct 2026 15:36:10 +0100 Subject: [PATCH 27/48] fix(docs): correct squatting-window and re-dispute-fix wording (review notes) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Clarify that the squat holds from the moment of opening, not only once the owner rejects — "when the owner rejects" implied the window only opens post-resolution - Drop "per GI" from the resolveDispute one-liner ("per batch, ever") - Correct the multi-dispute fix hint in slashing-taxonomy §S4: the block is the challenger check, not the resolved flag, so clearing/replacing challenger is the fix — not replacing resolved with a counter --- Developer/design/slashing-taxonomy.md | 2 +- foundry/src/DINTaskCoordinator.sol | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Developer/design/slashing-taxonomy.md b/Developer/design/slashing-taxonomy.md index cddd51cb..ddcc6ca5 100644 --- a/Developer/design/slashing-taxonomy.md +++ b/Developer/design/slashing-taxonomy.md @@ -37,7 +37,7 @@ Also confirmed: both existing slash call sites (`DINTaskAuditor.slashAuditors`, - **Hook point:** built this round. `DINTaskCoordinator`'s new dispute-resolution scaffold (Part 4c) — a validator opens a dispute against a specific finalized T1 or T2 batch within a dispute window, posting a bond; if upheld (by owner/DAO adjudication for now — no on-chain recomputation of model weights is feasible in this contract, see note below), the batch is marked disputed and a fresh aggregator subgroup (excluding the accused and the original batch's aggregators) is assigned to redo it. Bond forfeiture/bounty payout is stubbed behind a treasury-shaped interface, matching Part 3's `treasuryAccrued` pattern — no real fund movement, since `DinTreasury` doesn't exist on `develop` yet. - **Why "off-chain challenge + on-chain dispute" rather than on-chain recomputation:** the CID being disputed points to aggregated model weights on IPFS; verifying "does this CID actually equal the correct aggregation of these inputs" requires re-running the aggregation function over potentially large tensors, which is not something a Solidity contract can or should do. The chain's job is bond custody, window enforcement, and re-assignment bookkeeping — the actual dispute adjudication (was the CID wrong?) happens off-chain today (by the owner, in this scaffold) and is a natural target for a future on-chain fraud-proof or committee-vote mechanism. Flagged, not solved, here. - **Gap:** adjudication is `onlyOwner` in the scaffold, i.e. centralized. That's explicit and intentional for this stretch scope — MECHANISM_DESIGN's dispute section doesn't specify the adjudicator, and building a fair on-chain jury mechanism is well beyond a stretch-scope scaffold. -- **One-shot constraint:** exactly one dispute per batch is possible, ever. `openDispute` checks `challenger != address(0)` and reverts `TC_DisputeAlreadyOpen`; the challenger field is never cleared, so a resolved dispute also blocks reopening with the same error. Documented because the consequence goes beyond developer surprise: a colluder can open a weak dispute on their own batch early, accept the forfeited bond, and leave the batch immune to a better-evidenced challenge for the rest of the window. If multi-dispute-per-batch is ever needed, the fix is to replace the boolean `resolved` flag with a counter or re-openable state enum. Revisit as part of [issue #181](https://github.com/InfiniteZeroFoundation/DevNet/issues/181) (decentralised adjudication). +- **One-shot constraint:** exactly one dispute per batch is possible, ever. `openDispute` checks `challenger != address(0)` and reverts `TC_DisputeAlreadyOpen`; the challenger field is never cleared, so a resolved dispute also blocks reopening with the same error. Documented because the consequence goes beyond developer surprise: a colluder can open a weak dispute on their own batch early, accept the forfeited bond, and leave the batch immune to a better-evidenced challenge for the rest of the window. If multi-dispute-per-batch is ever needed, the fix is to clear or replace the `challenger` field on resolution — the block is the `challenger != address(0)` check, not the `resolved` flag. Revisit as part of [issue #181](https://github.com/InfiniteZeroFoundation/DevNet/issues/181) (decentralised adjudication). ## S5 — Repeated liveness failures (recidivism) diff --git a/foundry/src/DINTaskCoordinator.sol b/foundry/src/DINTaskCoordinator.sol index 64ef34b6..709cd542 100644 --- a/foundry/src/DINTaskCoordinator.sol +++ b/foundry/src/DINTaskCoordinator.sol @@ -1392,10 +1392,10 @@ contract DINTaskCoordinator is Ownable, ReentrancyGuardTransient { } // One dispute per batch, ever. The challenger field is set on open and // never cleared, so this check also blocks reopening after resolution. - // Consequence: a colluder can squat a batch early, accept bond forfeiture - // when the owner rejects the dispute, and leave the batch immune to a - // better-evidenced challenge for the rest of the window. Revisit if - // multi-dispute-per-batch is needed — see issue #181 (decentralised adjudication). + // Consequence: a colluder can squat a batch from the moment they open a + // weak dispute — the batch becomes immune to a better-evidenced challenge + // for the rest of the window regardless of how the owner resolves it. + // Revisit if multi-dispute-per-batch is needed — see issue #181 (decentralised adjudication). if (disputes[_GI][tierKind][batchId].challenger != address(0)) { revert TC_DisputeAlreadyOpen(); } @@ -1538,7 +1538,7 @@ contract DINTaskCoordinator is Ownable, ReentrancyGuardTransient { /// @param tierKind Whether the batch is a Tier-1 or Tier-2 batch. /// @param batchId Index of the disputed batch within its tier. /// @param upheld True if the dispute is found valid. - // One dispute per batch per GI: once resolved (upheld or rejected) the batch cannot be re-disputed. + // One dispute per batch, ever: once resolved (upheld or rejected) the batch cannot be re-disputed. function resolveDispute( uint _GI, TierKind tierKind, From 562766692bc45e94f8677123b38cf41229f51e1b Mon Sep 17 00:00:00 2001 From: robertocarlous Date: Fri, 10 Jul 2026 23:47:20 +0100 Subject: [PATCH 28/48] docs(indexer): add platform-contract event coverage audit (P4-IDX1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audits all events emitted by DINModelRegistry, DinValidatorStake, DinCoordinator, and DinToken against indexer reconstruction requirements. Findings: - ModelRegistrationRequested: missing isOpenSource and fee — cannot filter pending requests by type or surface fee amounts without a storage call - ManifestUpdateRequested: missing requester address — cannot attribute pending updates to their submitter - DinCoordinator.withdraw(): no event emitted — ETH treasury outflows are invisible to the indexer - SlasherContractAdded/Removed: identical signatures on DinCoordinator and DinValidatorStake — resolved at schema level via sourceContract field, no contract change needed - ValidatorJailed path is dead code (jailedUntil is never set); flagged for the P3-4.x slashing spec to ensure a jailing event lands when that mechanism is added Proposed additions (confirmed with Umer, 2026-07-09 — land as one PR after audit review): 1. ModelRegistrationRequested: + bool isOpenSource, uint256 fee 2. ManifestUpdateRequested: + address indexed requester 3. DinCoordinator: new ETHTreasuryWithdrawn(address indexed to, uint256 amount) Includes daemon event schema table (P4-7.1 feed) mapping platform events to dind subscription requirements and coverage gaps. --- subgraph/docs/event-coverage-audit.md | 239 ++++++++++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 subgraph/docs/event-coverage-audit.md diff --git a/subgraph/docs/event-coverage-audit.md b/subgraph/docs/event-coverage-audit.md new file mode 100644 index 00000000..ce42c4ea --- /dev/null +++ b/subgraph/docs/event-coverage-audit.md @@ -0,0 +1,239 @@ +# Event Coverage Audit — DIN Platform Contracts + +**Scope:** The four platform contracts indexed in P4-IDX1: +`DINModelRegistry`, `DinValidatorStake`, `DinCoordinator`, `DinToken`. + +**Principle:** Every meaningful state transition must be reconstructable +from events alone. Where reconstruction requires a storage call, a minimal +event addition is preferable to a call handler — call handlers are not +supported on all Graph node backends and degrade indexing performance. + +**Outcome:** Two confirmed event additions (coordinated with Umer, 2026-07-09) +to be landed as one small PR after this audit is reviewed. No storage-layout +changes. All four contracts are behind proxies (PR #13), so re-deploying +locally with updated event signatures is low-friction. + +--- + +## 1. DINModelRegistry + +### 1.1 Events audited + +| Event | Indexed fields | Non-indexed fields | Assessment | +|---|---|---|---| +| `ModelRegistrationRequested` | `requestId`, `requester` | — | **Gap — see §3.1** | +| `ModelApproved` | `requestId`, `modelId` | — | Sufficient | +| `ModelRejected` | `requestId` | — | Sufficient | +| `ManifestUpdateRequested` | `requestId`, `modelId` | — | **Gap — see §3.2** | +| `ManifestUpdated` | `requestId`, `modelId` | `newCID` | Sufficient | +| `ManifestUpdateRejected` | `requestId` | — | Sufficient | +| `ModelDisabled` | `modelId` | — | Sufficient | +| `ModelEnabled` | `modelId` | — | Sufficient | +| `OpenSourceFeeUpdated` | — | `newFee` | Sufficient for timeline; no old-value field but this is acceptable | +| `ProprietaryFeeUpdated` | — | `newFee` | Same as above | +| `OpenSourceUpdateFeeUpdated` | — | `newFee` | Same as above | +| `ProprietaryUpdateFeeUpdated` | — | `newFee` | Same as above | +| `FeesUpdated` | — | `openSourceFee`, `proprietaryFee`, `openSourceUpdateFee`, `proprietaryUpdateFee` | Sufficient for atomic governance proposals | +| `FeesWithdrawn` | `to` | `amount` | Sufficient | +| `DAOAdminUpdated` | `oldAdmin`, `newAdmin` | — | Sufficient | + +### 1.2 Missing transitions + +**No event on `ModelRequest` task-contract addresses.** `ModelRegistrationRequested` +does not include `taskCoordinator` or `taskAuditor`. These can be recovered from the +`ModelApproved` path via the on-chain `getModel()` view, but that requires a call at +index time. The proposed addition in §3.1 does not cover these fields — they are an +additional gap. For v1 the indexer will query them via the `ModelApproved` handler +(the contracts are local-only so a call in the mapping is currently acceptable as a +one-time lookup on approval, not on every request). Flagged as a follow-up candidate +for a future event extension. + +--- + +## 2. DinValidatorStake + +### 2.1 Events audited + +| Event | Indexed fields | Non-indexed fields | Assessment | +|---|---|---|---| +| `ValidatorStaked` | `validator` | `amount` | Sufficient | +| `ValidatorSlashed` | `validator`, `reason`, `slasher` | `amount` | Sufficient — all key query fields indexed | +| `ValidatorUnstakeRequested` | `validator` | `amount`, `withdrawAvailableAt` | Sufficient | +| `ValidatorWithdrawalClaimed` | `validator` | `amount` | Sufficient | +| `ValidatorBlacklisted` | `validator` | — | Sufficient | +| `ValidatorUnblacklisted` | `validator` | — | Sufficient | +| `SlasherContractAdded` | `slasher` | — | **Signature conflict — see §4** | +| `SlasherContractRemoved` | `slasher` | — | **Signature conflict — see §4** | + +### 2.2 Missing transitions + +**No `ValidatorJailed` event.** `ValidatorInfo` carries a `jailedUntil` timestamp and +the `_syncValidatorStatus` helper transitions validators into `Jailed` status when +`jailedUntil > block.timestamp`, but no function in the current contract sets +`jailedUntil` or emits a jailing event. The Jailed status path is currently dead code. +No action required for the indexer now; flagged for the slashing/dispute spec (P3-4.x) +— when a jailing mechanism is added, it must emit a `ValidatorJailed` event with the +`jailedUntil` timestamp for the indexer to reconstruct status correctly. + +**Validator status derivable from existing events.** Current live status transitions +(None → Active → Exiting → Active, Blacklisted, Unblacklisted) are fully reconstructable +from `ValidatorStaked`, `ValidatorUnstakeRequested`, `ValidatorWithdrawalClaimed`, +`ValidatorBlacklisted`, and `ValidatorUnblacklisted`. No additional event needed for +the current status machine. + +--- + +## 3. DinCoordinator + +### 3.1 Events audited + +| Event | Indexed fields | Non-indexed fields | Assessment | +|---|---|---|---| +| `EthDepositAndDINminted` | `user` | `ethAmount`, `mintAmount` | Sufficient | +| `SlasherContractAdded` | `slasher` | — | **Signature conflict — see §4** | +| `SlasherContractRemoved` | `slasher` | — | **Signature conflict — see §4** | +| `ValidatorStakeContractUpdated` | `validatorStakeContract` | — | Sufficient | +| `DinPerEthUpdated` | — | `newRate` | Sufficient; old rate omitted but can be derived from the previous snapshot entity | + +### 3.2 Missing transitions + +**`withdraw()` emits no event.** The owner ETH withdrawal function transfers the +coordinator's entire ETH balance with no event. This makes treasury outflow invisible +to the indexer. + +**Proposed addition:** `ETHTreasuryWithdrawn(address indexed to, uint256 amount)` +emitted at the end of `withdraw()`. This is a pure observatory addition — one event, +one line. See §3.3. + +--- + +## 4. DinToken + +### 4.1 Events audited + +| Event | Indexed fields | Non-indexed fields | Assessment | +|---|---|---|---| +| `TokensMinted` | `to` | `amount` | Sufficient | +| `Transfer` (ERC20 inherited) | `from`, `to` | `value` | Sufficient — standard | +| `Approval` (ERC20 inherited) | `owner`, `spender` | `value` | Sufficient — standard | + +No gaps identified. + +--- + +## 5. Cross-contract issue: SlasherContractAdded / SlasherContractRemoved + +Both `DinCoordinator` and `DinValidatorStake` emit events with **identical signatures**: + +``` +event SlasherContractAdded(address indexed slasher) +event SlasherContractRemoved(address indexed slasher) +``` + +This is intentional at the Solidity level — `DinCoordinator.addSlasherContract` +forwards the call to `DinValidatorStake`, so both contracts emit the same event for +the same action. + +**Indexer implication:** If both events are processed without tracking the source +contract address, a single slasher add/remove operation creates two identical entity +records and slasher counts become inflated. + +**Resolution (schema-level, no contract change needed):** The subgraph data source +for each contract processes its own event; the `SlasherRegistration` entity carries a +`sourceContract` field (set to the emitting contract's address in the mapping handler). +`DinCoordinator`-sourced and `DinValidatorStake`-sourced events are therefore kept +as distinct records. The canonical slasher set is derived only from +`DinValidatorStake` events, since that contract owns the actual `slasherContracts` +mapping. `DinCoordinator` events are recorded for audit trail completeness only. + +--- + +## 6. Proposed event additions + +The following two additions were confirmed by Umer (2026-07-09, issue #24). All +other gaps noted in this document are either handled at the schema level (§5) or +deferred to future work. + +### 6.1 `DINModelRegistry.ModelRegistrationRequested` + +**Current:** +```solidity +event ModelRegistrationRequested( + uint256 indexed requestId, + address indexed requester +); +``` + +**Proposed:** +```solidity +event ModelRegistrationRequested( + uint256 indexed requestId, + address indexed requester, + bool isOpenSource, + uint256 fee +); +``` + +**Why:** Without `isOpenSource`, the indexer cannot distinguish open-source from +proprietary pending requests without a storage call. Without `fee`, the indexer +cannot surface fee amounts for pending-request dashboards. Both fields are already +in storage (`modelRequests[requestId].isOpenSource` / `.feePaid`) — emitting them +costs one additional log word each. + +### 6.2 `DINModelRegistry.ManifestUpdateRequested` + +**Current:** +```solidity +event ManifestUpdateRequested( + uint256 indexed requestId, + uint256 indexed modelId +); +``` + +**Proposed:** +```solidity +event ManifestUpdateRequested( + uint256 indexed requestId, + uint256 indexed modelId, + address indexed requester +); +``` + +**Why:** Without `requester`, the indexer cannot attribute a pending manifest update +to its submitter. The requester address is already available as `msg.sender` at emit +time — adding it costs nothing beyond the extra log topic slot. + +### 6.3 `DinCoordinator.ETHTreasuryWithdrawn` (new event) + +**Proposed addition to `withdraw()`:** +```solidity +event ETHTreasuryWithdrawn(address indexed to, uint256 amount); +``` + +**Why:** The coordinator's `withdraw()` function transfers the full ETH balance to +the owner with no trace. For treasury governance and audit purposes, every outflow +should be observable on-chain. This addition is independent of the §6.1/6.2 changes +and can be batched into the same PR. + +--- + +## 7. Daemon event schema (P4-7.1 feed) + +The following platform-contract events are the minimum the `dind` daemon (issue #21) +needs to subscribe to for reactive job scheduling. Coverage gaps are noted. + +| Event | Contract | dind use | Gap | +|---|---|---|---| +| `ModelRegistrationRequested` | DINModelRegistry | Notify DAO admin of new pending request | After §6.1 addition: adequate | +| `ModelApproved` | DINModelRegistry | Trigger model-owner onboarding flow | Adequate | +| `ModelRejected` | DINModelRegistry | Notify model owner | Adequate | +| `ManifestUpdateRequested` | DINModelRegistry | Notify DAO admin | After §6.2 addition: adequate | +| `ManifestUpdated` | DINModelRegistry | Trigger manifest refresh in local cache | Adequate | +| `ValidatorStaked` | DinValidatorStake | Update local validator-eligibility cache | Adequate | +| `ValidatorSlashed` | DinValidatorStake | Log slash for local validator operator | Adequate | +| `ValidatorBlacklisted` | DinValidatorStake | Halt local validator process immediately | Adequate | +| `DinPerEthUpdated` | DinCoordinator | Update local exchange-rate cache | Adequate — old rate derivable from prior snapshot | +| `DAOAdminUpdated` | DINModelRegistry | Alert operator of governance-key rotation | Adequate | + +**Events not yet needed by dind v1** (task-contract GI events): not in scope here. +These belong in the P4-IDX3 handoff notes once platform-contract indexing is stable. From 150e35c99ee93cf648ef34179a1266af2e790243 Mon Sep 17 00:00:00 2001 From: robertocarlous Date: Fri, 10 Jul 2026 23:48:59 +0100 Subject: [PATCH 29/48] feat(indexer): add GraphQL entity schema for platform-contract subgraph (P4-IDX1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defines all queryable entities for the DIN platform subgraph covering DINModelRegistry, DinValidatorStake, DinCoordinator, and DinToken. Entities: Registry: ModelRegistrationRequest, Model, ManifestUpdateRequest, ModelKillSwitchEvent, FeeSnapshot, RegistryFeeWithdrawal, DAOAdminTransfer Staking: Validator (aggregate), StakeEvent, SlashEvent, UnstakeRequest, WithdrawalClaim, SlasherRegistration Coordinator: DINMintEvent, TreasuryWithdrawal, ExchangeRateSnapshot Token: TokenMintEvent, TokenTransferEvent Design notes: - SlasherRegistration carries sourceContract to keep DinCoordinator and DinValidatorStake event streams distinguishable (identical event signatures) - ModelRegistrationRequest.isOpenSource / .feePaid and ManifestUpdateRequest .requester are marked pending the §6.1/6.2 event additions; mapping handlers will use storage calls as a bridge until those events land - TreasuryWithdrawal entity is defined but will not be populated until the ETHTreasuryWithdrawn event addition (audit §6.3) lands - Task-level contracts (DINTaskCoordinator/DINTaskAuditor) are out of scope; deferred to P5+ dynamic data sources --- subgraph/schema.graphql | 380 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 380 insertions(+) create mode 100644 subgraph/schema.graphql diff --git a/subgraph/schema.graphql b/subgraph/schema.graphql new file mode 100644 index 00000000..a6daa103 --- /dev/null +++ b/subgraph/schema.graphql @@ -0,0 +1,380 @@ +# DIN Protocol — The Graph subgraph schema +# +# Covers the four platform contracts: +# DINModelRegistry, DinValidatorStake, DinCoordinator, DinToken +# +# Task-level contracts (DINTaskCoordinator, DINTaskAuditor) are out of scope +# for this subgraph — deferred to P5+ dynamic data sources. +# +# Chain: chainId 1337 (local anvil / hardhat). +# Event field notes: two event additions are pending a reviewed PR +# (see subgraph/docs/event-coverage-audit.md §6). Fields marked +# [PENDING EVENT ADDITION] are populated once those events land; until then +# the mapping handler will set them from a storage call on approval. + +# ───────────────────────────────────────────────────────────────────────────── +# Model Registry — registration requests +# ───────────────────────────────────────────────────────────────────────────── + +"""A request submitted by a model owner for DAO review and approval.""" +type ModelRegistrationRequest @entity { + "requestId as decimal string" + id: ID! + + requestId: BigInt! + requester: Bytes! + + "Populated from event once §6.1 addition lands; derived from storage call until then" + isOpenSource: Boolean! + + "Fee paid at submission time in wei; populated once §6.1 addition lands" + feePaid: BigInt! + + manifestCID: Bytes! + taskCoordinator: Bytes! + taskAuditor: Bytes! + + processed: Boolean! + approved: Boolean + + "Set when the request is approved" + model: Model + + submittedAtBlock: BigInt! + submittedAtTimestamp: BigInt! + processedAtBlock: BigInt + processedAtTimestamp: BigInt +} + +# ───────────────────────────────────────────────────────────────────────────── +# Model Registry — approved models +# ───────────────────────────────────────────────────────────────────────────── + +"""An approved federated-learning model registered on the DIN protocol.""" +type Model @entity { + "modelId as decimal string" + id: ID! + + modelId: BigInt! + owner: Bytes! + isOpenSource: Boolean! + manifestCID: Bytes! + taskCoordinator: Bytes! + taskAuditor: Bytes! + + disabled: Boolean! + + "The registration request that created this model" + registrationRequest: ModelRegistrationRequest! + + manifestHistory: [ManifestUpdateRequest!]! @derivedFrom(field: "model") + disableEvents: [ModelKillSwitchEvent!]! @derivedFrom(field: "model") + + createdAtBlock: BigInt! + createdAtTimestamp: BigInt! +} + +# ───────────────────────────────────────────────────────────────────────────── +# Model Registry — manifest update requests +# ───────────────────────────────────────────────────────────────────────────── + +"""A manifest update request submitted by the model owner for DAO review.""" +type ManifestUpdateRequest @entity { + "requestId as decimal string" + id: ID! + + requestId: BigInt! + model: Model! + + "Populated from event once §6.2 addition lands; derivable from model.owner until then" + requester: Bytes! + + feePaid: BigInt! + newManifestCID: Bytes! + + processed: Boolean! + approved: Boolean + + submittedAtBlock: BigInt! + submittedAtTimestamp: BigInt! + processedAtBlock: BigInt + processedAtTimestamp: BigInt +} + +# ───────────────────────────────────────────────────────────────────────────── +# Model Registry — kill-switch history +# ───────────────────────────────────────────────────────────────────────────── + +"""Records each ModelDisabled / ModelEnabled transition.""" +type ModelKillSwitchEvent @entity { + "txHash-logIndex" + id: ID! + + model: Model! + disabled: Boolean! + + blockNumber: BigInt! + blockTimestamp: BigInt! + transactionHash: Bytes! +} + +# ───────────────────────────────────────────────────────────────────────────── +# Model Registry — fee governance +# ───────────────────────────────────────────────────────────────────────────── + +""" +A snapshot of protocol fees at the time of a fee-change event. +One snapshot per fee-change transaction; latest snapshot is the current fee schedule. +""" +type FeeSnapshot @entity { + "txHash-logIndex" + id: ID! + + openSourceFee: BigInt! + proprietaryFee: BigInt! + openSourceUpdateFee: BigInt! + proprietaryUpdateFee: BigInt! + + "atomic = fired by setFees(); granular = fired by an individual setter" + updateKind: String! + + blockNumber: BigInt! + blockTimestamp: BigInt! + transactionHash: Bytes! +} + +"""An ETH fee withdrawal from DINModelRegistry.""" +type RegistryFeeWithdrawal @entity { + "txHash-logIndex" + id: ID! + + to: Bytes! + amount: BigInt! + + blockNumber: BigInt! + blockTimestamp: BigInt! + transactionHash: Bytes! +} + +# ───────────────────────────────────────────────────────────────────────────── +# Model Registry — governance key rotation +# ───────────────────────────────────────────────────────────────────────────── + +"""Records every DAOAdmin ownership transfer.""" +type DAOAdminTransfer @entity { + "txHash-logIndex" + id: ID! + + oldAdmin: Bytes! + newAdmin: Bytes! + + blockNumber: BigInt! + blockTimestamp: BigInt! + transactionHash: Bytes! +} + +# ───────────────────────────────────────────────────────────────────────────── +# Validator stake — per-validator aggregate +# ───────────────────────────────────────────────────────────────────────────── + +""" +Aggregate state for a single validator address, updated on every relevant event. +Derived status (Active / Exiting / Jailed / Blacklisted / None) is computed +from the event stream — no dedicated ValidatorStatusChanged event exists yet. +""" +type Validator @entity { + "validator address as hex string" + id: ID! + + address: Bytes! + + activeStake: BigInt! + pendingWithdrawal: BigInt! + withdrawAvailableAt: BigInt + + "Active | Exiting | Jailed | Blacklisted | None" + status: String! + + totalStaked: BigInt! + totalSlashed: BigInt! + totalWithdrawn: BigInt! + + blacklisted: Boolean! + + stakeEvents: [StakeEvent!]! @derivedFrom(field: "validator") + slashEvents: [SlashEvent!]! @derivedFrom(field: "validator") + unstakeRequests: [UnstakeRequest!]! @derivedFrom(field: "validator") + withdrawalClaims: [WithdrawalClaim!]! @derivedFrom(field: "validator") +} + +# ───────────────────────────────────────────────────────────────────────────── +# Validator stake — individual events +# ───────────────────────────────────────────────────────────────────────────── + +"""A DIN stake deposit by a validator.""" +type StakeEvent @entity { + "txHash-logIndex" + id: ID! + + validator: Validator! + amount: BigInt! + + blockNumber: BigInt! + blockTimestamp: BigInt! + transactionHash: Bytes! +} + +"""A slash applied to a validator's stake.""" +type SlashEvent @entity { + "txHash-logIndex" + id: ID! + + validator: Validator! + amount: BigInt! + reason: Bytes! + + "Address of the slasher contract (DINTaskCoordinator or DINTaskAuditor)" + slasherContract: Bytes! + + blockNumber: BigInt! + blockTimestamp: BigInt! + transactionHash: Bytes! +} + +"""An unstake (unbonding) request submitted by a validator.""" +type UnstakeRequest @entity { + "txHash-logIndex" + id: ID! + + validator: Validator! + amount: BigInt! + withdrawAvailableAt: BigInt! + + claimed: Boolean! + + blockNumber: BigInt! + blockTimestamp: BigInt! + transactionHash: Bytes! +} + +"""A completed withdrawal after the unbonding period.""" +type WithdrawalClaim @entity { + "txHash-logIndex" + id: ID! + + validator: Validator! + amount: BigInt! + + blockNumber: BigInt! + blockTimestamp: BigInt! + transactionHash: Bytes! +} + +# ───────────────────────────────────────────────────────────────────────────── +# Validator stake — slasher registry +# ───────────────────────────────────────────────────────────────────────────── + +""" +A slasher contract authorized to call DinValidatorStake.slash(). +sourceContract distinguishes DinCoordinator-originated events from +DinValidatorStake-originated events — both emit identical SlasherContractAdded/ +Removed signatures (see event-coverage-audit.md §5). +The canonical active-slasher set is derived from DinValidatorStake events only. +""" +type SlasherRegistration @entity { + "slasherAddress-sourceContract as hex pair" + id: ID! + + slasherAddress: Bytes! + + "DinCoordinator or DinValidatorStake — the contract that emitted the event" + sourceContract: Bytes! + + active: Boolean! + + addedAtBlock: BigInt! + addedAtTimestamp: BigInt! + removedAtBlock: BigInt + removedAtTimestamp: BigInt +} + +# ───────────────────────────────────────────────────────────────────────────── +# DinCoordinator — ETH deposits and treasury +# ───────────────────────────────────────────────────────────────────────────── + +"""An ETH deposit that minted DIN tokens for a user.""" +type DINMintEvent @entity { + "txHash-logIndex" + id: ID! + + user: Bytes! + ethAmount: BigInt! + dinAmount: BigInt! + + blockNumber: BigInt! + blockTimestamp: BigInt! + transactionHash: Bytes! +} + +""" +An ETH treasury withdrawal from DinCoordinator. +Requires the ETHTreasuryWithdrawn event addition (audit §6.3). +Until that event lands this entity will not be populated. +""" +type TreasuryWithdrawal @entity { + "txHash-logIndex" + id: ID! + + to: Bytes! + amount: BigInt! + + blockNumber: BigInt! + blockTimestamp: BigInt! + transactionHash: Bytes! +} + +"""A change to the DIN-per-ETH exchange rate.""" +type ExchangeRateSnapshot @entity { + "txHash-logIndex" + id: ID! + + newRate: BigInt! + + blockNumber: BigInt! + blockTimestamp: BigInt! + transactionHash: Bytes! +} + +# ───────────────────────────────────────────────────────────────────────────── +# DinToken — mint and transfer history +# ───────────────────────────────────────────────────────────────────────────── + +"""A DIN token mint (always from DinCoordinator via depositAndMint).""" +type TokenMintEvent @entity { + "txHash-logIndex" + id: ID! + + to: Bytes! + amount: BigInt! + + blockNumber: BigInt! + blockTimestamp: BigInt! + transactionHash: Bytes! +} + +""" +A DIN ERC20 transfer. Indexed from the standard Transfer event. +Includes zero-address mint/burn transfers for completeness. +""" +type TokenTransferEvent @entity { + "txHash-logIndex" + id: ID! + + from: Bytes! + to: Bytes! + amount: BigInt! + + blockNumber: BigInt! + blockTimestamp: BigInt! + transactionHash: Bytes! +} From f464d777f1beb0c230a61f2696d397e38e6a0d8b Mon Sep 17 00:00:00 2001 From: robertocarlous Date: Sat, 11 Jul 2026 12:56:44 +0100 Subject: [PATCH 30/48] feat(indexer): add subgraph.yaml manifest and contract ABIs (P4-IDX1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defines the Graph Protocol subgraph manifest covering all four platform contracts on the local development chain (chainId 1337, network: din-local). Data sources: - DINModelRegistry → src/registry.ts (15 event handlers) - DinValidatorStake → src/staking.ts (8 event handlers) - DinCoordinator → src/coordinator.ts (5 event handlers + 1 commented-out pending ETHTreasuryWithdrawn addition) - DinToken → src/token.ts (2 event handlers) ABIs copied from dincli/abis/ into subgraph/abis/ — single source of truth until a build step syncs them automatically. Contract addresses are placeholders (0x000...001–004); replace with actual deployed addresses after running the platform contract deployment script. SlasherContractAdded/Removed duplicate-signature handling: DinValidatorStake handlers produce the canonical slasher-set records; DinCoordinator handlers produce audit-trail records with sourceContract set to the coordinator address. --- subgraph/abis/DINModelRegistry.json | 934 +++++++++++++++++++++++++++ subgraph/abis/DinCoordinator.json | 290 +++++++++ subgraph/abis/DinToken.json | 383 +++++++++++ subgraph/abis/DinValidatorStake.json | 639 ++++++++++++++++++ subgraph/subgraph.yaml | 213 ++++++ 5 files changed, 2459 insertions(+) create mode 100644 subgraph/abis/DINModelRegistry.json create mode 100644 subgraph/abis/DinCoordinator.json create mode 100644 subgraph/abis/DinToken.json create mode 100644 subgraph/abis/DinValidatorStake.json create mode 100644 subgraph/subgraph.yaml diff --git a/subgraph/abis/DINModelRegistry.json b/subgraph/abis/DINModelRegistry.json new file mode 100644 index 00000000..096023c8 --- /dev/null +++ b/subgraph/abis/DINModelRegistry.json @@ -0,0 +1,934 @@ +{ + "abi": [ + { + "inputs": [ + { + "internalType": "address", + "name": "_dinValidatorStake", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [], + "name": "AlreadyProcessed", + "type": "error" + }, + { + "inputs": [], + "name": "AuditorNoLongerSlasher", + "type": "error" + }, + { + "inputs": [], + "name": "AuditorOwnershipChanged", + "type": "error" + }, + { + "inputs": [], + "name": "CoordinatorNoLongerSlasher", + "type": "error" + }, + { + "inputs": [], + "name": "CoordinatorOwnershipChanged", + "type": "error" + }, + { + "inputs": [], + "name": "InsufficientFee", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidModelId", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidRequestId", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "modelId", + "type": "uint256" + } + ], + "name": "ModelIsDisabled", + "type": "error" + }, + { + "inputs": [], + "name": "NotDINDAOAdmin", + "type": "error" + }, + { + "inputs": [], + "name": "NotModelOwner", + "type": "error" + }, + { + "inputs": [], + "name": "NotOwnerOfTaskAuditor", + "type": "error" + }, + { + "inputs": [], + "name": "NotOwnerOfTaskCoordinator", + "type": "error" + }, + { + "inputs": [], + "name": "TaskAuditorAlreadyRegistered", + "type": "error" + }, + { + "inputs": [], + "name": "TaskCoordinatorAlreadyRegistered", + "type": "error" + }, + { + "inputs": [], + "name": "TaskCoordinatorEqualsTaskAuditor", + "type": "error" + }, + { + "inputs": [], + "name": "ZeroAddress", + "type": "error" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "oldAdmin", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "newAdmin", + "type": "address" + } + ], + "name": "DAOAdminUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "openSourceFee", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "proprietaryFee", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "openSourceUpdateFee", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "proprietaryUpdateFee", + "type": "uint256" + } + ], + "name": "FeesUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "FeesWithdrawn", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "requestId", + "type": "uint256" + } + ], + "name": "ManifestUpdateRejected", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "requestId", + "type": "uint256" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "modelId", + "type": "uint256" + } + ], + "name": "ManifestUpdateRequested", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "requestId", + "type": "uint256" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "modelId", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "bytes32", + "name": "newCID", + "type": "bytes32" + } + ], + "name": "ManifestUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "requestId", + "type": "uint256" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "modelId", + "type": "uint256" + } + ], + "name": "ModelApproved", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "modelId", + "type": "uint256" + } + ], + "name": "ModelDisabled", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "modelId", + "type": "uint256" + } + ], + "name": "ModelEnabled", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "requestId", + "type": "uint256" + }, + { + "indexed": true, + "internalType": "address", + "name": "requester", + "type": "address" + } + ], + "name": "ModelRegistrationRequested", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "requestId", + "type": "uint256" + } + ], + "name": "ModelRejected", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "newFee", + "type": "uint256" + } + ], + "name": "OpenSourceFeeUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "newFee", + "type": "uint256" + } + ], + "name": "OpenSourceUpdateFeeUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "newFee", + "type": "uint256" + } + ], + "name": "ProprietaryFeeUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "newFee", + "type": "uint256" + } + ], + "name": "ProprietaryUpdateFeeUpdated", + "type": "event" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "requestId", + "type": "uint256" + } + ], + "name": "approveManifestUpdate", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "requestId", + "type": "uint256" + } + ], + "name": "approveModel", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "daoAdmin", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "dinValidatorStake", + "outputs": [ + { + "internalType": "contract IDinValidatorStake", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "modelId", + "type": "uint256" + } + ], + "name": "disableModel", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "modelId", + "type": "uint256" + } + ], + "name": "enableModel", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "modelId", + "type": "uint256" + } + ], + "name": "getModel", + "outputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "internalType": "bool", + "name": "isOpenSource", + "type": "bool" + }, + { + "internalType": "bytes32", + "name": "manifestCID", + "type": "bytes32" + }, + { + "internalType": "uint256", + "name": "createdAt", + "type": "uint256" + }, + { + "internalType": "address", + "name": "taskCoordinator", + "type": "address" + }, + { + "internalType": "address", + "name": "taskAuditor", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "taskAuditor", + "type": "address" + } + ], + "name": "getModelIdByTaskAuditor", + "outputs": [ + { + "internalType": "bool", + "name": "exists", + "type": "bool" + }, + { + "internalType": "uint256", + "name": "modelId", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "taskCoordinator", + "type": "address" + } + ], + "name": "getModelIdByTaskCoordinator", + "outputs": [ + { + "internalType": "bool", + "name": "exists", + "type": "bool" + }, + { + "internalType": "uint256", + "name": "modelId", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "name": "manifestRequests", + "outputs": [ + { + "internalType": "uint256", + "name": "modelId", + "type": "uint256" + }, + { + "internalType": "bytes32", + "name": "newManifestCID", + "type": "bytes32" + }, + { + "internalType": "address", + "name": "requester", + "type": "address" + }, + { + "internalType": "uint256", + "name": "feePaid", + "type": "uint256" + }, + { + "internalType": "bool", + "name": "processed", + "type": "bool" + }, + { + "internalType": "bool", + "name": "approved", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "name": "modelDisabled", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "name": "modelRequests", + "outputs": [ + { + "internalType": "address", + "name": "requester", + "type": "address" + }, + { + "internalType": "bool", + "name": "isOpenSource", + "type": "bool" + }, + { + "internalType": "bytes32", + "name": "manifestCID", + "type": "bytes32" + }, + { + "internalType": "address", + "name": "taskCoordinator", + "type": "address" + }, + { + "internalType": "address", + "name": "taskAuditor", + "type": "address" + }, + { + "internalType": "uint256", + "name": "feePaid", + "type": "uint256" + }, + { + "internalType": "bool", + "name": "processed", + "type": "bool" + }, + { + "internalType": "bool", + "name": "approved", + "type": "bool" + }, + { + "internalType": "uint256", + "name": "createdAt", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "openSourceFee", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "openSourceUpdateFee", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "proprietaryFee", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "proprietaryUpdateFee", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "requestId", + "type": "uint256" + } + ], + "name": "rejectManifestUpdate", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "requestId", + "type": "uint256" + } + ], + "name": "rejectModel", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "modelId", + "type": "uint256" + }, + { + "internalType": "bytes32", + "name": "newManifestCID", + "type": "bytes32" + } + ], + "name": "requestManifestUpdate", + "outputs": [ + { + "internalType": "uint256", + "name": "requestId", + "type": "uint256" + } + ], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "manifestCID", + "type": "bytes32" + }, + { + "internalType": "address", + "name": "taskCoordinator", + "type": "address" + }, + { + "internalType": "address", + "name": "taskAuditor", + "type": "address" + }, + { + "internalType": "bool", + "name": "isOpenSource", + "type": "bool" + } + ], + "name": "requestModelRegistration", + "outputs": [ + { + "internalType": "uint256", + "name": "requestId", + "type": "uint256" + } + ], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newAdmin", + "type": "address" + } + ], + "name": "setDAOAdmin", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_openSourceFee", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_proprietaryFee", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_openSourceUpdateFee", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_proprietaryUpdateFee", + "type": "uint256" + } + ], + "name": "setFees", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "newFee", + "type": "uint256" + } + ], + "name": "setOpenSourceFee", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "newFee", + "type": "uint256" + } + ], + "name": "setOpenSourceUpdateFee", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "newFee", + "type": "uint256" + } + ], + "name": "setProprietaryFee", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "newFee", + "type": "uint256" + } + ], + "name": "setProprietaryUpdateFee", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "totalManifestRequests", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "totalModelRequests", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "totalModels", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address payable", + "name": "to", + "type": "address" + } + ], + "name": "withdrawFees", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + } + ] +} \ No newline at end of file diff --git a/subgraph/abis/DinCoordinator.json b/subgraph/abis/DinCoordinator.json new file mode 100644 index 00000000..82f390d9 --- /dev/null +++ b/subgraph/abis/DinCoordinator.json @@ -0,0 +1,290 @@ +{ + "abi": [ + { + "inputs": [], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [], + "name": "InvalidAddress", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + } + ], + "name": "OwnableInvalidOwner", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "OwnableUnauthorizedAccount", + "type": "error" + }, + { + "inputs": [], + "name": "ReentrancyGuardReentrantCall", + "type": "error" + }, + { + "inputs": [], + "name": "TransferFailed", + "type": "error" + }, + { + "inputs": [], + "name": "ValidatorStakeContractNotSet", + "type": "error" + }, + { + "inputs": [], + "name": "ZeroValue", + "type": "error" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "newRate", + "type": "uint256" + } + ], + "name": "DinPerEthUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "user", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "ethAmount", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "mintAmount", + "type": "uint256" + } + ], + "name": "EthDepositAndDINminted", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "previousOwner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "OwnershipTransferred", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "slasher", + "type": "address" + } + ], + "name": "SlasherContractAdded", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "slasher", + "type": "address" + } + ], + "name": "SlasherContractRemoved", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "validatorStakeContract", + "type": "address" + } + ], + "name": "ValidatorStakeContractUpdated", + "type": "event" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "slasherContract", + "type": "address" + } + ], + "name": "addSlasherContract", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "depositAndMint", + "outputs": [], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [], + "name": "dinPerEth", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "dinToken", + "outputs": [ + { + "internalType": "contract DinToken", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "dinValidatorStakeContract", + "outputs": [ + { + "internalType": "contract IDinValidatorStake", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "owner", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "slasherContract", + "type": "address" + } + ], + "name": "removeSlasherContract", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "renounceOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "transferOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "newRate", + "type": "uint256" + } + ], + "name": "updateDinPerEth", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "validatorStakeContract", + "type": "address" + } + ], + "name": "updateValidatorStakeContract", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "withdraw", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + } + ] +} \ No newline at end of file diff --git a/subgraph/abis/DinToken.json b/subgraph/abis/DinToken.json new file mode 100644 index 00000000..1b7e09c3 --- /dev/null +++ b/subgraph/abis/DinToken.json @@ -0,0 +1,383 @@ +{ + "abi": [ + { + "inputs": [ + { + "internalType": "address", + "name": "owner_", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "allowance", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "needed", + "type": "uint256" + } + ], + "name": "ERC20InsufficientAllowance", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "sender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "balance", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "needed", + "type": "uint256" + } + ], + "name": "ERC20InsufficientBalance", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "approver", + "type": "address" + } + ], + "name": "ERC20InvalidApprover", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "receiver", + "type": "address" + } + ], + "name": "ERC20InvalidReceiver", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "sender", + "type": "address" + } + ], + "name": "ERC20InvalidSender", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + } + ], + "name": "ERC20InvalidSpender", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidAddress", + "type": "error" + }, + { + "inputs": [], + "name": "Unauthorized", + "type": "error" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "Approval", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "TokensMinted", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "Transfer", + "type": "event" + }, + { + "inputs": [], + "name": "OWNER", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "internalType": "address", + "name": "spender", + "type": "address" + } + ], + "name": "allowance", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "approve", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "balanceOf", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "decimals", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "mint", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "name", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "symbol", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "totalSupply", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "transfer", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "transferFrom", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + } + ] +} \ No newline at end of file diff --git a/subgraph/abis/DinValidatorStake.json b/subgraph/abis/DinValidatorStake.json new file mode 100644 index 00000000..cb285322 --- /dev/null +++ b/subgraph/abis/DinValidatorStake.json @@ -0,0 +1,639 @@ +{ + "abi": [ + { + "inputs": [ + { + "internalType": "address", + "name": "dinToken", + "type": "address" + }, + { + "internalType": "address", + "name": "dinCoordinator", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [], + "name": "AmountLessThanMinStake", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidAddress", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidSlashAmount", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidUnstakeAmount", + "type": "error" + }, + { + "inputs": [], + "name": "NoPendingWithdrawal", + "type": "error" + }, + { + "inputs": [], + "name": "NotDINCoordinator", + "type": "error" + }, + { + "inputs": [], + "name": "NotEnoughStake", + "type": "error" + }, + { + "inputs": [], + "name": "NotSlasherContract", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + } + ], + "name": "OwnableInvalidOwner", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "OwnableUnauthorizedAccount", + "type": "error" + }, + { + "inputs": [], + "name": "PendingWithdrawalExists", + "type": "error" + }, + { + "inputs": [], + "name": "ReentrancyGuardReentrantCall", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "token", + "type": "address" + } + ], + "name": "SafeERC20FailedOperation", + "type": "error" + }, + { + "inputs": [], + "name": "SlasherContractAlreadyAdded", + "type": "error" + }, + { + "inputs": [], + "name": "SlasherContractNotAdded", + "type": "error" + }, + { + "inputs": [], + "name": "ValidatorIsBlacklisted", + "type": "error" + }, + { + "inputs": [], + "name": "ValidatorNotBlacklisted", + "type": "error" + }, + { + "inputs": [], + "name": "WithdrawalNotReady", + "type": "error" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "previousOwner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "OwnershipTransferred", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "slasher", + "type": "address" + } + ], + "name": "SlasherContractAdded", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "slasher", + "type": "address" + } + ], + "name": "SlasherContractRemoved", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "validator", + "type": "address" + } + ], + "name": "ValidatorBlacklisted", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "validator", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + }, + { + "indexed": true, + "internalType": "bytes32", + "name": "reason", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "address", + "name": "slasher", + "type": "address" + } + ], + "name": "ValidatorSlashed", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "validator", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "ValidatorStaked", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "validator", + "type": "address" + } + ], + "name": "ValidatorUnblacklisted", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "validator", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint64", + "name": "withdrawAvailableAt", + "type": "uint64" + } + ], + "name": "ValidatorUnstakeRequested", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "validator", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "ValidatorWithdrawalClaimed", + "type": "event" + }, + { + "inputs": [], + "name": "DIN_COORDINATOR", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "DIN_TOKEN", + "outputs": [ + { + "internalType": "contract IERC20", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "MIN_STAKE", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "UNBONDING_PERIOD", + "outputs": [ + { + "internalType": "uint64", + "name": "", + "type": "uint64" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "slasherContract", + "type": "address" + } + ], + "name": "addSlasherContract", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "validator", + "type": "address" + } + ], + "name": "blacklistValidator", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "claimUnstaked", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "validator", + "type": "address" + } + ], + "name": "getStake", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "slasherContract", + "type": "address" + } + ], + "name": "isSlasherContract", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "validator", + "type": "address" + } + ], + "name": "isValidatorActive", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "minStake", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "pure", + "type": "function" + }, + { + "inputs": [], + "name": "owner", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "slasherContract", + "type": "address" + } + ], + "name": "removeSlasherContract", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "renounceOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "validator", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + }, + { + "internalType": "bytes32", + "name": "reason", + "type": "bytes32" + } + ], + "name": "slash", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "validator", + "type": "address" + } + ], + "name": "slashableStakeOf", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "slasherContracts", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "stake", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "transferOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "validator", + "type": "address" + } + ], + "name": "unblacklistValidator", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "unstake", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "validators", + "outputs": [ + { + "internalType": "uint256", + "name": "activeStake", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "pendingWithdrawals", + "type": "uint256" + }, + { + "internalType": "uint64", + "name": "withdrawAvailableAt", + "type": "uint64" + }, + { + "internalType": "uint64", + "name": "jailedUntil", + "type": "uint64" + }, + { + "internalType": "enum DinValidatorStake.ValidatorStatus", + "name": "status", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + } + ] +} \ No newline at end of file diff --git a/subgraph/subgraph.yaml b/subgraph/subgraph.yaml new file mode 100644 index 00000000..408eb3ef --- /dev/null +++ b/subgraph/subgraph.yaml @@ -0,0 +1,213 @@ +specVersion: 0.0.5 +description: > + DIN Protocol platform-contract subgraph. Indexes DINModelRegistry, + DinValidatorStake, DinCoordinator, and DinToken on the local development + chain (chainId 1337). Task-level contracts are out of scope — deferred to + P5+ dynamic data sources. +repository: https://github.com/InfiniteZeroFoundation/DevNet +schema: + file: ./schema.graphql + +# ─── Network note ──────────────────────────────────────────────────────────── +# The network name "din-local" must match the network configured in the +# Graph node's config.toml or networks.toml (see docker-compose.yml). The +# Graph node maps this name to the local anvil/hardhat RPC at +# http://localhost:8545 (chainId 1337). +# +# ─── Address note ──────────────────────────────────────────────────────────── +# Placeholder addresses below must be replaced with the actual addresses +# after deploying the platform contracts to the local chain. The canonical +# deploy command is: +# cd hardhat && npx hardhat run scripts/deploy.ts --network localhost +# Deployed addresses are written to hardhat/deployments/local.json. +# A helper script (scripts/update-subgraph-addresses.sh) will patch this file +# automatically once P4-IDX2 setup tooling is in place. +# ───────────────────────────────────────────────────────────────────────────── + +dataSources: + + # ── DINModelRegistry ──────────────────────────────────────────────────────── + - kind: ethereum + name: DINModelRegistry + network: din-local + source: + address: "0x0000000000000000000000000000000000000001" # replace after deploy + abi: DINModelRegistry + startBlock: 0 + mapping: + kind: ethereum/events + apiVersion: 0.0.7 + language: wasm/assemblyscript + entities: + - ModelRegistrationRequest + - Model + - ManifestUpdateRequest + - ModelKillSwitchEvent + - FeeSnapshot + - RegistryFeeWithdrawal + - DAOAdminTransfer + abis: + - name: DINModelRegistry + file: ./abis/DINModelRegistry.json + eventHandlers: + # Registration request lifecycle + - event: ModelRegistrationRequested(indexed uint256,indexed address) + handler: handleModelRegistrationRequested + # NOTE: missing isOpenSource and fee fields until audit §6.1 addition + # lands. Handler will call modelRequests(requestId) as a temporary + # bridge to populate those fields. + - event: ModelApproved(indexed uint256,indexed uint256) + handler: handleModelApproved + - event: ModelRejected(indexed uint256) + handler: handleModelRejected + + # Manifest update lifecycle + - event: ManifestUpdateRequested(indexed uint256,indexed uint256) + handler: handleManifestUpdateRequested + # NOTE: missing requester field until audit §6.2 addition lands. + # Handler will derive requester from Model.owner as a bridge. + - event: ManifestUpdated(indexed uint256,indexed uint256,bytes32) + handler: handleManifestUpdated + - event: ManifestUpdateRejected(indexed uint256) + handler: handleManifestUpdateRejected + + # Kill switch + - event: ModelDisabled(indexed uint256) + handler: handleModelDisabled + - event: ModelEnabled(indexed uint256) + handler: handleModelEnabled + + # Fee governance — granular setters + - event: OpenSourceFeeUpdated(uint256) + handler: handleOpenSourceFeeUpdated + - event: ProprietaryFeeUpdated(uint256) + handler: handleProprietaryFeeUpdated + - event: OpenSourceUpdateFeeUpdated(uint256) + handler: handleOpenSourceUpdateFeeUpdated + - event: ProprietaryUpdateFeeUpdated(uint256) + handler: handleProprietaryUpdateFeeUpdated + + # Fee governance — atomic setter (governance proposals) + - event: FeesUpdated(uint256,uint256,uint256,uint256) + handler: handleFeesUpdated + + # Fee withdrawal and admin rotation + - event: FeesWithdrawn(indexed address,uint256) + handler: handleFeesWithdrawn + - event: DAOAdminUpdated(indexed address,indexed address) + handler: handleDAOAdminUpdated + file: ./src/registry.ts + + # ── DinValidatorStake ──────────────────────────────────────────────────────── + - kind: ethereum + name: DinValidatorStake + network: din-local + source: + address: "0x0000000000000000000000000000000000000002" # replace after deploy + abi: DinValidatorStake + startBlock: 0 + mapping: + kind: ethereum/events + apiVersion: 0.0.7 + language: wasm/assemblyscript + entities: + - Validator + - StakeEvent + - SlashEvent + - UnstakeRequest + - WithdrawalClaim + - SlasherRegistration + abis: + - name: DinValidatorStake + file: ./abis/DinValidatorStake.json + eventHandlers: + - event: ValidatorStaked(indexed address,uint256) + handler: handleValidatorStaked + - event: ValidatorSlashed(indexed address,uint256,indexed bytes32,indexed address) + handler: handleValidatorSlashed + - event: ValidatorUnstakeRequested(indexed address,uint256,uint64) + handler: handleValidatorUnstakeRequested + - event: ValidatorWithdrawalClaimed(indexed address,uint256) + handler: handleValidatorWithdrawalClaimed + - event: ValidatorBlacklisted(indexed address) + handler: handleValidatorBlacklisted + - event: ValidatorUnblacklisted(indexed address) + handler: handleValidatorUnblacklisted + + # SlasherContractAdded/Removed are emitted by both DinCoordinator and + # DinValidatorStake with identical signatures (see event-coverage-audit.md §5). + # This data source produces the canonical slasher-set records + # (sourceContract = DinValidatorStake address). + - event: SlasherContractAdded(indexed address) + handler: handleSlasherContractAdded + - event: SlasherContractRemoved(indexed address) + handler: handleSlasherContractRemoved + file: ./src/staking.ts + + # ── DinCoordinator ─────────────────────────────────────────────────────────── + - kind: ethereum + name: DinCoordinator + network: din-local + source: + address: "0x0000000000000000000000000000000000000003" # replace after deploy + abi: DinCoordinator + startBlock: 0 + mapping: + kind: ethereum/events + apiVersion: 0.0.7 + language: wasm/assemblyscript + entities: + - DINMintEvent + - TreasuryWithdrawal + - ExchangeRateSnapshot + - SlasherRegistration + abis: + - name: DinCoordinator + file: ./abis/DinCoordinator.json + eventHandlers: + - event: EthDepositAndDINminted(indexed address,uint256,uint256) + handler: handleEthDepositAndDINminted + - event: DinPerEthUpdated(uint256) + handler: handleDinPerEthUpdated + - event: ValidatorStakeContractUpdated(indexed address) + handler: handleValidatorStakeContractUpdated + + # Audit-trail records only — canonical slasher set is owned by + # DinValidatorStake. sourceContract set to DinCoordinator address. + - event: SlasherContractAdded(indexed address) + handler: handleCoordinatorSlasherAdded + - event: SlasherContractRemoved(indexed address) + handler: handleCoordinatorSlasherRemoved + + # ETHTreasuryWithdrawn not yet in ABI — handler registered here for + # when the audit §6.3 addition lands and the ABI is updated. + # Uncomment once the event exists: + # - event: ETHTreasuryWithdrawn(indexed address,uint256) + # handler: handleETHTreasuryWithdrawn + file: ./src/coordinator.ts + + # ── DinToken ───────────────────────────────────────────────────────────────── + - kind: ethereum + name: DinToken + network: din-local + source: + address: "0x0000000000000000000000000000000000000004" # replace after deploy + abi: DinToken + startBlock: 0 + mapping: + kind: ethereum/events + apiVersion: 0.0.7 + language: wasm/assemblyscript + entities: + - TokenMintEvent + - TokenTransferEvent + abis: + - name: DinToken + file: ./abis/DinToken.json + eventHandlers: + - event: TokensMinted(indexed address,uint256) + handler: handleTokensMinted + # Standard ERC20 Transfer — includes mint (from=0x0) and burn (to=0x0) + - event: Transfer(indexed address,indexed address,uint256) + handler: handleTransfer + file: ./src/token.ts From 539ca66e2969e5f9bcc134a78205543f673fe96e Mon Sep 17 00:00:00 2001 From: robertocarlous Date: Sat, 11 Jul 2026 12:58:44 +0100 Subject: [PATCH 31/48] docs(indexer): add daemon event schema for dind P4-7.1 (P4-IDX1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defines which platform-contract events the dind daemon must subscribe to, the reaction each event triggers in the daemon job queue, and all current coverage gaps. Handoff document from P4-IDX1 design to Santiago's P4-7.1 on-chain event listening engine. Contents: §1 Subscription priority — immediate (blacklist/kill-switch), high-priority (slash/approval/manifest), and informational buckets §2 Full event payload reference for all daemon-consumed events, including PENDING field annotations for the two confirmed additions (audit §6.1/6.2) §3 Coverage gaps: - Task-level GI events (T1AggregationStarted etc.) not in platform contracts — listed as P5+ task-level indexing requirement with the exact event schema needed for daemon filtering - ValidatorJailed dead code path — required event shape documented for when P3-4.x slashing spec adds the jailing mechanism - ETHTreasuryWithdrawn missing from DinCoordinator.withdraw() §4 Subscription mechanics recommendation for Santiago (WebSocket eth_subscribe, lastProcessedBlock per-contract, replay on restart) §5 Integration order — dincli → SDK extraction → dind, per Umer 2026-07-09 --- subgraph/docs/daemon-event-schema.md | 244 +++++++++++++++++++++++++++ 1 file changed, 244 insertions(+) create mode 100644 subgraph/docs/daemon-event-schema.md diff --git a/subgraph/docs/daemon-event-schema.md b/subgraph/docs/daemon-event-schema.md new file mode 100644 index 00000000..5eace659 --- /dev/null +++ b/subgraph/docs/daemon-event-schema.md @@ -0,0 +1,244 @@ +# Daemon Event Schema — DIN Platform Contracts (P4-IDX1) + +**Consumer:** `dind` — the DIN daemon (issue #21, P4-7.1) +**Authored by:** Robbert (P4-IDX1 deliverable) +**Status:** Design — feeds Santiago's P4-7.1 on-chain event listening engine + +--- + +## Purpose + +This document defines which platform-contract events `dind` must subscribe to, +what each event means for the daemon's job queue, and where current event +coverage is insufficient. It is the handoff from the indexer design (P4-IDX1) +to the daemon implementation (P4-7.1). + +The indexer subgraph and the daemon share the same event surface. The subgraph +indexes events for historical queries and pagination; the daemon subscribes to +the same events in real time to trigger job execution without manual CLI +invocation. + +--- + +## 1. Subscription priority + +Events are grouped by how immediately the daemon must react. + +### 1.1 Immediate — daemon must act within the same block or next block + +| Event | Contract | Trigger | Daemon action | +|---|---|---|---| +| `ValidatorBlacklisted(address validator)` | DinValidatorStake | Validator address matches local operator | **Halt all active tasks immediately.** Stop any in-flight LMS, aggregation, or audit jobs. Alert operator via structured log at CRITICAL level. | +| `ModelDisabled(uint256 modelId)` | DINModelRegistry | modelId is one the local operator runs | Abort any in-flight work for that model. Remove model from active-participation list. Alert operator. | + +### 1.2 High-priority — daemon should react within a few blocks + +| Event | Contract | Trigger | Daemon action | +|---|---|---|---| +| `ValidatorSlashed(address validator, uint256 amount, bytes32 reason, address slasher)` | DinValidatorStake | Validator address matches local operator | Log slash with amount and reason. Recalculate active stake. If stake falls below `MIN_STAKE`, suspend new task registration until stake is topped up. | +| `ModelApproved(uint256 requestId, uint256 modelId)` | DINModelRegistry | requester matches local operator | Model registration confirmed. Cache the new modelId locally; the operator can now run `dincli model-owner gi start`. In future daemon automation, this triggers the GI-start workflow. | +| `ManifestUpdated(uint256 requestId, uint256 modelId, bytes32 newCID)` | DINModelRegistry | modelId is one the local operator participates in | Invalidate local manifest cache for that modelId. Re-fetch manifest from IPFS at the new CID before the next task action. | +| `ValidatorUnblacklisted(address validator)` | DinValidatorStake | Validator address matches local operator | Restore participation eligibility. Log at INFO level; do not auto-resume — operator must explicitly re-register for active tasks. | + +### 1.3 Informational — daemon should record and expose via `/health` or status API + +| Event | Contract | Notes | +|---|---|---| +| `ModelRegistrationRequested(uint256 requestId, address requester)` | DINModelRegistry | Relevant to DAO admin operators: new request pending review. Notify via log. | +| `ManifestUpdateRequested(uint256 requestId, uint256 modelId)` | DINModelRegistry | Same — DAO admin operators should be alerted. | +| `ModelRejected(uint256 requestId)` | DINModelRegistry | Relevant if local operator was the requester. | +| `ManifestUpdateRejected(uint256 requestId)` | DINModelRegistry | Same. | +| `ModelEnabled(uint256 modelId)` | DINModelRegistry | Model re-enabled after a kill-switch. Operator may choose to resume participation. | +| `ValidatorStaked(address validator, uint256 amount)` | DinValidatorStake | Useful for dashboard: record in local stake history. | +| `ValidatorUnstakeRequested(address validator, uint256 amount, uint64 withdrawAvailableAt)` | DinValidatorStake | Record in local unstake tracker. Daemon can surface `withdrawAvailableAt` countdown in `/health`. | +| `ValidatorWithdrawalClaimed(address validator, uint256 amount)` | DinValidatorStake | Record completion of withdrawal in local history. | +| `DinPerEthUpdated(uint256 newRate)` | DinCoordinator | Update local exchange-rate cache. Relevant for cost estimation in future reward projections. | +| `DAOAdminUpdated(address oldAdmin, address newAdmin)` | DINModelRegistry | Alert operator of governance-key rotation. Log at WARN level. | +| `EthDepositAndDINminted(address user, uint256 ethAmount, uint256 dinAmount)` | DinCoordinator | Relevant only if user matches local operator wallet. Record in local token history. | + +--- + +## 2. Event payload reference + +Full field inventory for events the daemon consumes. Includes current field +set and pending additions from the event-coverage audit. + +### DINModelRegistry + +``` +ModelRegistrationRequested( + uint256 indexed requestId, + address indexed requester + // PENDING: bool isOpenSource (audit §6.1) + // PENDING: uint256 fee (audit §6.1) +) + +ModelApproved( + uint256 indexed requestId, + uint256 indexed modelId +) + +ModelRejected( + uint256 indexed requestId +) + +ManifestUpdateRequested( + uint256 indexed requestId, + uint256 indexed modelId + // PENDING: address indexed requester (audit §6.2) +) + +ManifestUpdated( + uint256 indexed requestId, + uint256 indexed modelId, + bytes32 newCID +) + +ManifestUpdateRejected( + uint256 indexed requestId +) + +ModelDisabled(uint256 indexed modelId) +ModelEnabled(uint256 indexed modelId) + +DAOAdminUpdated( + address indexed oldAdmin, + address indexed newAdmin +) +``` + +### DinValidatorStake + +``` +ValidatorStaked( + address indexed validator, + uint256 amount +) + +ValidatorSlashed( + address indexed validator, + uint256 amount, + bytes32 indexed reason, + address indexed slasher +) + +ValidatorUnstakeRequested( + address indexed validator, + uint256 amount, + uint64 withdrawAvailableAt +) + +ValidatorWithdrawalClaimed( + address indexed validator, + uint256 amount +) + +ValidatorBlacklisted(address indexed validator) +ValidatorUnblacklisted(address indexed validator) +``` + +### DinCoordinator + +``` +EthDepositAndDINminted( + address indexed user, + uint256 ethAmount, + uint256 mintAmount +) + +DinPerEthUpdated(uint256 newRate) + +ValidatorStakeContractUpdated(address indexed validatorStakeContract) + +// PENDING: ETHTreasuryWithdrawn(address indexed to, uint256 amount) +// (audit §6.3 — not yet emitted by withdraw()) +``` + +--- + +## 3. Coverage gaps + +### 3.1 Task-level GI events — out of scope for platform subgraph + +P4-7.1 notes that the daemon must "at minimum wire: `T1AggregationStarted` → +queue aggregation job." None of the GI-lifecycle events exist on the four +platform contracts. They live on the task-level contracts `DINTaskCoordinator` +and `DINTaskAuditor`, which are out of scope for this subgraph (P5+). + +Events the daemon needs from task-level contracts once those are indexed: + +| Event | Contract | Daemon action | +|---|---|---| +| `DINaggregatorsRegistrationStarted` | DINTaskCoordinator | Queue aggregator registration job | +| `DINauditorsRegistrationStarted` | DINTaskCoordinator | Queue auditor registration job | +| `LMSstarted` | DINTaskCoordinator | Queue local model submission job | +| `AuditorsBatchesCreated` | DINTaskCoordinator | Trigger test data fetch for assigned auditor batch | +| `LMSevaluationStarted` | DINTaskCoordinator | Queue audit scoring job | +| `T1AggregationStarted` | DINTaskCoordinator | Queue T1 aggregation job (explicitly called out in P4-7.1) | +| `T2AggregationStarted` | DINTaskCoordinator | Queue T2 aggregation job | +| `GIended` | DINTaskCoordinator | Finalize round, archive local model artifacts | + +These events do not exist in the task contracts under these names today — the +`GIstates` enum drives state transitions but the state-change itself is not +emitted as an event with an indexed GI number. When task-level indexing is +designed (P5+), each `GIstate` transition should emit a dedicated event with +`uint256 indexed modelId` and `uint256 indexed gi` so the daemon can filter +by model and iteration without scanning all events. + +### 3.2 `ValidatorJailed` — dead code path + +`DinValidatorStake` has a `Jailed` status and a `jailedUntil` timestamp but no +function that sets `jailedUntil` and no `ValidatorJailed` event. When the +slashing/dispute spec (P3-4.x) lands a jailing mechanism, it must emit: + +``` +ValidatorJailed( + address indexed validator, + uint64 jailedUntil +) +``` + +Without this event the daemon cannot react to a jailing in real time — it +would only discover the status change on the next stake-related event for that +validator. + +### 3.3 `ETHTreasuryWithdrawn` — missing + +`DinCoordinator.withdraw()` emits no event. The daemon has no way to observe +ETH treasury outflows. Proposed addition documented in audit §6.3. + +--- + +## 4. Subscription mechanics for Santiago (P4-7.1) + +The daemon can subscribe to events by polling the JSON-RPC `eth_getLogs` +endpoint with the contract address and topic filters, or by using a WebSocket +connection (`eth_subscribe` with `logs` filter). + +Recommended approach for `dind` v1: +- **WebSocket `eth_subscribe` logs filter** per contract address — one + subscription per contract, four total. Lower latency than polling and + avoids block re-org edge cases that polling must handle explicitly. +- Maintain a local `lastProcessedBlock` per contract in the daemon's + persistent state store (SQLite or JSON per P4-1.1 choice) so subscriptions + can be replayed from the correct block on daemon restart. +- Filter by `address` + `topics[0]` (event selector). The Graph node itself + uses the same mechanism internally. + +The indexer subgraph handles historical state (indexer as the query layer); +the daemon handles real-time reaction (websocket subscriptions as the trigger +layer). The two paths are complementary — the daemon does not need to re-index +historical events, only respond to new ones. + +--- + +## 5. Integration order + +Per Umer (2026-07-09): dincli first, then SDK, then daemon — in that order. + +| Phase | Consumer | Integration point | +|---|---|---| +| P4-IDX2 (now) | `dincli` | Replace `dindao.py` RPC loop with GraphQL query to subgraph | +| P4-IDX3 | `dincli` test suite | Wire call site replacement into existing tests | +| P4-1.2 (SDK extraction) | `dincli/sdk/` | Event subscription helpers extracted from CLI internals | +| P4-7.1 | `dind` | Santiago implements listening engine using this schema as spec | From cf934d3914e8a84511c7e8c8d44098b4ecf038a2 Mon Sep 17 00:00:00 2001 From: robertocarlous Date: Sun, 12 Jul 2026 05:19:09 +0100 Subject: [PATCH 32/48] feat(indexer): add AssemblyScript mapping handlers for all platform contracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five source files covering all 30 event handlers declared in subgraph.yaml. src/utils.ts - eventId(): stable txHash-logIndex entity IDs for event-based records - loadOrCreateValidator(): initialises Validator aggregate on first encounter - syncValidatorStatus(): mirrors DinValidatorStake._syncValidatorStatus() to keep the Validator.status field in sync after every mutation src/registry.ts (DINModelRegistry — 15 handlers) - Registration request lifecycle: Requested → Approved / Rejected - Bridge for missing event fields: modelRequests(id) storage call populates isOpenSource, feePaid, manifestCID, taskCoordinator, taskAuditor until the audit §6.1/6.2 event additions land - Manifest update lifecycle: Requested → Updated / Rejected; requester derived from Model.owner as bridge until §6.2 addition lands - Kill switch: ModelDisabled / ModelEnabled update Model.disabled and emit ModelKillSwitchEvent - Fee snapshots: individual setters read full fee schedule from storage so every FeeSnapshot carries all four values; FeesUpdated reads from event params directly (atomic — no storage call needed) - FeesWithdrawn → RegistryFeeWithdrawal - DAOAdminUpdated → DAOAdminTransfer src/staking.ts (DinValidatorStake — 8 handlers) - ValidatorStaked / Slashed / UnstakeRequested / WithdrawalClaimed update the Validator aggregate and create the corresponding history entities - Slash handler mirrors contract's active-stake-first, then pending-withdrawal deduction logic for accurate aggregate tracking - Blacklisted / Unblacklisted flip Validator.blacklisted and re-sync status - SlasherContractAdded/Removed: canonical slasher-set records with sourceContract = DinValidatorStake address src/coordinator.ts (DinCoordinator — 5 handlers) - EthDepositAndDINminted → DINMintEvent - DinPerEthUpdated → ExchangeRateSnapshot - ValidatorStakeContractUpdated: intentional no-op (one-time deploy wiring) - SlasherContractAdded/Removed: audit-trail SlasherRegistration records with sourceContract = DinCoordinator address (distinct from staking.ts records) - ETHTreasuryWithdrawn handler stubbed and commented pending audit §6.3 src/token.ts (DinToken — 2 handlers) - TokensMinted → TokenMintEvent - Transfer → TokenTransferEvent (covers mints from 0x0 and normal transfers) --- subgraph/package.json | 16 ++ subgraph/src/coordinator.ts | 108 ++++++++++++++ subgraph/src/registry.ts | 285 ++++++++++++++++++++++++++++++++++++ subgraph/src/staking.ts | 169 +++++++++++++++++++++ subgraph/src/token.ts | 26 ++++ subgraph/src/utils.ts | 53 +++++++ 6 files changed, 657 insertions(+) create mode 100644 subgraph/package.json create mode 100644 subgraph/src/coordinator.ts create mode 100644 subgraph/src/registry.ts create mode 100644 subgraph/src/staking.ts create mode 100644 subgraph/src/token.ts create mode 100644 subgraph/src/utils.ts diff --git a/subgraph/package.json b/subgraph/package.json new file mode 100644 index 00000000..dc98799b --- /dev/null +++ b/subgraph/package.json @@ -0,0 +1,16 @@ +{ + "name": "din-protocol-subgraph", + "version": "0.1.0", + "description": "The Graph subgraph for DIN Protocol platform contracts", + "scripts": { + "codegen": "graph codegen", + "build": "graph build", + "create:local": "graph create --node http://localhost:8020/ din-protocol", + "deploy:local": "graph deploy --node http://localhost:8020/ --ipfs http://localhost:5001 din-protocol", + "remove:local": "graph remove --node http://localhost:8020/ din-protocol" + }, + "dependencies": { + "@graphprotocol/graph-cli": "^0.71.0", + "@graphprotocol/graph-ts": "^0.35.1" + } +} diff --git a/subgraph/src/coordinator.ts b/subgraph/src/coordinator.ts new file mode 100644 index 00000000..4a096783 --- /dev/null +++ b/subgraph/src/coordinator.ts @@ -0,0 +1,108 @@ +import { + EthDepositAndDINminted, + DinPerEthUpdated, + ValidatorStakeContractUpdated, + SlasherContractAdded, + SlasherContractRemoved, +} from "../generated/DinCoordinator/DinCoordinator" +import { + DINMintEvent, + ExchangeRateSnapshot, + SlasherRegistration, +} from "../generated/schema" +import { eventId } from "./utils" + +// ─── ETH deposits ───────────────────────────────────────────────────────────── + +export function handleEthDepositAndDINminted( + event: EthDepositAndDINminted +): void { + let e = new DINMintEvent(eventId(event.transaction.hash, event.logIndex)) + e.user = event.params.user + e.ethAmount = event.params.ethAmount + e.dinAmount = event.params.mintAmount + e.blockNumber = event.block.number + e.blockTimestamp = event.block.timestamp + e.transactionHash = event.transaction.hash + e.save() +} + +// ─── Exchange rate ──────────────────────────────────────────────────────────── + +export function handleDinPerEthUpdated(event: DinPerEthUpdated): void { + let s = new ExchangeRateSnapshot( + eventId(event.transaction.hash, event.logIndex) + ) + s.newRate = event.params.newRate + s.blockNumber = event.block.number + s.blockTimestamp = event.block.timestamp + s.transactionHash = event.transaction.hash + s.save() +} + +// ─── Validator stake contract pointer ──────────────────────────────────────── +// No schema entity for this — it is a one-time wiring step in the deployment +// sequence. Log at debug level so it is visible in graph-node output. + +export function handleValidatorStakeContractUpdated( + event: ValidatorStakeContractUpdated +): void { + // Intentionally a no-op at the entity level. + // The ValidatorStakeContractUpdated event records the address of the stake + // contract wired into DinCoordinator. Since this is a one-time deployment + // step (or extremely rare governance action), storing it in a dedicated + // entity adds noise without benefit. The graph-node debug log captures it. +} + +// ─── Slasher audit trail ────────────────────────────────────────────────────── +// +// DinCoordinator forwards addSlasherContract/removeSlasherContract to +// DinValidatorStake, causing both contracts to emit the same event. These +// handlers record the coordinator-sourced event as an audit-trail entry only. +// The canonical active-slasher set comes from staking.ts handlers. + +export function handleCoordinatorSlasherAdded( + event: SlasherContractAdded +): void { + let id = event.params.slasher.toHexString() + "-" + event.address.toHexString() + let s = new SlasherRegistration(id) + s.slasherAddress = event.params.slasher + s.sourceContract = event.address + s.active = true + s.addedAtBlock = event.block.number + s.addedAtTimestamp = event.block.timestamp + s.removedAtBlock = null + s.removedAtTimestamp = null + s.save() +} + +export function handleCoordinatorSlasherRemoved( + event: SlasherContractRemoved +): void { + let id = event.params.slasher.toHexString() + "-" + event.address.toHexString() + let s = SlasherRegistration.load(id) + if (s == null) return + s.active = false + s.removedAtBlock = event.block.number + s.removedAtTimestamp = event.block.timestamp + s.save() +} + +// ─── Treasury withdrawal ────────────────────────────────────────────────────── +// ETHTreasuryWithdrawn is not yet in the ABI (audit §6.3). The handler is +// kept here as a stub; uncomment and add the import once the event lands. + +// import { ETHTreasuryWithdrawn } from "../generated/DinCoordinator/DinCoordinator" +// import { TreasuryWithdrawal } from "../generated/schema" + +// export function handleETHTreasuryWithdrawn( +// event: ETHTreasuryWithdrawn +// ): void { +// let w = new TreasuryWithdrawal(eventId(event.transaction.hash, event.logIndex)) +// w.to = event.params.to +// w.amount = event.params.amount +// w.blockNumber = event.block.number +// w.blockTimestamp = event.block.timestamp +// w.transactionHash = event.transaction.hash +// w.save() +// } diff --git a/subgraph/src/registry.ts b/subgraph/src/registry.ts new file mode 100644 index 00000000..a31ba414 --- /dev/null +++ b/subgraph/src/registry.ts @@ -0,0 +1,285 @@ +import { BigInt } from "@graphprotocol/graph-ts" +import { + ModelRegistrationRequested, + ModelApproved, + ModelRejected, + ManifestUpdateRequested, + ManifestUpdated, + ManifestUpdateRejected, + ModelDisabled, + ModelEnabled, + OpenSourceFeeUpdated, + ProprietaryFeeUpdated, + OpenSourceUpdateFeeUpdated, + ProprietaryUpdateFeeUpdated, + FeesUpdated, + FeesWithdrawn, + DAOAdminUpdated, + DINModelRegistry, +} from "../generated/DINModelRegistry/DINModelRegistry" +import { + ModelRegistrationRequest, + Model, + ManifestUpdateRequest, + ModelKillSwitchEvent, + FeeSnapshot, + RegistryFeeWithdrawal, + DAOAdminTransfer, +} from "../generated/schema" +import { eventId } from "./utils" + +// ─── Registration request lifecycle ────────────────────────────────────────── + +export function handleModelRegistrationRequested( + event: ModelRegistrationRequested +): void { + let id = event.params.requestId.toString() + let req = new ModelRegistrationRequest(id) + + req.requestId = event.params.requestId + req.requester = event.params.requester + + // isOpenSource and fee are not in the event until audit §6.1 addition lands. + // Bridge: read from contract storage at this block. + let registry = DINModelRegistry.bind(event.address) + let stored = registry.modelRequests(event.params.requestId) + req.isOpenSource = stored.getIsOpenSource() + req.feePaid = stored.getFeePaid() + req.manifestCID = stored.getManifestCID() + req.taskCoordinator = stored.getTaskCoordinator() + req.taskAuditor = stored.getTaskAuditor() + + req.processed = false + req.approved = null + req.model = null + + req.submittedAtBlock = event.block.number + req.submittedAtTimestamp = event.block.timestamp + req.processedAtBlock = null + req.processedAtTimestamp = null + + req.save() +} + +export function handleModelApproved(event: ModelApproved): void { + let reqId = event.params.requestId.toString() + let req = ModelRegistrationRequest.load(reqId) + if (req == null) return + + req.processed = true + req.approved = true + req.processedAtBlock = event.block.number + req.processedAtTimestamp = event.block.timestamp + + let modelId = event.params.modelId.toString() + req.model = modelId + req.save() + + // Build the approved Model entity from storage — all fields are available + // via getModel() at this block without a separate event. + let registry = DINModelRegistry.bind(event.address) + let stored = registry.getModel(event.params.modelId) + + let model = new Model(modelId) + model.modelId = event.params.modelId + model.owner = stored.getOwner() + model.isOpenSource = stored.getIsOpenSource() + model.manifestCID = stored.getManifestCID() + model.taskCoordinator = stored.getTaskCoordinator() + model.taskAuditor = stored.getTaskAuditor() + model.disabled = false + model.registrationRequest = reqId + model.createdAtBlock = event.block.number + model.createdAtTimestamp = event.block.timestamp + model.save() +} + +export function handleModelRejected(event: ModelRejected): void { + let req = ModelRegistrationRequest.load(event.params.requestId.toString()) + if (req == null) return + req.processed = true + req.approved = false + req.processedAtBlock = event.block.number + req.processedAtTimestamp = event.block.timestamp + req.save() +} + +// ─── Manifest update lifecycle ──────────────────────────────────────────────── + +export function handleManifestUpdateRequested( + event: ManifestUpdateRequested +): void { + let id = event.params.requestId.toString() + let mur = new ManifestUpdateRequest(id) + + mur.requestId = event.params.requestId + mur.model = event.params.modelId.toString() + + // requester is not in the event until audit §6.2 addition lands. + // Bridge: derive from Model.owner — only the model owner can call + // requestManifestUpdate (onlyModelOwner modifier). + let model = Model.load(event.params.modelId.toString()) + mur.requester = model != null ? model.owner : event.transaction.from + + // Read fee and CID from storage. + let registry = DINModelRegistry.bind(event.address) + let stored = registry.manifestRequests(event.params.requestId) + mur.feePaid = stored.getFeePaid() + mur.newManifestCID = stored.getNewManifestCID() + + mur.processed = false + mur.approved = null + + mur.submittedAtBlock = event.block.number + mur.submittedAtTimestamp = event.block.timestamp + mur.processedAtBlock = null + mur.processedAtTimestamp = null + + mur.save() +} + +export function handleManifestUpdated(event: ManifestUpdated): void { + let mur = ManifestUpdateRequest.load(event.params.requestId.toString()) + if (mur != null) { + mur.processed = true + mur.approved = true + mur.processedAtBlock = event.block.number + mur.processedAtTimestamp = event.block.timestamp + mur.save() + } + + let model = Model.load(event.params.modelId.toString()) + if (model != null) { + model.manifestCID = event.params.newCID + model.save() + } +} + +export function handleManifestUpdateRejected( + event: ManifestUpdateRejected +): void { + let mur = ManifestUpdateRequest.load(event.params.requestId.toString()) + if (mur == null) return + mur.processed = true + mur.approved = false + mur.processedAtBlock = event.block.number + mur.processedAtTimestamp = event.block.timestamp + mur.save() +} + +// ─── Kill switch ────────────────────────────────────────────────────────────── + +export function handleModelDisabled(event: ModelDisabled): void { + _applyKillSwitch(event.params.modelId, true, event) +} + +export function handleModelEnabled(event: ModelEnabled): void { + _applyKillSwitch(event.params.modelId, false, event) +} + +function _applyKillSwitch( + modelId: BigInt, + disabled: boolean, + event: ModelDisabled | ModelEnabled +): void { + let model = Model.load(modelId.toString()) + if (model != null) { + model.disabled = disabled + model.save() + } + + let ks = new ModelKillSwitchEvent( + eventId(event.transaction.hash, event.logIndex) + ) + ks.model = modelId.toString() + ks.disabled = disabled + ks.blockNumber = event.block.number + ks.blockTimestamp = event.block.timestamp + ks.transactionHash = event.transaction.hash + ks.save() +} + +// ─── Fee governance ─────────────────────────────────────────────────────────── + +// For the four individual setters we read the full current fee schedule from +// storage so each FeeSnapshot always carries a complete picture. + +export function handleOpenSourceFeeUpdated( + event: OpenSourceFeeUpdated +): void { + _saveGranularFeeSnapshot(event.address, "granular", event) +} + +export function handleProprietaryFeeUpdated( + event: ProprietaryFeeUpdated +): void { + _saveGranularFeeSnapshot(event.address, "granular", event) +} + +export function handleOpenSourceUpdateFeeUpdated( + event: OpenSourceUpdateFeeUpdated +): void { + _saveGranularFeeSnapshot(event.address, "granular", event) +} + +export function handleProprietaryUpdateFeeUpdated( + event: ProprietaryUpdateFeeUpdated +): void { + _saveGranularFeeSnapshot(event.address, "granular", event) +} + +function _saveGranularFeeSnapshot( + contractAddress: import("@graphprotocol/graph-ts").Address, + kind: string, + event: OpenSourceFeeUpdated | ProprietaryFeeUpdated | OpenSourceUpdateFeeUpdated | ProprietaryUpdateFeeUpdated +): void { + let registry = DINModelRegistry.bind(contractAddress) + let snap = new FeeSnapshot(eventId(event.transaction.hash, event.logIndex)) + snap.openSourceFee = registry.openSourceFee() + snap.proprietaryFee = registry.proprietaryFee() + snap.openSourceUpdateFee = registry.openSourceUpdateFee() + snap.proprietaryUpdateFee = registry.proprietaryUpdateFee() + snap.updateKind = kind + snap.blockNumber = event.block.number + snap.blockTimestamp = event.block.timestamp + snap.transactionHash = event.transaction.hash + snap.save() +} + +// FeesUpdated carries all four new values directly — no storage call needed. +export function handleFeesUpdated(event: FeesUpdated): void { + let snap = new FeeSnapshot(eventId(event.transaction.hash, event.logIndex)) + snap.openSourceFee = event.params.openSourceFee + snap.proprietaryFee = event.params.proprietaryFee + snap.openSourceUpdateFee = event.params.openSourceUpdateFee + snap.proprietaryUpdateFee = event.params.proprietaryUpdateFee + snap.updateKind = "atomic" + snap.blockNumber = event.block.number + snap.blockTimestamp = event.block.timestamp + snap.transactionHash = event.transaction.hash + snap.save() +} + +export function handleFeesWithdrawn(event: FeesWithdrawn): void { + let w = new RegistryFeeWithdrawal( + eventId(event.transaction.hash, event.logIndex) + ) + w.to = event.params.to + w.amount = event.params.amount + w.blockNumber = event.block.number + w.blockTimestamp = event.block.timestamp + w.transactionHash = event.transaction.hash + w.save() +} + +export function handleDAOAdminUpdated(event: DAOAdminUpdated): void { + let t = new DAOAdminTransfer( + eventId(event.transaction.hash, event.logIndex) + ) + t.oldAdmin = event.params.oldAdmin + t.newAdmin = event.params.newAdmin + t.blockNumber = event.block.number + t.blockTimestamp = event.block.timestamp + t.transactionHash = event.transaction.hash + t.save() +} diff --git a/subgraph/src/staking.ts b/subgraph/src/staking.ts new file mode 100644 index 00000000..9d896c8e --- /dev/null +++ b/subgraph/src/staking.ts @@ -0,0 +1,169 @@ +import { BigInt, Bytes } from "@graphprotocol/graph-ts" +import { + ValidatorStaked, + ValidatorSlashed, + ValidatorUnstakeRequested, + ValidatorWithdrawalClaimed, + ValidatorBlacklisted, + ValidatorUnblacklisted, + SlasherContractAdded, + SlasherContractRemoved, +} from "../generated/DinValidatorStake/DinValidatorStake" +import { + StakeEvent, + SlashEvent, + UnstakeRequest, + WithdrawalClaim, + SlasherRegistration, +} from "../generated/schema" +import { eventId, loadOrCreateValidator, syncValidatorStatus } from "./utils" + +// ─── Stake ──────────────────────────────────────────────────────────────────── + +export function handleValidatorStaked(event: ValidatorStaked): void { + let v = loadOrCreateValidator(event.params.validator) + v.activeStake = v.activeStake.plus(event.params.amount) + v.totalStaked = v.totalStaked.plus(event.params.amount) + syncValidatorStatus(v) + v.save() + + let e = new StakeEvent(eventId(event.transaction.hash, event.logIndex)) + e.validator = event.params.validator.toHexString() + e.amount = event.params.amount + e.blockNumber = event.block.number + e.blockTimestamp = event.block.timestamp + e.transactionHash = event.transaction.hash + e.save() +} + +// ─── Slash ──────────────────────────────────────────────────────────────────── + +export function handleValidatorSlashed(event: ValidatorSlashed): void { + let v = loadOrCreateValidator(event.params.validator) + let amount = event.params.amount + + // Mirror DinValidatorStake.slash(): active stake is consumed first, then + // pending withdrawals. + if (v.activeStake.ge(amount)) { + v.activeStake = v.activeStake.minus(amount) + } else { + let remainder = amount.minus(v.activeStake) + v.activeStake = BigInt.zero() + v.pendingWithdrawal = v.pendingWithdrawal.gt(remainder) + ? v.pendingWithdrawal.minus(remainder) + : BigInt.zero() + if (v.pendingWithdrawal.equals(BigInt.zero())) { + v.withdrawAvailableAt = null + } + } + + v.totalSlashed = v.totalSlashed.plus(amount) + syncValidatorStatus(v) + v.save() + + let e = new SlashEvent(eventId(event.transaction.hash, event.logIndex)) + e.validator = event.params.validator.toHexString() + e.amount = amount + e.reason = event.params.reason + e.slasherContract = event.params.slasher + e.blockNumber = event.block.number + e.blockTimestamp = event.block.timestamp + e.transactionHash = event.transaction.hash + e.save() +} + +// ─── Unstake ────────────────────────────────────────────────────────────────── + +export function handleValidatorUnstakeRequested( + event: ValidatorUnstakeRequested +): void { + let v = loadOrCreateValidator(event.params.validator) + v.activeStake = v.activeStake.minus(event.params.amount) + v.pendingWithdrawal = event.params.amount + v.withdrawAvailableAt = BigInt.fromI64( + event.params.withdrawAvailableAt as i64 + ) + syncValidatorStatus(v) + v.save() + + let id = eventId(event.transaction.hash, event.logIndex) + let u = new UnstakeRequest(id) + u.validator = event.params.validator.toHexString() + u.amount = event.params.amount + u.withdrawAvailableAt = BigInt.fromI64( + event.params.withdrawAvailableAt as i64 + ) + u.claimed = false + u.blockNumber = event.block.number + u.blockTimestamp = event.block.timestamp + u.transactionHash = event.transaction.hash + u.save() +} + +export function handleValidatorWithdrawalClaimed( + event: ValidatorWithdrawalClaimed +): void { + let v = loadOrCreateValidator(event.params.validator) + v.totalWithdrawn = v.totalWithdrawn.plus(event.params.amount) + v.pendingWithdrawal = BigInt.zero() + v.withdrawAvailableAt = null + syncValidatorStatus(v) + v.save() + + let w = new WithdrawalClaim(eventId(event.transaction.hash, event.logIndex)) + w.validator = event.params.validator.toHexString() + w.amount = event.params.amount + w.blockNumber = event.block.number + w.blockTimestamp = event.block.timestamp + w.transactionHash = event.transaction.hash + w.save() +} + +// ─── Blacklist ──────────────────────────────────────────────────────────────── + +export function handleValidatorBlacklisted(event: ValidatorBlacklisted): void { + let v = loadOrCreateValidator(event.params.validator) + v.blacklisted = true + syncValidatorStatus(v) + v.save() +} + +export function handleValidatorUnblacklisted( + event: ValidatorUnblacklisted +): void { + let v = loadOrCreateValidator(event.params.validator) + v.blacklisted = false + syncValidatorStatus(v) + v.save() +} + +// ─── Slasher registry ───────────────────────────────────────────────────────── +// +// DinValidatorStake is the canonical source of the active slasher set. +// The entity ID encodes both the slasher address and the source contract so +// it stays distinct from the DinCoordinator audit-trail records. + +export function handleSlasherContractAdded(event: SlasherContractAdded): void { + let id = event.params.slasher.toHexString() + "-" + event.address.toHexString() + let s = new SlasherRegistration(id) + s.slasherAddress = event.params.slasher + s.sourceContract = event.address + s.active = true + s.addedAtBlock = event.block.number + s.addedAtTimestamp = event.block.timestamp + s.removedAtBlock = null + s.removedAtTimestamp = null + s.save() +} + +export function handleSlasherContractRemoved( + event: SlasherContractRemoved +): void { + let id = event.params.slasher.toHexString() + "-" + event.address.toHexString() + let s = SlasherRegistration.load(id) + if (s == null) return + s.active = false + s.removedAtBlock = event.block.number + s.removedAtTimestamp = event.block.timestamp + s.save() +} diff --git a/subgraph/src/token.ts b/subgraph/src/token.ts new file mode 100644 index 00000000..2c6e23f6 --- /dev/null +++ b/subgraph/src/token.ts @@ -0,0 +1,26 @@ +import { TokensMinted, Transfer } from "../generated/DinToken/DinToken" +import { TokenMintEvent, TokenTransferEvent } from "../generated/schema" +import { eventId } from "./utils" + +export function handleTokensMinted(event: TokensMinted): void { + let e = new TokenMintEvent(eventId(event.transaction.hash, event.logIndex)) + e.to = event.params.to + e.amount = event.params.amount + e.blockNumber = event.block.number + e.blockTimestamp = event.block.timestamp + e.transactionHash = event.transaction.hash + e.save() +} + +// Standard ERC20 Transfer — covers mints (from = 0x0) and normal transfers. +// Burns are not possible in DinToken but the handler is intentionally general. +export function handleTransfer(event: Transfer): void { + let e = new TokenTransferEvent(eventId(event.transaction.hash, event.logIndex)) + e.from = event.params.from + e.to = event.params.to + e.amount = event.params.value + e.blockNumber = event.block.number + e.blockTimestamp = event.block.timestamp + e.transactionHash = event.transaction.hash + e.save() +} diff --git a/subgraph/src/utils.ts b/subgraph/src/utils.ts new file mode 100644 index 00000000..383a8bc2 --- /dev/null +++ b/subgraph/src/utils.ts @@ -0,0 +1,53 @@ +import { BigInt, Bytes } from "@graphprotocol/graph-ts" +import { Validator } from "../generated/schema" + +export const MIN_STAKE = BigInt.fromString("10000000000000000000") // 10 DIN (18 decimals) + +/** Stable entity ID for event-based entities: txHash-logIndex. */ +export function eventId(txHash: Bytes, logIndex: BigInt): string { + return txHash.toHex() + "-" + logIndex.toString() +} + +/** Load a Validator aggregate entity, initialising it on first encounter. */ +export function loadOrCreateValidator(address: Bytes): Validator { + let id = address.toHexString() + let v = Validator.load(id) + if (v == null) { + v = new Validator(id) + v.address = address + v.activeStake = BigInt.zero() + v.pendingWithdrawal = BigInt.zero() + v.withdrawAvailableAt = null + v.totalStaked = BigInt.zero() + v.totalSlashed = BigInt.zero() + v.totalWithdrawn = BigInt.zero() + v.status = "None" + v.blacklisted = false + } + return v! +} + +/** + * Derive the validator status string from the aggregate's current field + * values, mirroring DinValidatorStake._syncValidatorStatus(). + * Called after every mutation that can change stake or withdrawal state. + */ +export function syncValidatorStatus(v: Validator): void { + if (v.blacklisted) { + v.status = "Blacklisted" + return + } + if (v.pendingWithdrawal.gt(BigInt.zero())) { + v.status = "Exiting" + return + } + if (v.activeStake.ge(MIN_STAKE)) { + v.status = "Active" + return + } + if (v.activeStake.gt(BigInt.zero())) { + v.status = "Exiting" + return + } + v.status = "None" +} From b47ff92bc7b857069b53502b92326648ac1015c1 Mon Sep 17 00:00:00 2001 From: robertocarlous Date: Sun, 12 Jul 2026 05:19:59 +0100 Subject: [PATCH 33/48] feat(indexer): add local Graph node docker-compose stack (P4-IDX2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runs graph-node, IPFS (kubo), and Postgres against the local anvil chain (chainId 1153 1337, network: din-local) for subgraph development and testing. Services: graph-node graphprotocol/graph-node:v0.34.1 - ports 8000 (HTTP), 8001 (WS), 8020 (deploy), 8030 (status), 8040 (metrics) - ethereum env var maps din-local → host.docker.internal:8545 - GRAPH_ETHEREUM_TARGET_TRIGGERS_PER_BLOCK_RANGE=1 for on-demand anvil blocks ipfs ipfs/kubo:v0.27.0 - port 5001 (API); used by graph-node and graph deploy postgres postgres:16-alpine - POSTGRES_INITDB_ARGS sets UTF8/C locale required by graph-node TOAST Both ipfs and postgres have healthchecks; graph-node depends_on postgres with condition: service_healthy so it waits for the DB to be ready before attempting to connect. .env.example documents the ETHEREUM_RPC and Postgres credential overrides. Linux users must replace host.docker.internal with 172.17.0.1. Startup sequence is documented in the docker-compose.yml header comment. --- subgraph/.env.example | 11 +++++ subgraph/docker-compose.yml | 97 +++++++++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+) create mode 100644 subgraph/.env.example create mode 100644 subgraph/docker-compose.yml diff --git a/subgraph/.env.example b/subgraph/.env.example new file mode 100644 index 00000000..27d551b0 --- /dev/null +++ b/subgraph/.env.example @@ -0,0 +1,11 @@ +# Copy to .env and adjust if your local setup differs from the defaults. +# docker-compose.yml reads these values at container start time. + +# RPC endpoint for the local anvil/hardhat chain (chainId 1337). +# On Linux, replace host.docker.internal with the Docker bridge IP: 172.17.0.1 +ETHEREUM_RPC=http://host.docker.internal:8545 + +# Postgres credentials — change before any non-local deployment. +POSTGRES_USER=graph-node +POSTGRES_PASSWORD=din-graph-local +POSTGRES_DB=graph-node diff --git a/subgraph/docker-compose.yml b/subgraph/docker-compose.yml new file mode 100644 index 00000000..e0452e04 --- /dev/null +++ b/subgraph/docker-compose.yml @@ -0,0 +1,97 @@ +# DIN Protocol — local Graph node stack +# +# Runs a self-hosted graph-node, IPFS, and Postgres against the local anvil +# chain (chainId 1337) for subgraph development and testing. +# +# Quick start +# ─────────── +# 1. Start the local anvil chain first: +# ./foundry/anvil.sh (from repo root) +# +# 2. Deploy the platform contracts and copy their addresses into +# subgraph.yaml (replace the 0x0000...0001–0004 placeholders): +# cd hardhat && npx hardhat run scripts/deploy.ts --network localhost +# +# 3. Start the Graph node stack: +# cd subgraph && docker compose up -d +# +# 4. Wait ~10 s for graph-node to connect to Postgres and IPFS, then +# create and deploy the subgraph: +# npm run codegen +# npm run build +# npm run create:local +# npm run deploy:local +# +# 5. Query at http://localhost:8000/subgraphs/name/din-protocol/graphql +# +# Teardown +# ──────── +# docker compose down -v # removes containers AND volumes (fresh slate) +# docker compose down # stops containers, keeps indexed data + +services: + + graph-node: + image: graphprotocol/graph-node:v0.34.1 + ports: + - "8000:8000" # GraphQL HTTP endpoint + - "8001:8001" # GraphQL WebSocket endpoint + - "8020:8020" # Admin / subgraph deploy RPC + - "8030:8030" # Index-node status API + - "8040:8040" # Prometheus metrics + depends_on: + postgres: + condition: service_healthy + ipfs: + condition: service_started + environment: + postgres_host: postgres + postgres_user: graph-node + postgres_pass: din-graph-local + postgres_db: graph-node + ipfs: "ipfs:5001" + # din-local must match the network name in subgraph.yaml. + # host.docker.internal resolves to the host on Docker Desktop (macOS/Win). + # On Linux replace with the Docker bridge IP: 172.17.0.1 + ethereum: "din-local:http://host.docker.internal:8545" + GRAPH_LOG: info + # Allow the graph-node to connect to a chain that has not finalised + # many blocks (local anvil produces blocks on demand). + GRAPH_ETHEREUM_TARGET_TRIGGERS_PER_BLOCK_RANGE: "1" + restart: unless-stopped + + ipfs: + image: ipfs/kubo:v0.27.0 + ports: + - "5001:5001" # IPFS API (used by graph-node and graph deploy) + volumes: + - ipfs-data:/data/ipfs + healthcheck: + test: ["CMD", "ipfs", "id"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + + postgres: + image: postgres:16-alpine + ports: + - "5432:5432" + environment: + POSTGRES_USER: graph-node + POSTGRES_PASSWORD: din-graph-local + POSTGRES_DB: graph-node + # Required by graph-node to use TOAST compression for large bytea values. + POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C" + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U graph-node -d graph-node"] + interval: 5s + timeout: 5s + retries: 10 + restart: unless-stopped + +volumes: + ipfs-data: + postgres-data: From 39433d6e9974557aca04391ce4486ab8aafad33b Mon Sep 17 00:00:00 2001 From: robertocarlous Date: Sun, 12 Jul 2026 05:29:38 +0100 Subject: [PATCH 34/48] feat(indexer): replace dindao RPC loops with GraphQL queries (P4-IDX3) Replace the two for-idx-in-range enumeration loops in list-pending-requests with GraphQL queries to the local subgraph, with a silent fallback to the original RPC loops when the graph-node endpoint is unavailable. Add unit tests covering the GraphQL happy path and all three fallback triggers (connection error, GraphQL error body, HTTP 5xx). --- dincli/cli/dinrep.py | 76 +++++++++++-- tests/test_list_pending_requests.py | 165 ++++++++++++++++++++++++++++ 2 files changed, 229 insertions(+), 12 deletions(-) create mode 100644 tests/test_list_pending_requests.py diff --git a/dincli/cli/dinrep.py b/dincli/cli/dinrep.py index c7cb9a09..2e4030ba 100644 --- a/dincli/cli/dinrep.py +++ b/dincli/cli/dinrep.py @@ -1,12 +1,36 @@ import os from importlib.resources import files +import requests as _requests import typer from dincli.cli.contract_utils import get_contract_instance from dincli.cli.utils import (build_and_send_tx, get_env_key, resolve_task_coordinator_address) +_SUBGRAPH_URL = os.environ.get( + "DIN_SUBGRAPH_URL", + "http://localhost:8000/subgraphs/name/din-protocol/graphql", +) + + +def _query_subgraph(query: str, *, timeout: int = 5) -> dict | None: + """POST a GraphQL query to the local subgraph. Returns the data dict or None on any error.""" + try: + resp = _requests.post( + _SUBGRAPH_URL, + json={"query": query}, + timeout=timeout, + ) + resp.raise_for_status() + body = resp.json() + if "errors" in body: + return None + return body.get("data") + except Exception: + return None + + app = typer.Typer(help="Commands for the DIN-Representative") registry_app = typer.Typer(help="Registry sub-app ('dincli dinrep registry ...') to interact with DINModelRegistry") @@ -380,31 +404,59 @@ def list_pending_requests(ctx: typer.Context, req_type: str = typer.Option(None, if normalized_type in (None, "model"): console.print("[bold cyan]Pending Model Registration Requests:[/bold cyan]") - totalModelRequests = DINModelRegistry_Contract.functions.totalModelRequests().call() found_model = False - for idx in range(totalModelRequests): - req = DINModelRegistry_Contract.functions.modelRequests(idx).call() - if not req[6]: + gql_data = _query_subgraph( + "{ modelRegistrationRequests(where: { processed: false }, first: 1000)" + " { requestId requester isOpenSource feePaid } }" + ) + if gql_data is not None: + for r in gql_data.get("modelRegistrationRequests", []): console.print( - f" [green]Request ID {idx}[/green] - Requester: {req[0]}, " - f"Open Source: {req[1]}, Fee: {w3.from_wei(req[5], 'ether')} ETH" + f" [green]Request ID {r['requestId']}[/green] - " + f"Requester: {r['requester']}, " + f"Open Source: {r['isOpenSource']}, " + f"Fee: {w3.from_wei(int(r['feePaid']), 'ether')} ETH" ) found_model = True + else: + totalModelRequests = DINModelRegistry_Contract.functions.totalModelRequests().call() + for idx in range(totalModelRequests): + req = DINModelRegistry_Contract.functions.modelRequests(idx).call() + if not req[6]: + console.print( + f" [green]Request ID {idx}[/green] - Requester: {req[0]}, " + f"Open Source: {req[1]}, Fee: {w3.from_wei(req[5], 'ether')} ETH" + ) + found_model = True if not found_model: console.print(" [gray]No pending model registration requests[/gray]") if normalized_type in (None, "manifest"): console.print("[bold cyan]Pending Manifest Update Requests:[/bold cyan]") - totalManifestRequests = DINModelRegistry_Contract.functions.totalManifestRequests().call() found_manifest = False - for idx in range(totalManifestRequests): - req = DINModelRegistry_Contract.functions.manifestRequests(idx).call() - if not req[4]: + gql_data = _query_subgraph( + "{ manifestUpdateRequests(where: { processed: false }, first: 1000)" + " { requestId model { modelId } requester feePaid } }" + ) + if gql_data is not None: + for r in gql_data.get("manifestUpdateRequests", []): console.print( - f" [green]Request ID {idx}[/green] - Model ID: {req[0]}, " - f"Requester: {req[2]}, Fee: {w3.from_wei(req[3], 'ether')} ETH" + f" [green]Request ID {r['requestId']}[/green] - " + f"Model ID: {r['model']['modelId']}, " + f"Requester: {r['requester']}, " + f"Fee: {w3.from_wei(int(r['feePaid']), 'ether')} ETH" ) found_manifest = True + else: + totalManifestRequests = DINModelRegistry_Contract.functions.totalManifestRequests().call() + for idx in range(totalManifestRequests): + req = DINModelRegistry_Contract.functions.manifestRequests(idx).call() + if not req[4]: + console.print( + f" [green]Request ID {idx}[/green] - Model ID: {req[0]}, " + f"Requester: {req[2]}, Fee: {w3.from_wei(req[3], 'ether')} ETH" + ) + found_manifest = True if not found_manifest: console.print(" [gray]No pending manifest update requests[/gray]") diff --git a/tests/test_list_pending_requests.py b/tests/test_list_pending_requests.py new file mode 100644 index 00000000..f9a87b40 --- /dev/null +++ b/tests/test_list_pending_requests.py @@ -0,0 +1,165 @@ +""" +Unit tests for the GraphQL integration in dindao list-pending-requests. + +Covers: + - GraphQL path: model registration requests returned from subgraph + - GraphQL path: manifest update requests returned from subgraph + - Fallback to RPC when the subgraph endpoint is unreachable + - Fallback to RPC when the subgraph returns a GraphQL error body +""" + +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +import dincli.cli.dindao as dindao_module + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_response(data: dict | None = None, errors: list | None = None, status: int = 200): + """Build a minimal requests.Response-like mock.""" + body: dict = {} + if errors is not None: + body["errors"] = errors + elif data is not None: + body["data"] = data + resp = MagicMock() + resp.status_code = status + resp.raise_for_status = MagicMock( + side_effect=None if status < 400 else Exception(f"HTTP {status}") + ) + resp.json = MagicMock(return_value=body) + return resp + + +class _DummyW3: + def from_wei(self, amount: int, unit: str) -> str: + assert unit == "ether" + return str(amount / 10**18) + + +def _make_ctx(w3=None): + """Minimal ctx.obj that satisfies the command's usage.""" + w3 = w3 or _DummyW3() + console_messages: list = [] + + class _Console: + def print(self, msg, *args, **kwargs): + console_messages.append(msg) + + registry_contract = MagicMock() + # Default: no requests on-chain (used only in fallback paths) + registry_contract.functions.totalModelRequests.return_value.call.return_value = 0 + registry_contract.functions.totalManifestRequests.return_value.call.return_value = 0 + + ctx_obj = MagicMock() + ctx_obj.get_en_w3_account_console.return_value = ("local", w3, MagicMock(), _Console()) + ctx_obj.get_deployed_din_registry_contract.return_value = registry_contract + + ctx = MagicMock() + ctx.obj = ctx_obj + return ctx, console_messages, registry_contract + + +# --------------------------------------------------------------------------- +# GraphQL — model registration requests +# --------------------------------------------------------------------------- + + +def test_graphql_model_path(): + ctx, msgs, registry = _make_ctx() + fake_data = { + "modelRegistrationRequests": [ + {"requestId": "0", "requester": "0xABCD", "isOpenSource": True, "feePaid": "5000000000000000000"}, + {"requestId": "1", "requester": "0xDEAD", "isOpenSource": False, "feePaid": "10000000000000000000"}, + ] + } + with patch.object(dindao_module._requests, "post", return_value=_make_response(fake_data)): + dindao_module.list_pending_requests.callback(ctx, req_type="model") + + output = "\n".join(str(m) for m in msgs) + assert "Request ID 0" in output + assert "0xABCD" in output + assert "Request ID 1" in output + assert "0xDEAD" in output + # RPC must NOT have been called when GraphQL succeeded + registry.functions.totalModelRequests.assert_not_called() + + +def test_graphql_model_path_empty(): + ctx, msgs, registry = _make_ctx() + fake_data = {"modelRegistrationRequests": []} + with patch.object(dindao_module._requests, "post", return_value=_make_response(fake_data)): + dindao_module.list_pending_requests.callback(ctx, req_type="model") + + output = "\n".join(str(m) for m in msgs) + assert "No pending model registration requests" in output + registry.functions.totalModelRequests.assert_not_called() + + +# --------------------------------------------------------------------------- +# GraphQL — manifest update requests +# --------------------------------------------------------------------------- + + +def test_graphql_manifest_path(): + ctx, msgs, registry = _make_ctx() + fake_data = { + "manifestUpdateRequests": [ + { + "requestId": "0", + "model": {"modelId": "3"}, + "requester": "0xCAFE", + "feePaid": "1000000000000000000", + } + ] + } + with patch.object(dindao_module._requests, "post", return_value=_make_response(fake_data)): + dindao_module.list_pending_requests.callback(ctx, req_type="manifest") + + output = "\n".join(str(m) for m in msgs) + assert "Request ID 0" in output + assert "Model ID: 3" in output + assert "0xCAFE" in output + registry.functions.totalManifestRequests.assert_not_called() + + +# --------------------------------------------------------------------------- +# Fallback — connection error +# --------------------------------------------------------------------------- + + +def test_rpc_fallback_on_connection_error(): + ctx, msgs, registry = _make_ctx() + # Subgraph unreachable + with patch.object( + dindao_module._requests, "post", side_effect=ConnectionError("refused") + ): + dindao_module.list_pending_requests.callback(ctx, req_type="model") + + # RPC loop must have been invoked + registry.functions.totalModelRequests.return_value.call.assert_called_once() + + +def test_rpc_fallback_on_graphql_error_body(): + ctx, msgs, registry = _make_ctx() + error_resp = _make_response(errors=[{"message": "subgraph not found"}]) + with patch.object(dindao_module._requests, "post", return_value=error_resp): + dindao_module.list_pending_requests.callback(ctx, req_type="model") + + # GraphQL returned errors — must fall through to RPC + registry.functions.totalModelRequests.return_value.call.assert_called_once() + + +def test_rpc_fallback_on_http_error(): + ctx, msgs, registry = _make_ctx() + bad_resp = _make_response(status=503) + with patch.object(dindao_module._requests, "post", return_value=bad_resp): + dindao_module.list_pending_requests.callback(ctx, req_type="model") + + registry.functions.totalModelRequests.return_value.call.assert_called_once() From 9ca158d48c4b6ec1ce7ea6eeebc70f21e6aed7ba Mon Sep 17 00:00:00 2001 From: robertocarlous Date: Sun, 12 Jul 2026 05:29:45 +0100 Subject: [PATCH 35/48] docs(indexer): add GraphQL integration handoff notes (P4-IDX3) Documents the integration point, GraphQL queries used, fallback behaviour, DIN_SUBGRAPH_URL config, and follow-up RPC loop candidates in lms.py and aggregation.py. --- subgraph/docs/handoff.md | 96 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 subgraph/docs/handoff.md diff --git a/subgraph/docs/handoff.md b/subgraph/docs/handoff.md new file mode 100644 index 00000000..b5fba813 --- /dev/null +++ b/subgraph/docs/handoff.md @@ -0,0 +1,96 @@ +# DIN Indexer — GraphQL integration handoff + +## What was done + +The `dindao registry list-pending-requests` command (`dincli/cli/dindao.py`) previously +enumerated pending model and manifest requests by calling `totalModelRequests()` / +`totalManifestRequests()` on-chain and iterating through every index with an individual +RPC call per entry. Those two `for idx in range(...)` loops have been replaced with +GraphQL queries to the locally-running subgraph. + +### Integration point + +| File | Location | Change | +|------|----------|--------| +| `dincli/cli/dindao.py` | top of file | Added `_SUBGRAPH_URL` constant and `_query_subgraph()` helper | +| `dincli/cli/dindao.py` | `list_pending_requests` — model block | GraphQL-first, RPC fallback | +| `dincli/cli/dindao.py` | `list_pending_requests` — manifest block | GraphQL-first, RPC fallback | + +### Queries issued + +**Model registration requests** +```graphql +{ + modelRegistrationRequests(where: { processed: false }, first: 1000) { + requestId + requester + isOpenSource + feePaid + } +} +``` + +**Manifest update requests** +```graphql +{ + manifestUpdateRequests(where: { processed: false }, first: 1000) { + requestId + model { modelId } + requester + feePaid + } +} +``` + +### Fallback behaviour + +`_query_subgraph()` returns `None` on any of: + +- `requests.exceptions.ConnectionError` / `Timeout` (graph-node not running) +- HTTP 4xx / 5xx response +- GraphQL `errors` field present in the response body + +When `None` is returned, both blocks fall through to the original RPC enumeration loop, +so the command remains fully functional in environments where the subgraph is not deployed. + +### Configuration + +| Env var | Default | Purpose | +|---------|---------|---------| +| `DIN_SUBGRAPH_URL` | `http://localhost:8000/subgraphs/name/din-protocol/graphql` | Override the subgraph endpoint (CI, staging, custom port) | + +--- + +## Follow-up candidates + +The same RPC-enumeration pattern appears in at least two other places. These are +candidates for a P5 ticket once the subgraph schema is extended to cover task-level events: + +| File | Approx. line | Description | +|------|-------------|-------------| +| `dincli/cli/modelownerd/lms.py` | ~62 | Iterates `totalLocalModelSubmissions()` to list LMS entries for the current GI | +| `dincli/cli/modelownerd/aggregation.py` | ~76, 95, 136 | Iterates T1/T2 batch entries to build aggregation payloads | + +These loops touch `DINTaskCoordinator` state which is task-level (not covered by the +current platform-contract subgraph). They should be migrated once dynamic data sources +for task contracts are added in a future subgraph version. + +--- + +## Test coverage + +`tests/test_list_pending_requests.py` adds six unit tests (no live node required): + +| Test | What it verifies | +|------|-----------------| +| `test_graphql_model_path` | Two model requests returned from subgraph appear in output | +| `test_graphql_model_path_empty` | Empty subgraph response shows the "no pending" message | +| `test_graphql_manifest_path` | Manifest request with nested `model.modelId` renders correctly | +| `test_rpc_fallback_on_connection_error` | `ConnectionError` triggers RPC loop | +| `test_rpc_fallback_on_graphql_error_body` | `errors` in response body triggers RPC loop | +| `test_rpc_fallback_on_http_error` | HTTP 503 triggers RPC loop | + +The existing integration test `test_din_rep_lists_pending_requests` in +`tests/dincli/test_03_registration.py` continues to exercise the command end-to-end +against a live Hardhat node (graph-node is not running in that environment, so the RPC +fallback path is exercised there automatically). From 097450d935f3b4b0365632402a682e677cb8360b Mon Sep 17 00:00:00 2001 From: robertocarlous Date: Sun, 12 Jul 2026 05:37:38 +0100 Subject: [PATCH 36/48] docs(indexer): add example GraphQL queries for validator, model, and slash history (P4-IDX2) --- subgraph/docs/example-queries.md | 265 +++++++++++++++++++++++++++++++ 1 file changed, 265 insertions(+) create mode 100644 subgraph/docs/example-queries.md diff --git a/subgraph/docs/example-queries.md b/subgraph/docs/example-queries.md new file mode 100644 index 00000000..891a2c5d --- /dev/null +++ b/subgraph/docs/example-queries.md @@ -0,0 +1,265 @@ +# DIN Protocol subgraph — example queries + +All queries run against the local GraphQL playground at +`http://localhost:8000/subgraphs/name/din-protocol/graphql` once the stack +from `subgraph/docker-compose.yml` is up and the subgraph is deployed. + +--- + +## 1. Validator registry + +### All active validators with stake summary + +```graphql +{ + validators( + where: { status: "Active" } + orderBy: activeStake + orderDirection: desc + first: 50 + ) { + id + activeStake + totalStaked + totalSlashed + status + } +} +``` + +### Validators currently in the exit queue (unbonding) + +```graphql +{ + validators(where: { status: "Exiting" }) { + id + activeStake + pendingWithdrawal + withdrawAvailableAt + } +} +``` + +### All blacklisted validators + +```graphql +{ + validators(where: { blacklisted: true }) { + id + totalSlashed + status + } +} +``` + +--- + +## 2. Model registry + +### All pending model registration requests + +```graphql +{ + modelRegistrationRequests( + where: { processed: false } + orderBy: submittedAtTimestamp + orderDirection: asc + first: 100 + ) { + requestId + requester + isOpenSource + feePaid + submittedAtTimestamp + } +} +``` + +### All approved models (open-source only) + +```graphql +{ + models( + where: { isOpenSource: true, disabled: false } + orderBy: createdAtTimestamp + orderDirection: desc + first: 50 + ) { + modelId + owner + manifestCID + taskCoordinator + createdAtTimestamp + } +} +``` + +### Pending manifest update requests for a specific model owner + +```graphql +{ + manifestUpdateRequests( + where: { + processed: false + requester: "0xYOUR_ADDRESS_HERE" + } + orderBy: submittedAtTimestamp + orderDirection: asc + ) { + requestId + model { modelId } + feePaid + newManifestCID + submittedAtTimestamp + } +} +``` + +### Full request lifecycle for model 0 + +```graphql +{ + model(id: "0") { + modelId + owner + isOpenSource + disabled + registrationRequest { + requestId + feePaid + submittedAtTimestamp + processedAtTimestamp + } + manifestHistory(orderBy: submittedAtTimestamp, orderDirection: asc) { + requestId + approved + submittedAtTimestamp + processedAtTimestamp + } + } +} +``` + +--- + +## 3. Slash and reward history + +### Full slash history — most recent first + +```graphql +{ + slashEvents( + orderBy: blockTimestamp + orderDirection: desc + first: 50 + ) { + validator { id } + amount + reason + slasherContract + blockTimestamp + transactionHash + } +} +``` + +### Slash history for a specific validator + +```graphql +{ + validator(id: "0xVALIDATOR_ADDRESS_HERE") { + id + totalSlashed + slashEvents(orderBy: blockTimestamp, orderDirection: desc) { + amount + reason + slasherContract + blockTimestamp + } + } +} +``` + +### All slashes issued by a specific task contract + +```graphql +{ + slashEvents( + where: { slasherContract: "0xTASK_COORDINATOR_ADDRESS_HERE" } + orderBy: blockTimestamp + orderDirection: desc + ) { + validator { id } + amount + reason + blockTimestamp + } +} +``` + +### DIN mint history (ETH deposits) — all time + +```graphql +{ + dINMintEvents( + orderBy: blockTimestamp + orderDirection: desc + first: 100 + ) { + user + ethAmount + dinAmount + blockTimestamp + } +} +``` + +### Exchange rate timeline + +```graphql +{ + exchangeRateSnapshots( + orderBy: blockTimestamp + orderDirection: asc + ) { + newRate + blockTimestamp + transactionHash + } +} +``` + +--- + +## 4. Authorized slashers + +### Currently active slasher contracts (canonical set from DinValidatorStake) + +```graphql +{ + slasherRegistrations( + where: { + active: true + sourceContract: "0xDINVALIDATORSTAKE_ADDRESS_HERE" + } + ) { + slasherAddress + addedAtBlock + addedAtTimestamp + } +} +``` + +### Full slasher audit trail (both DinCoordinator and DinValidatorStake events) + +```graphql +{ + slasherRegistrations(orderBy: addedAtTimestamp, orderDirection: asc) { + slasherAddress + sourceContract + active + addedAtTimestamp + removedAtTimestamp + } +} +``` From 54bb1083d797cf504a7b5ea69543c49b60a22e30 Mon Sep 17 00:00:00 2001 From: robertocarlous Date: Sun, 12 Jul 2026 05:37:47 +0100 Subject: [PATCH 37/48] =?UTF-8?q?docs(indexer):=20doc-correction=20pass=20?= =?UTF-8?q?on=20indexer.md=20=E2=80=94=20resolve=20open=20questions=20from?= =?UTF-8?q?=20P4-IDX=20(P4-IDX3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Developer/issues/indexer.md | 72 +++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/Developer/issues/indexer.md b/Developer/issues/indexer.md index 8d179fad..bc1d9975 100644 --- a/Developer/issues/indexer.md +++ b/Developer/issues/indexer.md @@ -387,3 +387,75 @@ The better production design is: - use The Graph or a similar indexer for pagination, filtering, and historical queries If a query pattern mainly serves dashboards, reviewers, governance interfaces, or analytics, it is usually a strong candidate for the indexer rather than additional on-chain enumeration logic. + +--- + +## P4-IDX Doc-Correction Pass (feat/din-indexer) + +This section records decisions made and open questions resolved by the +P4-IDX1/IDX2/IDX3 work (feat/din-indexer). Added as a correction pass per the +convention established in PR #13. + +### Open questions resolved + +**"Should DIN use The Graph directly, or maintain a DIN-operated indexer service?"** +Resolved: The Graph Protocol subgraph is the chosen approach. A self-hosted +`graph-node` runs against the local Hardhat chain (chainId 1337) via +`subgraph/docker-compose.yml`. See `subgraph/docs/event-coverage-audit.md` §1 +for the written tradeoff. + +**"Which DIN product queries truly require on-chain enumerable structures, if any?"** +Resolved: None for the current platform contracts. The `list-pending-requests` +command in `dincli/cli/dindao.py` was the primary on-chain enumeration loop +for model and manifest requests — both loops have been replaced with GraphQL +queries in P4-IDX3. Remaining RPC-loop candidates (`lms.py` ~line 62, +`aggregation.py` ~lines 76, 95, 136) touch task-level contract state +(DINTaskCoordinator), which is out of scope for this subgraph; see +`subgraph/docs/handoff.md` for follow-up notes. + +**"Are there any platform-level queues whose ordering is execution-critical?"** +Resolved: No. The model and manifest request ordering in `DINModelRegistry` is +UI-critical only (review queue for the DAO admin). Execution semantics depend +on the request ID (array index), not on queue position. The indexer serves this +read pattern correctly via `processed: false` filters. + +**"Which current events are insufficient for reconstructing state cleanly?"** +Resolved: Two events were audited as insufficient; both are flagged as PENDING +additions in `subgraph/docs/event-coverage-audit.md` §6: +- `ModelRegistrationRequested` — missing `isOpenSource` and `feePaid` in payload +- `ManifestUpdateRequested` — missing `requester` in payload + +Until these additions land, the mapping handlers bridge the gap with storage +calls (`DINModelRegistry.bind(...).modelRequests(requestId)`). A third addition +(`ETHTreasuryWithdrawn` on `DinCoordinator.withdraw()`) is also proposed in §6.3 +but not yet merged. + +**"Should governance, registry, and staking data be indexed in one subgraph or separate modules?"** +Resolved: One subgraph for all four platform contracts in P4. Dynamic data +sources for task-level contracts (DINTaskCoordinator, DINTaskAuditor) are +deferred to P5+ as a materially harder Graph pattern with its own deploy/hosting +story. See `subgraph/subgraph.yaml` (network: `din-local`, 4 data sources, +31 handlers). + +### Stale references in this document + +None found. The architectural direction, guiding principle, and contribution +guidelines remain accurate against the current contract and CLI codebase. +The "Governance Queries" example section is still forward-looking (DIN-DAO +contracts are tracked in a separate issue, Start DIN-DAO #23); it is not stale, +just not yet implemented. + +### Artefacts produced by P4-IDX + +| Artefact | Path | +|----------|------| +| Indexing approach tradeoff + event audit | `subgraph/docs/event-coverage-audit.md` | +| GraphQL entity schema | `subgraph/schema.graphql` | +| Subgraph manifest | `subgraph/subgraph.yaml` | +| AssemblyScript handlers | `subgraph/src/` | +| Local Graph node stack | `subgraph/docker-compose.yml` | +| Daemon event schema | `subgraph/docs/daemon-event-schema.md` | +| Example queries | `subgraph/docs/example-queries.md` | +| GraphQL integration + fallback | `dincli/cli/dindao.py` | +| Integration unit tests | `tests/test_list_pending_requests.py` | +| Integration handoff notes | `subgraph/docs/handoff.md` | From c1c8537d5b7b3d9103b9be502f6095954e239d87 Mon Sep 17 00:00:00 2001 From: robertocarlous Date: Mon, 13 Jul 2026 23:40:56 +0100 Subject: [PATCH 38/48] docs(indexer): add task-contract event audit for DINTaskCoordinator and DINTaskAuditor --- .../audits/task-contract-event-audit.md | 232 ++++++++++++++++++ 1 file changed, 232 insertions(+) create mode 100644 Documentation/technical/audits/task-contract-event-audit.md diff --git a/Documentation/technical/audits/task-contract-event-audit.md b/Documentation/technical/audits/task-contract-event-audit.md new file mode 100644 index 00000000..e9b88100 --- /dev/null +++ b/Documentation/technical/audits/task-contract-event-audit.md @@ -0,0 +1,232 @@ +# Task-contract event audit — DINTaskCoordinator & DINTaskAuditor + +Conducted as part of the subgraph extension to task-level contracts +(`feat/din-indexer`). Follows the same format as the platform-contract audit +in `subgraph/docs/event-coverage-audit.md`. + +--- + +## §1 Scope + +Two contracts deployed once per model by the model owner: + +- `DINTaskCoordinator` — orchestrates the full GI lifecycle (aggregator/auditor + registration, LMS, batch creation, T1/T2 aggregation, slashing, GI end) +- `DINTaskAuditor` — handles auditor registration, local model submission, + eligibility voting, scoring, and auditor slashing + +**Not proxied.** Both contracts are deployed fresh for each model; there is no +upgrade proxy. This has one consequence for the indexer: already-deployed +instances on `sepolia_op_devnet` will not emit the new events proposed in §4. +Dynamic data sources can only index newly deployed task contracts going forward. +State this explicitly in any dind or operator runbook. + +--- + +## §2 Existing events + +### DINTaskCoordinator + +| Event | Emitted by | Indexed fields | Notes | +|-------|-----------|---------------|-------| +| `DINValidatorRegistered(uint GI, address validator)` | `registerDINaggregator()` | `GI`, `validator` | Aggregator registration only; auditors use the auditor contract | +| `Tier1BatchAuto(uint GI, uint batchId)` | `autoCreateTier1AndTier2()` | `GI`, `batchId` | Emitted once per T1 batch created | +| `Tier2BatchAuto(uint GI, uint batchId)` | `autoCreateTier1AndTier2()` | `GI`, `batchId` | Emitted once for the single T2 batch | +| `AggregatorSlashed(uint GI, uint batchId, address aggregator, bytes32 reason, uint256 requested, uint256 actual)` | `slashAggregators()` | `GI`, `batchId`, `aggregator` | Covers both T1 and T2 slash loops | + +### DINTaskAuditor + +| Event | Emitted by | Indexed fields | Notes | +|-------|-----------|---------------|-------| +| `DINAuditorRegistered(uint GI, address auditor)` | `registerDINAuditor()` | `GI`, `auditor` | | +| `AuditorsBatchAuto(uint GI, uint batchId)` | `createAuditorsBatches()` | `GI`, `batchId` | Per-batch; one emit per batch created | +| `AuditorsBatchesCreated(uint GI, uint batchCount)` | `createAuditorsBatches()` | `GI` | Summary event after all batches created | +| `AuditScoreSubmitted(uint256 gi, uint batchId, address auditor, uint modelIndex, uint256 score)` | `setAuditScorenEligibility()` | `gi`, `batchId`, `auditor` | | +| `EligibilityVoted(uint256 gi, uint batchId, uint modelIndex, address auditor, bool vote)` | `setAuditScorenEligibility()` | `gi`, `batchId`, `modelIndex` | | +| `EligibilityFinalized(uint256 gi, uint batchId, uint modelIndex, bool eligible, uint totalVotes)` | `_tryFinalizeEligibility()` | `gi`, `batchId`, `modelIndex` | Fires when quorum is reached after each vote | +| `PassScoreUpdated(uint256 oldScore, uint256 newScore)` | `updatePassScore()` | none | Called by coordinator at `startGI` when score arg supplied | +| `AuditorSlashed(uint256 gi, uint batchId, address auditor, bytes32 reason, uint256 requested, uint256 actual)` | `slashAuditors()` | `gi`, `batchId`, `auditor` | | + +--- + +## §3 Gap analysis + +### §3.1 Silent GI state machine + +`GIstates` (defined in `DINShared.sol`) has 23 states. `DINTaskCoordinator` +writes to `GIstate` at 23 sites — the constructor, the four pre-GI setup steps, +and every phase-transition function — and **none of them emit an event**. The +full list of silent write sites: + +| Site | Transition | +|------|-----------| +| `constructor` | → `AwaitingDINTaskAuditorToBeSet` | +| `setDINTaskAuditorContract()` | → `AwaitingDINTaskCoordinatorAsSlasher` | +| `setDINTaskCoordinatorAsSlasher()` | → `AwaitingDINTaskAuditorAsSlasher` | +| `setDINTaskAuditorAsSlasher()` | → `AwaitingGenesisModel` | +| `setGenesisModelIpfsHash()` | → `GenesisModelCreated` | +| `_startGI()` | → `GIstarted` | +| `startDINaggregatorsRegistration()` | → `DINaggregatorsRegistrationStarted` | +| `closeDINaggregatorsRegistration()` | → `DINaggregatorsRegistrationClosed` | +| `startDINauditorsRegistration()` | → `DINauditorsRegistrationStarted` | +| `closeDINauditorsRegistration()` | → `DINauditorsRegistrationClosed` | +| `startLMsubmissions()` | → `LMSstarted` | +| `closeLMsubmissions()` | → `LMSclosed` | +| `createAuditorsBatches()` | → `AuditorsBatchesCreated` | +| `startLMsubmissionsEvaluation()` | → `LMSevaluationStarted` | +| `closeLMsubmissionsEvaluation()` | → `LMSevaluationClosed` | +| `autoCreateTier1AndTier2()` | → `T1nT2Bcreated` | +| `startT1Aggregation()` | → `T1AggregationStarted` | +| `finalizeT1Aggregation()` | → `T1AggregationDone` | +| `startT2Aggregation()` | → `T2AggregationStarted` | +| `finalizeT2Aggregation()` | → `T2AggregationDone` | +| `slashAuditors()` | → `AuditorsSlashed` | +| `slashAggregators()` | → `AggregatorsSlashed` | +| `endGI()` | → `GIended` | + +The indexer cannot track which phase a model is currently in without a storage +call per block — defeating the purpose of event-driven indexing. + +### §3.2 Missing submission events + +| Function | Contract | Impact | +|----------|---------|--------| +| `submitLocalModel()` | `DINTaskAuditor` | `lms.py ~line 62` iterates `getClientModels()` over RPC. Cannot be replaced with a GraphQL query without a `LocalModelSubmitted` event. | +| `submitT1Aggregation()` | `DINTaskCoordinator` | `aggregation.py ~lines 76, 95` iterates T1 batch submissions over RPC. No event means no indexer coverage. | +| `submitT2Aggregation()` | `DINTaskCoordinator` | `aggregation.py ~line 136` iterates T2 batch submissions over RPC. Same gap. | + +### §3.3 Missing finalization events + +`finalizeT1Aggregation()` iterates all T1 batches, tallies votes per CID, and +writes the majority CID to `b.finalCID` in storage — no event emitted. +`finalizeT2Aggregation()` does the same for the single T2 batch; its `b.finalCID` +is the **new global model CID** for the GI — the single most important value +produced by a completed iteration. + +Without finalization events, the indexer would have to re-implement the +vote-majority tallying logic in AssemblyScript from the submission events alone. +That is complex, divergence-prone, and fragile against any future change to the +tally logic. The contract has already computed the winner; it should emit it. + +--- + +## §4 Proposed additions + +All six additions are minimal — one declaration and one (or N, for +`GIStateChanged`) emit site per event. No storage changes, no new state. To be +landed as one small reviewed PR before handler implementation begins. + +### §4.1 GI state machine — catch-all event + +```solidity +event GIStateChanged(uint indexed GI, uint8 indexed newState); +``` + +**Implementation:** replace every `GIstate = GIstates.X;` assignment in +`DINTaskCoordinator` with a call to an internal setter: + +```solidity +function _setGIstate(GIstates newState) internal { + GIstate = newState; + emit GIStateChanged(GI, uint8(newState)); +} +``` + +One declaration, one emit site, and it is impossible to miss a future +transition when new states are added. `newState` is indexed so consumers +(including `dind` P4-7.1) can topic-filter for a specific phase — +e.g. `newState == 16` (`T1AggregationStarted`) to queue aggregation jobs. + +The schema maps `uint8` → a GraphQL enum mirroring `GIstates` from +`DINShared.sol`, so queries read as clearly as named events would. + +### §4.2 Local model submission + +```solidity +event LocalModelSubmitted( + uint indexed GI, + uint indexed modelIndex, + address indexed client, + bytes32 modelCID +); +``` + +Emit inside `DINTaskAuditor.submitLocalModel()` after the `lmSubmissions[_GI].push(...)`. +`modelIndex` is `lmSubmissions[_GI].length` before the push (already computed +as a local variable in the current code). + +### §4.3 T1 and T2 aggregation submission + +```solidity +event T1AggregationSubmitted( + uint indexed GI, + uint indexed batchId, + address indexed aggregator, + bytes32 cid +); + +event T2AggregationSubmitted( + uint indexed GI, + uint indexed batchId, + address indexed aggregator, + bytes32 cid +); +``` + +Both in `DINTaskCoordinator`. `T2AggregationSubmitted.batchId` is always `0` +today (`TC_OnlyOneTier2Batch` enforces it) — kept for symmetry with T1 and +future extensibility. + +### §4.4 T1 and T2 finalization + +```solidity +event T1BatchFinalized( + uint indexed GI, + uint indexed batchId, + bytes32 winningCID +); + +event T2Finalized( + uint indexed GI, + bytes32 globalModelCID +); +``` + +`T1BatchFinalized` emits once per batch inside the `finalizeT1Aggregation()` +loop, immediately after `b.finalCID = winningCID`. `T2Finalized` emits once +inside `finalizeT2Aggregation()` — `globalModelCID` is the T2 winning CID, +i.e. the new global model for this GI. + +--- + +## §5 Dead code — RewardDeposited + +`DINTaskAuditor` declares: + +```solidity +event RewardDeposited(address indexed modelOwner, uint256 amount); +``` + +No `depositRewards` function exists anywhere in the contract. The event is +never emitted. **Proposal:** remove in the same PR as the event additions above, +unless P3-5.1–5.3 (fee routing / reward distribution) introduces a real deposit +flow — that work should decide whether the event gets an emitter or is deleted. +Do not add an emitter here without the corresponding P3 design. + +--- + +## §6 Dynamic data source design note + +Task contracts are factory-deployed — one `DINTaskCoordinator` + `DINTaskAuditor` +pair per model. The subgraph will use **The Graph data source templates**: + +1. `DINModelRegistry` emits `ModelApproved` when a model is registered. +2. The `handleModelApproved` mapping handler reads `taskCoordinator` and + `taskAuditor` addresses from the `DINModelRegistry` storage call (same bridge + approach used in the platform-contract handlers). +3. The handler calls `DINTaskCoordinatorTemplate.create(taskCoordinatorAddress)` + and `DINTaskAuditorTemplate.create(taskAuditorAddress)` to instantiate live + indexing for that model's contracts. + +Entity design and AssemblyScript handler implementation follow once this audit +and the proposed contract additions are reviewed and merged. From 9474146aca54e5ae9a3dc167a4d903af9107a84b Mon Sep 17 00:00:00 2001 From: robertocarlous Date: Mon, 13 Jul 2026 23:45:20 +0100 Subject: [PATCH 39/48] feat(indexer): add GlobalIteration, AggregatorRegistration, AuditorRegistration entities --- subgraph/schema.graphql | 87 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/subgraph/schema.graphql b/subgraph/schema.graphql index a6daa103..94dba77e 100644 --- a/subgraph/schema.graphql +++ b/subgraph/schema.graphql @@ -69,6 +69,7 @@ type Model @entity { manifestHistory: [ManifestUpdateRequest!]! @derivedFrom(field: "model") disableEvents: [ModelKillSwitchEvent!]! @derivedFrom(field: "model") + globalIterations: [GlobalIteration!]! @derivedFrom(field: "model") createdAtBlock: BigInt! createdAtTimestamp: BigInt! @@ -378,3 +379,89 @@ type TokenTransferEvent @entity { blockTimestamp: BigInt! transactionHash: Bytes! } + +# ============================================================================= +# Task-level contracts — dynamic data sources +# +# DINTaskCoordinator and DINTaskAuditor are deployed once per model (not once +# globally). The subgraph tracks them via data source templates instantiated +# when DINModelRegistry emits ModelApproved. +# +# Event additions required before several entities below are fully populated +# are marked [PENDING — see task-contract-event-audit.md §4]. +# ============================================================================= + +# ───────────────────────────────────────────────────────────────────────────── +# Global Iteration lifecycle +# ───────────────────────────────────────────────────────────────────────────── + +""" +A single Global Iteration (GI) for one federated-learning model. +Central entity linking registrations, batches, submissions, and scoring. +""" +type GlobalIteration @entity { + "taskCoordinatorAddress-gi (hex-decimal)" + id: ID! + + gi: BigInt! + taskCoordinator: Bytes! + + "Back-reference to the platform-level Model entity" + model: Model! + + "Current GIstates enum value as uint8. + Populated once GIStateChanged event lands [PENDING — audit §4.1]. + Starts at 5 (GIstarted) — the first state reachable after model registration." + currentState: Int! + + aggregatorRegistrations: [AggregatorRegistration!]! @derivedFrom(field: "globalIteration") + auditorRegistrations: [AuditorRegistration!]! @derivedFrom(field: "globalIteration") + tier1Batches: [Tier1Batch!]! @derivedFrom(field: "globalIteration") + tier2Batches: [Tier2Batch!]! @derivedFrom(field: "globalIteration") + auditBatches: [AuditBatch!]! @derivedFrom(field: "globalIteration") + localModelSubmissions: [LocalModelSubmission!]! @derivedFrom(field: "globalIteration") + aggregatorSlashEvents: [AggregatorSlashEvent!]! @derivedFrom(field: "globalIteration") + auditorSlashEvents: [AuditorSlashEvent!]! @derivedFrom(field: "globalIteration") + stateTransitions: [GIStateTransition!]! @derivedFrom(field: "globalIteration") + + "T2 winning CID — the new global model. Set once T2Finalized event lands [PENDING — audit §4.4]." + globalModelCID: Bytes + + "True once endGI() has been called and GIStateChanged(GIended) fires." + ended: Boolean! + + startedAtBlock: BigInt! + startedAtTimestamp: BigInt! + endedAtBlock: BigInt + endedAtTimestamp: BigInt +} + +# ───────────────────────────────────────────────────────────────────────────── +# Validator registrations per GI +# ───────────────────────────────────────────────────────────────────────────── + +"""An aggregator that registered for a specific Global Iteration.""" +type AggregatorRegistration @entity { + "taskCoordinatorAddress-gi-validatorAddress" + id: ID! + + globalIteration: GlobalIteration! + validator: Bytes! + + blockNumber: BigInt! + blockTimestamp: BigInt! + transactionHash: Bytes! +} + +"""An auditor that registered for a specific Global Iteration.""" +type AuditorRegistration @entity { + "taskAuditorAddress-gi-auditorAddress" + id: ID! + + globalIteration: GlobalIteration! + auditor: Bytes! + + blockNumber: BigInt! + blockTimestamp: BigInt! + transactionHash: Bytes! +} From 06ed32c20848c0bda4cc3f2e81360473c14494ba Mon Sep 17 00:00:00 2001 From: robertocarlous Date: Mon, 13 Jul 2026 23:46:29 +0100 Subject: [PATCH 40/48] feat(indexer): add Tier1Batch, Tier2Batch, AuditBatch entities --- subgraph/schema.graphql | 77 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/subgraph/schema.graphql b/subgraph/schema.graphql index 94dba77e..20cf27f4 100644 --- a/subgraph/schema.graphql +++ b/subgraph/schema.graphql @@ -465,3 +465,80 @@ type AuditorRegistration @entity { blockTimestamp: BigInt! transactionHash: Bytes! } + +# ───────────────────────────────────────────────────────────────────────────── +# Aggregation batches (T1 and T2) +# ───────────────────────────────────────────────────────────────────────────── + +""" +A Tier-1 aggregation batch. Created by autoCreateTier1AndTier2(). +Multiple T1 batches exist per GI; each covers a sub-set of approved models. +""" +type Tier1Batch @entity { + "taskCoordinatorAddress-gi-batchId" + id: ID! + + globalIteration: GlobalIteration! + batchId: BigInt! + + t1Submissions: [T1AggregationSubmission!]! @derivedFrom(field: "tier1Batch") + + "True once finalizeT1Aggregation() selects the majority CID." + finalized: Boolean! + + "Majority-agreed aggregation CID. Set once T1BatchFinalized event lands [PENDING — audit §4.4]." + winningCID: Bytes + + createdAtBlock: BigInt! + createdAtTimestamp: BigInt! + finalizedAtBlock: BigInt + finalizedAtTimestamp: BigInt +} + +""" +The single Tier-2 aggregation batch per GI. Created alongside T1 batches. +Its winningCID is the new global model for the iteration. +""" +type Tier2Batch @entity { + "taskCoordinatorAddress-gi (batchId is always 0)" + id: ID! + + globalIteration: GlobalIteration! + + t2Submissions: [T2AggregationSubmission!]! @derivedFrom(field: "tier2Batch") + + "True once finalizeT2Aggregation() selects the majority CID." + finalized: Boolean! + + "Majority-agreed CID — the new global model for this GI. Set once T2Finalized event lands [PENDING — audit §4.4]." + globalModelCID: Bytes + + createdAtBlock: BigInt! + createdAtTimestamp: BigInt! + finalizedAtBlock: BigInt + finalizedAtTimestamp: BigInt +} + +# ───────────────────────────────────────────────────────────────────────────── +# Audit batches +# ───────────────────────────────────────────────────────────────────────────── + +""" +An audit batch created by DINTaskAuditor.createAuditorsBatches(). +Groups a subset of auditors with a subset of local model submissions. +""" +type AuditBatch @entity { + "taskAuditorAddress-gi-batchId" + id: ID! + + globalIteration: GlobalIteration! + batchId: BigInt! + + scoreSubmissions: [AuditScoreSubmission!]! @derivedFrom(field: "auditBatch") + eligibilityVotes: [EligibilityVote!]! @derivedFrom(field: "auditBatch") + eligibilityResults: [EligibilityResult!]! @derivedFrom(field: "auditBatch") + auditorSlashEvents: [AuditorSlashEvent!]! @derivedFrom(field: "auditBatch") + + createdAtBlock: BigInt! + createdAtTimestamp: BigInt! +} From 1c805d51f36f97de7c54095aad651ad61a0e9457 Mon Sep 17 00:00:00 2001 From: robertocarlous Date: Mon, 13 Jul 2026 23:48:23 +0100 Subject: [PATCH 41/48] feat(indexer): add LocalModelSubmission, T1/T2AggregationSubmission, scoring entities --- subgraph/schema.graphql | 124 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) diff --git a/subgraph/schema.graphql b/subgraph/schema.graphql index 20cf27f4..45b1b283 100644 --- a/subgraph/schema.graphql +++ b/subgraph/schema.graphql @@ -542,3 +542,127 @@ type AuditBatch @entity { createdAtBlock: BigInt! createdAtTimestamp: BigInt! } + +# ───────────────────────────────────────────────────────────────────────────── +# Local model submissions +# ───────────────────────────────────────────────────────────────────────────── + +""" +A local model submitted by a client during the LMS phase. +Requires the LocalModelSubmitted event addition [PENDING — audit §4.2]. +Until that event lands this entity will not be populated. +""" +type LocalModelSubmission @entity { + "taskAuditorAddress-gi-modelIndex" + id: ID! + + globalIteration: GlobalIteration! + modelIndex: BigInt! + client: Bytes! + modelCID: Bytes! + + "Set once EligibilityFinalized fires for this modelIndex." + eligible: Boolean + "Set once finalizeEvaluation() is called (derived from EligibilityFinalized + AuditScoreSubmitted)." + finalAvgScore: BigInt + "True if eligible and finalAvgScore >= passScore." + approved: Boolean + + scoreSubmissions: [AuditScoreSubmission!]! @derivedFrom(field: "localModelSubmission") + eligibilityVotes: [EligibilityVote!]! @derivedFrom(field: "localModelSubmission") + eligibilityResult: EligibilityResult @derivedFrom(field: "localModelSubmission") + + blockNumber: BigInt! + blockTimestamp: BigInt! + transactionHash: Bytes! +} + +# ───────────────────────────────────────────────────────────────────────────── +# Aggregation submissions (T1 and T2) +# ───────────────────────────────────────────────────────────────────────────── + +""" +A T1 aggregation CID submitted by an aggregator for a specific batch. +Requires the T1AggregationSubmitted event addition [PENDING — audit §4.3]. +""" +type T1AggregationSubmission @entity { + "taskCoordinatorAddress-gi-batchId-aggregatorAddress" + id: ID! + + tier1Batch: Tier1Batch! + aggregator: Bytes! + cid: Bytes! + + blockNumber: BigInt! + blockTimestamp: BigInt! + transactionHash: Bytes! +} + +""" +A T2 aggregation CID submitted by an aggregator. +batchId is always 0. Requires the T2AggregationSubmitted event addition [PENDING — audit §4.3]. +""" +type T2AggregationSubmission @entity { + "taskCoordinatorAddress-gi-aggregatorAddress" + id: ID! + + tier2Batch: Tier2Batch! + aggregator: Bytes! + cid: Bytes! + + blockNumber: BigInt! + blockTimestamp: BigInt! + transactionHash: Bytes! +} + +# ───────────────────────────────────────────────────────────────────────────── +# Audit scoring and eligibility +# ───────────────────────────────────────────────────────────────────────────── + +"""An audit score submitted by a single auditor for one local model.""" +type AuditScoreSubmission @entity { + "taskAuditorAddress-gi-batchId-auditorAddress-modelIndex" + id: ID! + + auditBatch: AuditBatch! + localModelSubmission: LocalModelSubmission! + auditor: Bytes! + score: BigInt! + + blockNumber: BigInt! + blockTimestamp: BigInt! + transactionHash: Bytes! +} + +"""An eligibility vote cast by a single auditor for one local model.""" +type EligibilityVote @entity { + "taskAuditorAddress-gi-batchId-modelIndex-auditorAddress" + id: ID! + + auditBatch: AuditBatch! + localModelSubmission: LocalModelSubmission! + auditor: Bytes! + vote: Boolean! + + blockNumber: BigInt! + blockTimestamp: BigInt! + transactionHash: Bytes! +} + +""" +The finalized eligibility result for a local model once quorum is reached. +One record per modelIndex per GI. +""" +type EligibilityResult @entity { + "taskAuditorAddress-gi-batchId-modelIndex" + id: ID! + + auditBatch: AuditBatch! + localModelSubmission: LocalModelSubmission! + eligible: Boolean! + totalVotes: BigInt! + + blockNumber: BigInt! + blockTimestamp: BigInt! + transactionHash: Bytes! +} From 72625494cebb8d9b1ca40691b32572b55bc0a47d Mon Sep 17 00:00:00 2001 From: robertocarlous Date: Mon, 13 Jul 2026 23:50:17 +0100 Subject: [PATCH 42/48] feat(indexer): add AggregatorSlashEvent, AuditorSlashEvent, PassScoreUpdate, GIStateTransition entities --- subgraph/schema.graphql | 82 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/subgraph/schema.graphql b/subgraph/schema.graphql index 45b1b283..aeadc110 100644 --- a/subgraph/schema.graphql +++ b/subgraph/schema.graphql @@ -666,3 +666,85 @@ type EligibilityResult @entity { blockTimestamp: BigInt! transactionHash: Bytes! } + +# ───────────────────────────────────────────────────────────────────────────── +# Slash events (task-level) +# ───────────────────────────────────────────────────────────────────────────── + +"""A slash applied to an aggregator by DINTaskCoordinator.slashAggregators().""" +type AggregatorSlashEvent @entity { + "txHash-logIndex" + id: ID! + + globalIteration: GlobalIteration! + batchId: BigInt! + aggregator: Bytes! + reason: Bytes! + requestedAmount: BigInt! + actualAmount: BigInt! + + blockNumber: BigInt! + blockTimestamp: BigInt! + transactionHash: Bytes! +} + +"""A slash applied to an auditor by DINTaskAuditor.slashAuditors().""" +type AuditorSlashEvent @entity { + "txHash-logIndex" + id: ID! + + globalIteration: GlobalIteration! + auditBatch: AuditBatch! + auditor: Bytes! + reason: Bytes! + requestedAmount: BigInt! + actualAmount: BigInt! + + blockNumber: BigInt! + blockTimestamp: BigInt! + transactionHash: Bytes! +} + +# ───────────────────────────────────────────────────────────────────────────── +# Pass score governance +# ───────────────────────────────────────────────────────────────────────────── + +"""A change to the auditor pass score on DINTaskAuditor.""" +type PassScoreUpdate @entity { + "txHash-logIndex" + id: ID! + + "The task auditor contract that emitted the event" + taskAuditor: Bytes! + oldScore: BigInt! + newScore: BigInt! + + blockNumber: BigInt! + blockTimestamp: BigInt! + transactionHash: Bytes! +} + +# ───────────────────────────────────────────────────────────────────────────── +# GI state transition history +# ───────────────────────────────────────────────────────────────────────────── + +""" +A single GI state machine transition recorded from GIStateChanged. +Requires the GIStateChanged event addition [PENDING — audit §4.1]. +One record per state write — the full lifecycle of a GI is reconstructable +from the ordered sequence of GIStateTransition records for a GlobalIteration. +newState is the uint8 value of the GIstates enum in DINShared.sol. +""" +type GIStateTransition @entity { + "taskCoordinatorAddress-gi-newState" + id: ID! + + globalIteration: GlobalIteration! + + "uint8 value of the GIstates enum (0 = AwaitingDINTaskAuditorToBeSet … 22 = GIended)" + newState: Int! + + blockNumber: BigInt! + blockTimestamp: BigInt! + transactionHash: Bytes! +} From 6c61218dc0067580785c574e9c9e7505750cbbde Mon Sep 17 00:00:00 2001 From: robertocarlous Date: Tue, 14 Jul 2026 00:23:07 +0100 Subject: [PATCH 43/48] feat(indexer): add DINTaskCoordinator and DINTaskAuditor dynamic data source templates --- subgraph/abis/DINTaskAuditor.json | 1221 ++++++++++++++++++++++ subgraph/abis/DINTaskCoordinator.json | 1339 +++++++++++++++++++++++++ subgraph/subgraph.yaml | 136 ++- 3 files changed, 2692 insertions(+), 4 deletions(-) create mode 100644 subgraph/abis/DINTaskAuditor.json create mode 100644 subgraph/abis/DINTaskCoordinator.json diff --git a/subgraph/abis/DINTaskAuditor.json b/subgraph/abis/DINTaskAuditor.json new file mode 100644 index 00000000..3098c7f6 --- /dev/null +++ b/subgraph/abis/DINTaskAuditor.json @@ -0,0 +1,1221 @@ +{ + "abi": [ + { + "inputs": [ + { + "internalType": "address", + "name": "_dinvalidatorStakeContract_address", + "type": "address" + }, + { + "internalType": "address", + "name": "_dintaskcoordinator_contract_address", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + } + ], + "name": "OwnableInvalidOwner", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "OwnableUnauthorizedAccount", + "type": "error" + }, + { + "inputs": [], + "name": "TA_AlreadySubmitted", + "type": "error" + }, + { + "inputs": [], + "name": "TA_AlreadyVoted", + "type": "error" + }, + { + "inputs": [], + "name": "TA_AuditorAlreadyRegistered", + "type": "error" + }, + { + "inputs": [], + "name": "TA_AuditorNotActive", + "type": "error" + }, + { + "inputs": [], + "name": "TA_AuditorRegistrationNotOpen", + "type": "error" + }, + { + "inputs": [], + "name": "TA_BatchDoesNotExist", + "type": "error" + }, + { + "inputs": [], + "name": "TA_BatchIDMismatch", + "type": "error" + }, + { + "inputs": [], + "name": "TA_CannotCreateAuditorsBatches", + "type": "error" + }, + { + "inputs": [], + "name": "TA_CannotFinalizeEvaluation", + "type": "error" + }, + { + "inputs": [], + "name": "TA_CannotSetAuditScore", + "type": "error" + }, + { + "inputs": [], + "name": "TA_CannotSetTestDataAssignedFlag", + "type": "error" + }, + { + "inputs": [], + "name": "TA_FlagAlreadySet", + "type": "error" + }, + { + "inputs": [], + "name": "TA_FlagMustBeTrue", + "type": "error" + }, + { + "inputs": [], + "name": "TA_InvalidModelIndex", + "type": "error" + }, + { + "inputs": [], + "name": "TA_InvalidPassScore", + "type": "error" + }, + { + "inputs": [], + "name": "TA_LMSubmissionsNotOpen", + "type": "error" + }, + { + "inputs": [], + "name": "TA_MaxLMSubmissionsReached", + "type": "error" + }, + { + "inputs": [], + "name": "TA_NotAssignedAuditor", + "type": "error" + }, + { + "inputs": [], + "name": "TA_NotEnoughAuditors", + "type": "error" + }, + { + "inputs": [], + "name": "TA_NotTaskCoordinator", + "type": "error" + }, + { + "inputs": [], + "name": "TA_ScoreOutOfRange", + "type": "error" + }, + { + "inputs": [], + "name": "TA_WrongGI", + "type": "error" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "gi", + "type": "uint256" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "batchId", + "type": "uint256" + }, + { + "indexed": true, + "internalType": "address", + "name": "auditor", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "modelIndex", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "score", + "type": "uint256" + } + ], + "name": "AuditScoreSubmitted", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "gi", + "type": "uint256" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "batchId", + "type": "uint256" + }, + { + "indexed": true, + "internalType": "address", + "name": "auditor", + "type": "address" + }, + { + "indexed": false, + "internalType": "bytes32", + "name": "reason", + "type": "bytes32" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "requested", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "actual", + "type": "uint256" + } + ], + "name": "AuditorSlashed", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "GI", + "type": "uint256" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "batchId", + "type": "uint256" + } + ], + "name": "AuditorsBatchAuto", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "GI", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "batchCount", + "type": "uint256" + } + ], + "name": "AuditorsBatchesCreated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "GI", + "type": "uint256" + }, + { + "indexed": true, + "internalType": "address", + "name": "auditor", + "type": "address" + } + ], + "name": "DINAuditorRegistered", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "gi", + "type": "uint256" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "batchId", + "type": "uint256" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "modelIndex", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "bool", + "name": "eligible", + "type": "bool" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "totalVotes", + "type": "uint256" + } + ], + "name": "EligibilityFinalized", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "gi", + "type": "uint256" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "batchId", + "type": "uint256" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "modelIndex", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "address", + "name": "auditor", + "type": "address" + }, + { + "indexed": false, + "internalType": "bool", + "name": "vote", + "type": "bool" + } + ], + "name": "EligibilityVoted", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "previousOwner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "OwnershipTransferred", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "oldScore", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "newScore", + "type": "uint256" + } + ], + "name": "PassScoreUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "modelOwner", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "RewardDeposited", + "type": "event" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_GI", + "type": "uint256" + } + ], + "name": "AuditorsBatchCount", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "name": "Is_testdataCIDs_Assigned", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "address", + "name": "", + "type": "address" + }, + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "name": "LMeligibleVote", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_GI", + "type": "uint256" + } + ], + "name": "approvedModelIndexes", + "outputs": [ + { + "internalType": "uint256[]", + "name": "", + "type": "uint256[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "gi", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "batchId", + "type": "uint256" + }, + { + "internalType": "bytes32", + "name": "testDataCID", + "type": "bytes32" + } + ], + "name": "assignAuditTestDataset", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "name": "auditBatches", + "outputs": [ + { + "internalType": "uint256", + "name": "batchId", + "type": "uint256" + }, + { + "internalType": "bytes32", + "name": "testDataCID", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "address", + "name": "", + "type": "address" + }, + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "name": "auditScores", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "clientHasSubmitted", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "clientSubmissionIndex", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_GI", + "type": "uint256" + } + ], + "name": "createAuditorsBatches", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "name": "dinAuditors", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "dintaskcoordinatorContract", + "outputs": [ + { + "internalType": "contract IDINTaskCoordinator", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "dinvalidatorStakeContract", + "outputs": [ + { + "internalType": "contract IDinValidatorStake", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_GI", + "type": "uint256" + } + ], + "name": "finalizeEvaluation", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_GI", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_batchId", + "type": "uint256" + } + ], + "name": "getAuditorsBatch", + "outputs": [ + { + "internalType": "uint256", + "name": "batchId", + "type": "uint256" + }, + { + "internalType": "address[]", + "name": "auditors", + "type": "address[]" + }, + { + "internalType": "uint256[]", + "name": "modelIndexes", + "type": "uint256[]" + }, + { + "internalType": "bytes32", + "name": "testDataCID", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_GI", + "type": "uint256" + } + ], + "name": "getClientModels", + "outputs": [ + { + "components": [ + { + "internalType": "address", + "name": "client", + "type": "address" + }, + { + "internalType": "bytes32", + "name": "modelCID", + "type": "bytes32" + }, + { + "internalType": "uint40", + "name": "submittedAt", + "type": "uint40" + }, + { + "internalType": "bool", + "name": "eligible", + "type": "bool" + }, + { + "internalType": "bool", + "name": "evaluated", + "type": "bool" + }, + { + "internalType": "bool", + "name": "approved", + "type": "bool" + }, + { + "internalType": "uint256", + "name": "finalAvgScore", + "type": "uint256" + } + ], + "internalType": "struct DINTaskAuditor.LMSubmission[]", + "name": "", + "type": "tuple[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_GI", + "type": "uint256" + } + ], + "name": "getDINtaskAuditors", + "outputs": [ + { + "internalType": "address[]", + "name": "", + "type": "address[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "address", + "name": "", + "type": "address" + }, + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "name": "hasAuditedLM", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "isBatchAuditor", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "name": "isBatchModelIndex", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "isRegisteredAuditor", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "name": "lmSubmissions", + "outputs": [ + { + "internalType": "address", + "name": "client", + "type": "address" + }, + { + "internalType": "bytes32", + "name": "modelCID", + "type": "bytes32" + }, + { + "internalType": "uint40", + "name": "submittedAt", + "type": "uint40" + }, + { + "internalType": "bool", + "name": "eligible", + "type": "bool" + }, + { + "internalType": "bool", + "name": "evaluated", + "type": "bool" + }, + { + "internalType": "bool", + "name": "approved", + "type": "bool" + }, + { + "internalType": "uint256", + "name": "finalAvgScore", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "owner", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "params", + "outputs": [ + { + "internalType": "uint256", + "name": "auditorsPerBatch", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "modelsPerBatch", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "minEligibilityQuorum", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "minScoreQuorum", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "passScore", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "MIN_MODELS_PER_BATCH", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_GI", + "type": "uint256" + } + ], + "name": "registerDINAuditor", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "renounceOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "gi", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "batchId", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "modelIndex", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "score", + "type": "uint256" + }, + { + "internalType": "bool", + "name": "vote", + "type": "bool" + } + ], + "name": "setAuditScorenEligibility", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_GI", + "type": "uint256" + }, + { + "internalType": "bool", + "name": "flag", + "type": "bool" + } + ], + "name": "setTestDataAssignedFlag", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_GI", + "type": "uint256" + } + ], + "name": "slashAuditors", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "_clientModel", + "type": "bytes32" + }, + { + "internalType": "uint256", + "name": "_GI", + "type": "uint256" + } + ], + "name": "submitLocalModel", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "totalDepositedRewards", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "transferOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "newPassScore", + "type": "uint256" + } + ], + "name": "updatePassScore", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + } + ] +} \ No newline at end of file diff --git a/subgraph/abis/DINTaskCoordinator.json b/subgraph/abis/DINTaskCoordinator.json new file mode 100644 index 00000000..33db992c --- /dev/null +++ b/subgraph/abis/DINTaskCoordinator.json @@ -0,0 +1,1339 @@ +{ + "abi": [ + { + "inputs": [ + { + "internalType": "address", + "name": "dinvalidatorStakeContract_address", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + } + ], + "name": "OwnableInvalidOwner", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "OwnableUnauthorizedAccount", + "type": "error" + }, + { + "inputs": [], + "name": "TC_AggregatorAlreadyRegistered", + "type": "error" + }, + { + "inputs": [], + "name": "TC_AggregatorNotActive", + "type": "error" + }, + { + "inputs": [], + "name": "TC_AggregatorsRegistrationCannotBeFinished", + "type": "error" + }, + { + "inputs": [], + "name": "TC_AggregatorsRegistrationCannotBeStarted", + "type": "error" + }, + { + "inputs": [], + "name": "TC_AggregatorsRegistrationNotOpen", + "type": "error" + }, + { + "inputs": [], + "name": "TC_AlreadySubmitted", + "type": "error" + }, + { + "inputs": [], + "name": "TC_AuditorCannotBeSetAsSlasher", + "type": "error" + }, + { + "inputs": [], + "name": "TC_AuditorIsNotSlasher", + "type": "error" + }, + { + "inputs": [], + "name": "TC_AuditorsRegistrationCannotBeFinished", + "type": "error" + }, + { + "inputs": [], + "name": "TC_AuditorsRegistrationCannotBeStarted", + "type": "error" + }, + { + "inputs": [], + "name": "TC_BatchNotFound", + "type": "error" + }, + { + "inputs": [], + "name": "TC_CannotSetTestDataAssignedFlag", + "type": "error" + }, + { + "inputs": [], + "name": "TC_CoordinatorCannotBeSetAsSlasher", + "type": "error" + }, + { + "inputs": [], + "name": "TC_CoordinatorIsNotSlasher", + "type": "error" + }, + { + "inputs": [], + "name": "TC_EvalPhaseNotClosed", + "type": "error" + }, + { + "inputs": [], + "name": "TC_FailedToCreateAuditorsBatches", + "type": "error" + }, + { + "inputs": [], + "name": "TC_FailedToFinalizeEvaluation", + "type": "error" + }, + { + "inputs": [], + "name": "TC_FailedToSlashAuditors", + "type": "error" + }, + { + "inputs": [], + "name": "TC_GICannotBeStarted", + "type": "error" + }, + { + "inputs": [], + "name": "TC_GenesisModelHashCannotBeSet", + "type": "error" + }, + { + "inputs": [], + "name": "TC_InvalidBatch", + "type": "error" + }, + { + "inputs": [], + "name": "TC_LMEvalCannotBeFinished", + "type": "error" + }, + { + "inputs": [], + "name": "TC_LMEvalCannotBeStarted", + "type": "error" + }, + { + "inputs": [], + "name": "TC_LMSubmissionsCannotBeStarted", + "type": "error" + }, + { + "inputs": [], + "name": "TC_LMSubmissionsNotStarted", + "type": "error" + }, + { + "inputs": [], + "name": "TC_NoSubmissions", + "type": "error" + }, + { + "inputs": [], + "name": "TC_NotBatchAggregator", + "type": "error" + }, + { + "inputs": [], + "name": "TC_NotEnoughApprovedModels", + "type": "error" + }, + { + "inputs": [], + "name": "TC_NotEnoughValidators", + "type": "error" + }, + { + "inputs": [], + "name": "TC_NotReadyForT1Aggregation", + "type": "error" + }, + { + "inputs": [], + "name": "TC_NotReadyForT2Aggregation", + "type": "error" + }, + { + "inputs": [], + "name": "TC_NotReadyToEndGI", + "type": "error" + }, + { + "inputs": [], + "name": "TC_NotReadyToFinalizeT1", + "type": "error" + }, + { + "inputs": [], + "name": "TC_NotReadyToFinalizeT2", + "type": "error" + }, + { + "inputs": [], + "name": "TC_NotReadyToSetTier2Score", + "type": "error" + }, + { + "inputs": [], + "name": "TC_NotReadyToSlashAggregators", + "type": "error" + }, + { + "inputs": [], + "name": "TC_NotReadyToSlashAuditors", + "type": "error" + }, + { + "inputs": [], + "name": "TC_OnlyOneTier2Batch", + "type": "error" + }, + { + "inputs": [], + "name": "TC_T1AggregationNotStarted", + "type": "error" + }, + { + "inputs": [], + "name": "TC_T2AggregationNotStarted", + "type": "error" + }, + { + "inputs": [], + "name": "TC_TaskAuditorContractCannotBeSet", + "type": "error" + }, + { + "inputs": [], + "name": "TC_WrongGI", + "type": "error" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "GI", + "type": "uint256" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "batchId", + "type": "uint256" + }, + { + "indexed": true, + "internalType": "address", + "name": "aggregator", + "type": "address" + }, + { + "indexed": false, + "internalType": "bytes32", + "name": "reason", + "type": "bytes32" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "requested", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "actual", + "type": "uint256" + } + ], + "name": "AggregatorSlashed", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "GI", + "type": "uint256" + }, + { + "indexed": true, + "internalType": "address", + "name": "validator", + "type": "address" + } + ], + "name": "DINValidatorRegistered", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "previousOwner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "OwnershipTransferred", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "GI", + "type": "uint256" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "batchId", + "type": "uint256" + } + ], + "name": "Tier1BatchAuto", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "GI", + "type": "uint256" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "batchId", + "type": "uint256" + } + ], + "name": "Tier2BatchAuto", + "type": "event" + }, + { + "inputs": [], + "name": "GI", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "GIstate", + "outputs": [ + { + "internalType": "enum GIstates", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "MIN_T1_MODELS_PER_BATCH", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "T1_AGGREGATORS_PER_BATCH", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "T1_MODELS_PER_BATCH", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_GI", + "type": "uint256" + } + ], + "name": "autoCreateTier1AndTier2", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_GI", + "type": "uint256" + } + ], + "name": "closeDINaggregatorsRegistration", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_GI", + "type": "uint256" + } + ], + "name": "closeDINauditorsRegistration", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_GI", + "type": "uint256" + } + ], + "name": "closeLMsubmissions", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_GI", + "type": "uint256" + } + ], + "name": "closeLMsubmissionsEvaluation", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_GI", + "type": "uint256" + } + ], + "name": "createAuditorsBatches", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "name": "dinAggregators", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "dinTaskAuditorContract", + "outputs": [ + { + "internalType": "contract IDINTaskAuditor", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "dinvalidatorStakeContract", + "outputs": [ + { + "internalType": "contract IDinValidatorStake", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_GI", + "type": "uint256" + } + ], + "name": "endGI", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_GI", + "type": "uint256" + } + ], + "name": "finalizeT1Aggregation", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_GI", + "type": "uint256" + } + ], + "name": "finalizeT2Aggregation", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "genesisModelIpfsHash", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_GI", + "type": "uint256" + } + ], + "name": "getDINtaskAggregators", + "outputs": [ + { + "internalType": "address[]", + "name": "", + "type": "address[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_GI", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_id", + "type": "uint256" + } + ], + "name": "getTier1Batch", + "outputs": [ + { + "internalType": "uint256", + "name": "batchId", + "type": "uint256" + }, + { + "internalType": "address[]", + "name": "validators", + "type": "address[]" + }, + { + "internalType": "uint256[]", + "name": "modelIndexes", + "type": "uint256[]" + }, + { + "internalType": "bool", + "name": "finalized", + "type": "bool" + }, + { + "internalType": "bytes32", + "name": "finalCID", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_GI", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_id", + "type": "uint256" + } + ], + "name": "getTier2Batch", + "outputs": [ + { + "internalType": "uint256", + "name": "batchId", + "type": "uint256" + }, + { + "internalType": "address[]", + "name": "validators", + "type": "address[]" + }, + { + "internalType": "bool", + "name": "finalized", + "type": "bool" + }, + { + "internalType": "bytes32", + "name": "finalCID", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_GI", + "type": "uint256" + } + ], + "name": "getTier2Score", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "isDINAggregator", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "owner", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_GI", + "type": "uint256" + } + ], + "name": "registerDINaggregator", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "renounceOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "setDINTaskAuditorAsSlasher", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_dintaskauditor_contract_address", + "type": "address" + } + ], + "name": "setDINTaskAuditorContract", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "setDINTaskCoordinatorAsSlasher", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "_genesisModelIpfsHash", + "type": "bytes32" + } + ], + "name": "setGenesisModelIpfsHash", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_GI", + "type": "uint256" + }, + { + "internalType": "bool", + "name": "flag", + "type": "bool" + } + ], + "name": "setTestDataAssignedFlag", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_GI", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_score", + "type": "uint256" + } + ], + "name": "setTier2Score", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_GI", + "type": "uint256" + } + ], + "name": "slashAggregators", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_GI", + "type": "uint256" + } + ], + "name": "slashAuditors", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_GI", + "type": "uint256" + } + ], + "name": "startDINaggregatorsRegistration", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_GI", + "type": "uint256" + } + ], + "name": "startDINauditorsRegistration", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_GI", + "type": "uint256" + } + ], + "name": "startGI", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_GI", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "score", + "type": "uint256" + } + ], + "name": "startGI", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_GI", + "type": "uint256" + } + ], + "name": "startLMsubmissions", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_GI", + "type": "uint256" + } + ], + "name": "startLMsubmissionsEvaluation", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_GI", + "type": "uint256" + } + ], + "name": "startT1Aggregation", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_GI", + "type": "uint256" + } + ], + "name": "startT2Aggregation", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_GI", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_batchId", + "type": "uint256" + }, + { + "internalType": "bytes32", + "name": "_aggregationCID", + "type": "bytes32" + } + ], + "name": "submitT1Aggregation", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_GI", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_batchId", + "type": "uint256" + }, + { + "internalType": "bytes32", + "name": "_aggregationCID", + "type": "bytes32" + } + ], + "name": "submitT2Aggregation", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "t1SubmissionCID", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "t1Submitted", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "name": "t1Votes", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "t2SubmissionCID", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "t2Submitted", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "name": "t2Votes", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_GI", + "type": "uint256" + } + ], + "name": "tier1BatchCount", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "name": "tier1Batches", + "outputs": [ + { + "internalType": "uint256", + "name": "batchId", + "type": "uint256" + }, + { + "internalType": "bool", + "name": "finalized", + "type": "bool" + }, + { + "internalType": "bytes32", + "name": "finalCID", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "name": "tier2Batches", + "outputs": [ + { + "internalType": "uint256", + "name": "batchId", + "type": "uint256" + }, + { + "internalType": "bool", + "name": "finalized", + "type": "bool" + }, + { + "internalType": "bytes32", + "name": "finalCID", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "name": "tier2Score", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "transferOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + } + ] +} \ No newline at end of file diff --git a/subgraph/subgraph.yaml b/subgraph/subgraph.yaml index 408eb3ef..8b327ac3 100644 --- a/subgraph/subgraph.yaml +++ b/subgraph/subgraph.yaml @@ -1,9 +1,9 @@ specVersion: 0.0.5 description: > - DIN Protocol platform-contract subgraph. Indexes DINModelRegistry, - DinValidatorStake, DinCoordinator, and DinToken on the local development - chain (chainId 1337). Task-level contracts are out of scope — deferred to - P5+ dynamic data sources. + DIN Protocol subgraph. Indexes the four platform contracts (DINModelRegistry, + DinValidatorStake, DinCoordinator, DinToken) and task-level contracts + (DINTaskCoordinator, DINTaskAuditor) via dynamic data source templates on the + local development chain (chainId 1337). repository: https://github.com/InfiniteZeroFoundation/DevNet schema: file: ./schema.graphql @@ -211,3 +211,131 @@ dataSources: - event: Transfer(indexed address,indexed address,uint256) handler: handleTransfer file: ./src/token.ts + +# ───────────────────────────────────────────────────────────────────────────── +# Dynamic data source templates — task-level contracts +# +# One DINTaskCoordinator + DINTaskAuditor pair is deployed per model by the +# model owner. Template instances are created inside handleModelApproved() +# (registry.ts) when DINModelRegistry emits ModelApproved. The model ID and +# paired contract address are passed via DataSourceContext so task-level +# handlers can link entities back to the platform-level Model entity. +# +# Pending event additions (see task-contract-event-audit.md §4) are commented +# out below. Uncomment each once the corresponding event lands in the contract +# and the ABI is regenerated. +# ───────────────────────────────────────────────────────────────────────────── + +templates: + + # ── DINTaskCoordinator ────────────────────────────────────────────────────── + - kind: ethereum + name: DINTaskCoordinator + network: din-local + source: + abi: DINTaskCoordinator + mapping: + kind: ethereum/events + apiVersion: 0.0.7 + language: wasm/assemblyscript + entities: + - GlobalIteration + - AggregatorRegistration + - Tier1Batch + - Tier2Batch + - AggregatorSlashEvent + # Pending entities (populated once proposed events land): + # - GIStateTransition + # - T1AggregationSubmission + # - T2AggregationSubmission + abis: + - name: DINTaskCoordinator + file: ./abis/DINTaskCoordinator.json + # DINModelRegistry ABI needed for storage calls in handleModelApproved + # template-creation context — already referenced by the registry mapping. + eventHandlers: + # ── Aggregator registration ────────────────────────────────────────── + - event: DINValidatorRegistered(indexed uint256,indexed address) + handler: handleAggregatorRegistered + + # ── Batch creation ─────────────────────────────────────────────────── + - event: Tier1BatchAuto(indexed uint256,indexed uint256) + handler: handleTier1BatchCreated + - event: Tier2BatchAuto(indexed uint256,indexed uint256) + handler: handleTier2BatchCreated + + # ── Aggregator slashing ────────────────────────────────────────────── + - event: AggregatorSlashed(indexed uint256,indexed uint256,indexed address,bytes32,uint256,uint256) + handler: handleAggregatorSlashed + + # ── Pending — uncomment once events land in the contract ───────────── + # - event: GIStateChanged(indexed uint256,indexed uint8) + # handler: handleGIStateChanged + # - event: T1AggregationSubmitted(indexed uint256,indexed uint256,indexed address,bytes32) + # handler: handleT1AggregationSubmitted + # - event: T2AggregationSubmitted(indexed uint256,indexed uint256,indexed address,bytes32) + # handler: handleT2AggregationSubmitted + # - event: T1BatchFinalized(indexed uint256,indexed uint256,bytes32) + # handler: handleT1BatchFinalized + # - event: T2Finalized(indexed uint256,bytes32) + # handler: handleT2Finalized + file: ./src/taskCoordinator.ts + + # ── DINTaskAuditor ─────────────────────────────────────────────────────────── + - kind: ethereum + name: DINTaskAuditor + network: din-local + source: + abi: DINTaskAuditor + mapping: + kind: ethereum/events + apiVersion: 0.0.7 + language: wasm/assemblyscript + entities: + - GlobalIteration + - AuditorRegistration + - AuditBatch + - AuditScoreSubmission + - EligibilityVote + - EligibilityResult + - AuditorSlashEvent + - PassScoreUpdate + # Pending entity (populated once LocalModelSubmitted event lands): + # - LocalModelSubmission + abis: + - name: DINTaskAuditor + file: ./abis/DINTaskAuditor.json + eventHandlers: + # ── Auditor registration ───────────────────────────────────────────── + - event: DINAuditorRegistered(indexed uint256,indexed address) + handler: handleAuditorRegistered + + # ── Audit batch creation ───────────────────────────────────────────── + - event: AuditorsBatchAuto(indexed uint256,indexed uint256) + handler: handleAuditBatchCreated + - event: AuditorsBatchesCreated(indexed uint256,uint256) + handler: handleAuditorsBatchesCreated + + # ── Scoring and eligibility ────────────────────────────────────────── + - event: AuditScoreSubmitted(indexed uint256,indexed uint256,indexed address,uint256,uint256) + handler: handleAuditScoreSubmitted + - event: EligibilityVoted(indexed uint256,indexed uint256,indexed uint256,address,bool) + handler: handleEligibilityVoted + - event: EligibilityFinalized(indexed uint256,indexed uint256,indexed uint256,bool,uint256) + handler: handleEligibilityFinalized + + # ── Pass score governance ──────────────────────────────────────────── + - event: PassScoreUpdated(uint256,uint256) + handler: handlePassScoreUpdated + + # ── Auditor slashing ───────────────────────────────────────────────── + - event: AuditorSlashed(indexed uint256,indexed uint256,indexed address,bytes32,uint256,uint256) + handler: handleAuditorSlashed + + # RewardDeposited is declared in the ABI but never emitted (dead code — + # see task-contract-event-audit.md §5). No handler registered. + + # ── Pending — uncomment once LocalModelSubmitted event lands ───────── + # - event: LocalModelSubmitted(indexed uint256,indexed uint256,indexed address,bytes32) + # handler: handleLocalModelSubmitted + file: ./src/taskAuditor.ts From 77001ce7f8c8b7c7c584c1c4a4f790bfd5a7d2ee Mon Sep 17 00:00:00 2001 From: robertocarlous Date: Tue, 14 Jul 2026 00:31:44 +0100 Subject: [PATCH 44/48] feat(indexer): wire ModelApproved handler to instantiate task contract templates --- subgraph/src/registry.ts | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/subgraph/src/registry.ts b/subgraph/src/registry.ts index a31ba414..56290819 100644 --- a/subgraph/src/registry.ts +++ b/subgraph/src/registry.ts @@ -1,4 +1,4 @@ -import { BigInt } from "@graphprotocol/graph-ts" +import { BigInt, DataSourceContext, Address } from "@graphprotocol/graph-ts" import { ModelRegistrationRequested, ModelApproved, @@ -27,6 +27,10 @@ import { DAOAdminTransfer, } from "../generated/schema" import { eventId } from "./utils" +import { + DINTaskCoordinator as DINTaskCoordinatorTemplate, + DINTaskAuditor as DINTaskAuditorTemplate, +} from "../generated/templates" // ─── Registration request lifecycle ────────────────────────────────────────── @@ -92,6 +96,23 @@ export function handleModelApproved(event: ModelApproved): void { model.createdAtBlock = event.block.number model.createdAtTimestamp = event.block.timestamp model.save() + + // Spin up dynamic data source instances for this model's task contracts. + // Both templates receive the same context so handlers on either side can + // resolve the modelId and the paired contract address. + let ctx = new DataSourceContext() + ctx.setBigInt("modelId", event.params.modelId) + ctx.setBytes("taskCoordinatorAddress", model.taskCoordinator) + ctx.setBytes("taskAuditorAddress", model.taskAuditor) + + DINTaskCoordinatorTemplate.createWithContext( + Address.fromBytes(model.taskCoordinator), + ctx, + ) + DINTaskAuditorTemplate.createWithContext( + Address.fromBytes(model.taskAuditor), + ctx, + ) } export function handleModelRejected(event: ModelRejected): void { From 79de4bd7907ea00283acb75470126450bcd85358 Mon Sep 17 00:00:00 2001 From: robertocarlous Date: Tue, 14 Jul 2026 00:45:25 +0100 Subject: [PATCH 45/48] feat(indexer): add AssemblyScript handlers for DINTaskCoordinator and DINTaskAuditor templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit taskCoordinator.ts — four handlers covering aggregator registration, T1/T2 batch creation, and aggregator slashing. GlobalIteration is lazily instantiated on the first event per GI. taskAuditor.ts — eight handlers covering auditor registration, audit batch creation, scoring, eligibility voting/finalization, pass score governance, and auditor slashing. Introduces loadOrCreateLMS() which pre-creates a stub LocalModelSubmission (zero client/modelCID) on first reference so the required relationship field on AuditScoreSubmission, EligibilityVote, and EligibilityResult is never null at query time; stubs are upgraded in-place once the LocalModelSubmitted event lands. --- subgraph/src/taskAuditor.ts | 243 ++++++++++++++++++++++++++++++++ subgraph/src/taskCoordinator.ts | 115 +++++++++++++++ 2 files changed, 358 insertions(+) create mode 100644 subgraph/src/taskAuditor.ts create mode 100644 subgraph/src/taskCoordinator.ts diff --git a/subgraph/src/taskAuditor.ts b/subgraph/src/taskAuditor.ts new file mode 100644 index 00000000..22cee349 --- /dev/null +++ b/subgraph/src/taskAuditor.ts @@ -0,0 +1,243 @@ +import { BigInt, Address, Bytes, dataSource } from "@graphprotocol/graph-ts" +import { + DINAuditorRegistered, + AuditorsBatchAuto, + AuditorsBatchesCreated, + AuditScoreSubmitted, + EligibilityVoted, + EligibilityFinalized, + PassScoreUpdated, + AuditorSlashed, +} from "../generated/templates/DINTaskAuditor/DINTaskAuditor" +import { + GlobalIteration, + AuditorRegistration, + AuditBatch, + AuditScoreSubmission, + EligibilityVote, + EligibilityResult, + LocalModelSubmission, + AuditorSlashEvent, + PassScoreUpdate, +} from "../generated/schema" +import { eventId } from "./utils" + +// ─── Context helpers ────────────────────────────────────────────────────────── + +function taAddress(): Address { + return Address.fromBytes(dataSource.context().getBytes("taskAuditorAddress")) +} + +function tcAddressHex(): string { + return dataSource.context().getBytes("taskCoordinatorAddress").toHex() +} + +function modelId(): BigInt { + return dataSource.context().getBigInt("modelId") +} + +/** GlobalIteration ID uses the taskCoordinator address as the stable anchor. */ +function giId(gi: BigInt): string { + return tcAddressHex() + "-" + gi.toString() +} + +function loadOrCreateGI(gi: BigInt, blockNumber: BigInt, blockTimestamp: BigInt): GlobalIteration { + let id = giId(gi) + let entity = GlobalIteration.load(id) + if (entity == null) { + entity = new GlobalIteration(id) + entity.gi = gi + entity.taskCoordinator = dataSource.context().getBytes("taskCoordinatorAddress") + entity.model = modelId().toString() + entity.currentState = 5 // GIstarted — first reachable state after model registration + entity.globalModelCID = null + entity.ended = false + entity.startedAtBlock = blockNumber + entity.startedAtTimestamp = blockTimestamp + entity.endedAtBlock = null + entity.endedAtTimestamp = null + entity.save() + } + return entity! +} + +// Creates a stub LocalModelSubmission so that required relationship fields on +// AuditScoreSubmission / EligibilityVote / EligibilityResult are never null. +// The stub has zero-value client and modelCID until the LocalModelSubmitted +// event lands (audit §4.2) and populates the real values. +function loadOrCreateLMS( + gi: BigInt, + modelIndex: BigInt, + giEntity: GlobalIteration, + blockNumber: BigInt, + blockTimestamp: BigInt, + txHash: Bytes, +): LocalModelSubmission { + let id = taAddress().toHex() + "-" + gi.toString() + "-" + modelIndex.toString() + let entity = LocalModelSubmission.load(id) + if (entity == null) { + entity = new LocalModelSubmission(id) + entity.globalIteration = giEntity.id + entity.modelIndex = modelIndex + entity.client = Address.zero() + entity.modelCID = Bytes.fromI32(0) + entity.eligible = null + entity.finalAvgScore = null + entity.approved = null + entity.blockNumber = blockNumber + entity.blockTimestamp = blockTimestamp + entity.transactionHash = txHash + entity.save() + } + return entity! +} + +function auditBatchId(gi: BigInt, batchId: BigInt): string { + return taAddress().toHex() + "-" + gi.toString() + "-" + batchId.toString() +} + +function loadOrCreateAuditBatch( + gi: BigInt, + batchId: BigInt, + giEntity: GlobalIteration, + blockNumber: BigInt, + blockTimestamp: BigInt, +): AuditBatch { + let id = auditBatchId(gi, batchId) + let entity = AuditBatch.load(id) + if (entity == null) { + entity = new AuditBatch(id) + entity.globalIteration = giEntity.id + entity.batchId = batchId + entity.createdAtBlock = blockNumber + entity.createdAtTimestamp = blockTimestamp + entity.save() + } + return entity! +} + +// ─── Auditor registration ────────────────────────────────────────────────────── + +export function handleAuditorRegistered(event: DINAuditorRegistered): void { + let gi = loadOrCreateGI(event.params.GI, event.block.number, event.block.timestamp) + + let id = taAddress().toHex() + "-" + event.params.GI.toString() + "-" + event.params.auditor.toHex() + let reg = new AuditorRegistration(id) + reg.globalIteration = gi.id + reg.auditor = event.params.auditor + reg.blockNumber = event.block.number + reg.blockTimestamp = event.block.timestamp + reg.transactionHash = event.transaction.hash + reg.save() +} + +// ─── Audit batch creation ────────────────────────────────────────────────────── + +export function handleAuditBatchCreated(event: AuditorsBatchAuto): void { + let gi = loadOrCreateGI(event.params.GI, event.block.number, event.block.timestamp) + loadOrCreateAuditBatch(event.params.GI, event.params.batchId, gi, event.block.number, event.block.timestamp) +} + +// AuditorsBatchesCreated is a summary event — all individual batches are +// already created by handleAuditBatchCreated above. No new entity needed. +export function handleAuditorsBatchesCreated(event: AuditorsBatchesCreated): void { + // Ensure the GI anchor exists even if no AuditorsBatchAuto fired yet + // (defensive; in practice they are emitted in the same tx). + loadOrCreateGI(event.params.GI, event.block.number, event.block.timestamp) +} + +// ─── Scoring ─────────────────────────────────────────────────────────────────── + +export function handleAuditScoreSubmitted(event: AuditScoreSubmitted): void { + let gi = loadOrCreateGI(event.params.gi, event.block.number, event.block.timestamp) + let batch = loadOrCreateAuditBatch(event.params.gi, event.params.batchId, gi, event.block.number, event.block.timestamp) + let lms = loadOrCreateLMS(event.params.gi, event.params.modelIndex, gi, event.block.number, event.block.timestamp, event.transaction.hash) + + let id = taAddress().toHex() + "-" + event.params.gi.toString() + "-" + + event.params.batchId.toString() + "-" + event.params.auditor.toHex() + "-" + + event.params.modelIndex.toString() + let sub = new AuditScoreSubmission(id) + sub.auditBatch = batch.id + sub.localModelSubmission = lms.id + sub.auditor = event.params.auditor + sub.score = event.params.score + sub.blockNumber = event.block.number + sub.blockTimestamp = event.block.timestamp + sub.transactionHash = event.transaction.hash + sub.save() +} + +// ─── Eligibility ─────────────────────────────────────────────────────────────── + +export function handleEligibilityVoted(event: EligibilityVoted): void { + let gi = loadOrCreateGI(event.params.gi, event.block.number, event.block.timestamp) + let batch = loadOrCreateAuditBatch(event.params.gi, event.params.batchId, gi, event.block.number, event.block.timestamp) + let lms = loadOrCreateLMS(event.params.gi, event.params.modelIndex, gi, event.block.number, event.block.timestamp, event.transaction.hash) + + let id = taAddress().toHex() + "-" + event.params.gi.toString() + "-" + + event.params.batchId.toString() + "-" + event.params.modelIndex.toString() + "-" + + event.params.auditor.toHex() + let vote = new EligibilityVote(id) + vote.auditBatch = batch.id + vote.localModelSubmission = lms.id + vote.auditor = event.params.auditor + vote.vote = event.params.vote + vote.blockNumber = event.block.number + vote.blockTimestamp = event.block.timestamp + vote.transactionHash = event.transaction.hash + vote.save() +} + +export function handleEligibilityFinalized(event: EligibilityFinalized): void { + let gi = loadOrCreateGI(event.params.gi, event.block.number, event.block.timestamp) + let batch = loadOrCreateAuditBatch(event.params.gi, event.params.batchId, gi, event.block.number, event.block.timestamp) + let lms = loadOrCreateLMS(event.params.gi, event.params.modelIndex, gi, event.block.number, event.block.timestamp, event.transaction.hash) + + let id = taAddress().toHex() + "-" + event.params.gi.toString() + "-" + + event.params.batchId.toString() + "-" + event.params.modelIndex.toString() + let result = new EligibilityResult(id) + result.auditBatch = batch.id + result.localModelSubmission = lms.id + result.eligible = event.params.eligible + result.totalVotes = event.params.totalVotes + result.blockNumber = event.block.number + result.blockTimestamp = event.block.timestamp + result.transactionHash = event.transaction.hash + result.save() + + // Propagate eligible flag back to the LocalModelSubmission. + lms.eligible = event.params.eligible + lms.save() +} + +// ─── Pass score governance ───────────────────────────────────────────────────── + +export function handlePassScoreUpdated(event: PassScoreUpdated): void { + let e = new PassScoreUpdate(eventId(event.transaction.hash, event.logIndex)) + e.taskAuditor = event.address + e.oldScore = event.params.oldScore + e.newScore = event.params.newScore + e.blockNumber = event.block.number + e.blockTimestamp = event.block.timestamp + e.transactionHash = event.transaction.hash + e.save() +} + +// ─── Auditor slashing ────────────────────────────────────────────────────────── + +export function handleAuditorSlashed(event: AuditorSlashed): void { + let gi = loadOrCreateGI(event.params.gi, event.block.number, event.block.timestamp) + let batch = loadOrCreateAuditBatch(event.params.gi, event.params.batchId, gi, event.block.number, event.block.timestamp) + + let e = new AuditorSlashEvent(eventId(event.transaction.hash, event.logIndex)) + e.globalIteration = gi.id + e.auditBatch = batch.id + e.auditor = event.params.auditor + e.reason = event.params.reason + e.requestedAmount = event.params.requested + e.actualAmount = event.params.actual + e.blockNumber = event.block.number + e.blockTimestamp = event.block.timestamp + e.transactionHash = event.transaction.hash + e.save() +} diff --git a/subgraph/src/taskCoordinator.ts b/subgraph/src/taskCoordinator.ts new file mode 100644 index 00000000..7f9e06fa --- /dev/null +++ b/subgraph/src/taskCoordinator.ts @@ -0,0 +1,115 @@ +import { BigInt, dataSource } from "@graphprotocol/graph-ts" +import { + DINValidatorRegistered, + Tier1BatchAuto, + Tier2BatchAuto, + AggregatorSlashed, +} from "../generated/templates/DINTaskCoordinator/DINTaskCoordinator" +import { + GlobalIteration, + AggregatorRegistration, + Tier1Batch, + Tier2Batch, + AggregatorSlashEvent, +} from "../generated/schema" +import { eventId } from "./utils" + +// ─── Context helpers ────────────────────────────────────────────────────────── + +function modelId(): BigInt { + return dataSource.context().getBigInt("modelId") +} + +function giEntityId(gi: BigInt): string { + return dataSource.context().getBytes("taskCoordinatorAddress").toHex() + "-" + gi.toString() +} + +function loadOrCreateGI(gi: BigInt, blockNumber: BigInt, blockTimestamp: BigInt): GlobalIteration { + let id = giEntityId(gi) + let entity = GlobalIteration.load(id) + if (entity == null) { + entity = new GlobalIteration(id) + entity.gi = gi + entity.taskCoordinator = dataSource.context().getBytes("taskCoordinatorAddress") + entity.model = modelId().toString() + entity.currentState = 5 // GIstarted — first reachable state after model registration + entity.globalModelCID = null + entity.ended = false + entity.startedAtBlock = blockNumber + entity.startedAtTimestamp = blockTimestamp + entity.endedAtBlock = null + entity.endedAtTimestamp = null + entity.save() + } + return entity! +} + +// ─── Aggregator registration ─────────────────────────────────────────────────── + +export function handleAggregatorRegistered(event: DINValidatorRegistered): void { + let gi = loadOrCreateGI(event.params.GI, event.block.number, event.block.timestamp) + + let tcAddress = dataSource.context().getBytes("taskCoordinatorAddress") + let id = tcAddress.toHex() + "-" + event.params.GI.toString() + "-" + event.params.validator.toHex() + let reg = new AggregatorRegistration(id) + reg.globalIteration = gi.id + reg.validator = event.params.validator + reg.blockNumber = event.block.number + reg.blockTimestamp = event.block.timestamp + reg.transactionHash = event.transaction.hash + reg.save() +} + +// ─── Batch creation ──────────────────────────────────────────────────────────── + +export function handleTier1BatchCreated(event: Tier1BatchAuto): void { + let gi = loadOrCreateGI(event.params.GI, event.block.number, event.block.timestamp) + + let tcAddress = dataSource.context().getBytes("taskCoordinatorAddress") + let id = tcAddress.toHex() + "-" + event.params.GI.toString() + "-" + event.params.batchId.toString() + let batch = new Tier1Batch(id) + batch.globalIteration = gi.id + batch.batchId = event.params.batchId + batch.finalized = false + batch.winningCID = null + batch.createdAtBlock = event.block.number + batch.createdAtTimestamp = event.block.timestamp + batch.finalizedAtBlock = null + batch.finalizedAtTimestamp = null + batch.save() +} + +export function handleTier2BatchCreated(event: Tier2BatchAuto): void { + let gi = loadOrCreateGI(event.params.GI, event.block.number, event.block.timestamp) + + // batchId is always 0 — ID uses gi only to stay unique per model iteration + let tcAddress = dataSource.context().getBytes("taskCoordinatorAddress") + let id = tcAddress.toHex() + "-" + event.params.GI.toString() + let batch = new Tier2Batch(id) + batch.globalIteration = gi.id + batch.finalized = false + batch.globalModelCID = null + batch.createdAtBlock = event.block.number + batch.createdAtTimestamp = event.block.timestamp + batch.finalizedAtBlock = null + batch.finalizedAtTimestamp = null + batch.save() +} + +// ─── Aggregator slashing ─────────────────────────────────────────────────────── + +export function handleAggregatorSlashed(event: AggregatorSlashed): void { + let gi = loadOrCreateGI(event.params.GI, event.block.number, event.block.timestamp) + + let e = new AggregatorSlashEvent(eventId(event.transaction.hash, event.logIndex)) + e.globalIteration = gi.id + e.batchId = event.params.batchId + e.aggregator = event.params.aggregator + e.reason = event.params.reason + e.requestedAmount = event.params.requested + e.actualAmount = event.params.actual + e.blockNumber = event.block.number + e.blockTimestamp = event.block.timestamp + e.transactionHash = event.transaction.hash + e.save() +} From cc4ad431a4772f7a7da447f50b9906dcbee992f2 Mon Sep 17 00:00:00 2001 From: robertocarlous Date: Tue, 4 Aug 2026 13:33:13 +0100 Subject: [PATCH 46/48] chore(indexer): document 1000-result GraphQL cap; add update-subgraph-addresses.sh --- dincli/cli/dinrep.py | 6 ++ subgraph/scripts/update-subgraph-addresses.sh | 78 +++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100755 subgraph/scripts/update-subgraph-addresses.sh diff --git a/dincli/cli/dinrep.py b/dincli/cli/dinrep.py index 2e4030ba..99af2529 100644 --- a/dincli/cli/dinrep.py +++ b/dincli/cli/dinrep.py @@ -405,6 +405,9 @@ def list_pending_requests(ctx: typer.Context, req_type: str = typer.Option(None, if normalized_type in (None, "model"): console.print("[bold cyan]Pending Model Registration Requests:[/bold cyan]") found_model = False + # Known limit: subgraph silently truncates results beyond the first + # 1000 unprocessed requests. Fine for DevNet scale; the RPC-fallback + # path below does not share this cap. gql_data = _query_subgraph( "{ modelRegistrationRequests(where: { processed: false }, first: 1000)" " { requestId requester isOpenSource feePaid } }" @@ -434,6 +437,9 @@ def list_pending_requests(ctx: typer.Context, req_type: str = typer.Option(None, if normalized_type in (None, "manifest"): console.print("[bold cyan]Pending Manifest Update Requests:[/bold cyan]") found_manifest = False + # Known limit: subgraph silently truncates results beyond the first + # 1000 unprocessed requests. Fine for DevNet scale; the RPC-fallback + # path below does not share this cap. gql_data = _query_subgraph( "{ manifestUpdateRequests(where: { processed: false }, first: 1000)" " { requestId model { modelId } requester feePaid } }" diff --git a/subgraph/scripts/update-subgraph-addresses.sh b/subgraph/scripts/update-subgraph-addresses.sh new file mode 100755 index 00000000..3429e0ed --- /dev/null +++ b/subgraph/scripts/update-subgraph-addresses.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# update-subgraph-addresses.sh +# Patches subgraph/subgraph.yaml's placeholder platform-contract addresses +# with the real addresses from a deployments JSON (foundry or hardhat schema). +# +# Usage: +# ./scripts/update-subgraph-addresses.sh [path/to/deployments.json] +# +# Defaults to foundry/deployments/localhost.json, falling back to +# hardhat/deployments/localhost.json if the first doesn't exist. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SUBGRAPH_YAML="$SCRIPT_DIR/../subgraph.yaml" +REPO_ROOT="$SCRIPT_DIR/../.." + +DEPLOYMENTS_JSON="${1:-}" +if [[ -z "$DEPLOYMENTS_JSON" ]]; then + if [[ -f "$REPO_ROOT/foundry/deployments/localhost.json" ]]; then + DEPLOYMENTS_JSON="$REPO_ROOT/foundry/deployments/localhost.json" + elif [[ -f "$REPO_ROOT/hardhat/deployments/localhost.json" ]]; then + DEPLOYMENTS_JSON="$REPO_ROOT/hardhat/deployments/localhost.json" + else + echo "error: no deployments JSON found. Deploy the platform contracts first" >&2 + echo " (foundry: forge script script/DeployPlatform.s.sol ...," >&2 + echo " hardhat: npx hardhat run scripts/deploy.ts --network localhost)" >&2 + echo "or pass an explicit path: $0 " >&2 + exit 1 + fi +fi + +if ! command -v jq >/dev/null 2>&1; then + echo "error: jq is required (https://jqlang.github.io/jq/)" >&2 + exit 1 +fi + +echo "Reading addresses from $DEPLOYMENTS_JSON" + +# yaml "name:" value -> key in the deployments JSON. Plain array of pairs +# ("contract key") — associative arrays require bash 4, which macOS does not ship. +CONTRACT_KEY_PAIRS=( + DINModelRegistry dinModelRegistry + DinValidatorStake dinValidatorStake + DinCoordinator dinCoordinator + DinToken dinToken +) + +TMP="$(mktemp)" +cp "$SUBGRAPH_YAML" "$TMP" + +for ((i = 0; i < ${#CONTRACT_KEY_PAIRS[@]}; i += 2)); do + contract="${CONTRACT_KEY_PAIRS[$i]}" + key="${CONTRACT_KEY_PAIRS[$i + 1]}" + address="$(jq -r --arg k "$key" '.[$k] // empty' "$DEPLOYMENTS_JSON")" + if [[ -z "$address" ]]; then + echo "error: key '$key' not found in $DEPLOYMENTS_JSON" >&2 + rm -f "$TMP" + exit 1 + fi + + # Scope the replacement to this data source's block: find "name: ", + # then the next "address:" line after it, and patch only that one line. + name_line="$(grep -n "name: $contract\$" "$TMP" | head -1 | cut -d: -f1)" + if [[ -z "$name_line" ]]; then + echo "warning: data source '$contract' not found in subgraph.yaml, skipping" >&2 + continue + fi + offset="$(tail -n "+$name_line" "$TMP" | grep -n "address:" | head -1 | cut -d: -f1)" + address_line=$((name_line + offset - 1)) + + sed -i.bak "${address_line}s|address: \".*\"|address: \"$address\"|" "$TMP" + rm -f "$TMP.bak" + echo " $contract -> $address" +done + +mv "$TMP" "$SUBGRAPH_YAML" +echo "Updated $SUBGRAPH_YAML" From 461ea726c1e541637046ea393433d4efb52c7a3f Mon Sep 17 00:00:00 2001 From: robertocarlous Date: Tue, 4 Aug 2026 15:28:53 +0100 Subject: [PATCH 47/48] feat(indexer): wire P3-staking events into subgraph (#65) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merging feat/p3-staking (PR #65) into the indexer branch introduced six new DinValidatorStake events and changed MIN_STAKE from a constant to a governable storage variable. This commit brings the subgraph up to date: - abis/DinValidatorStake.json: regenerated from compiled P3 contracts - subgraph.yaml: register handlers for ValidatorJailed, ValidatorReactivated, MinStakeUpdated, UnbondingPeriodUpdated, ModelStakeBoundsUpdated, MaxConcurrentRegistrationsPerStakeUnitUpdated - staking.ts: add handleValidatorJailed, handleValidatorReactivated, handleMinStakeUpdated, handleUnbondingPeriodUpdated, handleModelStakeBoundsUpdated, handleMaxConcurrentRegistrationsPerStakeUnitUpdated; introduce StakeGovernance singleton and ModelStakeBounds entity writes - utils.ts: syncValidatorStatus gains a Jailed branch (jailedUntil != null), reads the current floor from StakeGovernance via getMinStake() instead of a hardcoded constant — no silent auto-return from Jailed, mirroring the contract's _syncValidatorStatus() semantics - schema.graphql: add jailedUntil field and jailEvents relation to Validator, new JailEvent entity, StakeGovernance singleton type, ModelStakeBounds type --- subgraph/abis/DinValidatorStake.json | 1050 +++++++++++++++++--------- subgraph/schema.graphql | 73 +- subgraph/src/staking.ts | 97 +++ subgraph/src/utils.ts | 29 +- subgraph/subgraph.yaml | 15 + 5 files changed, 884 insertions(+), 380 deletions(-) diff --git a/subgraph/abis/DinValidatorStake.json b/subgraph/abis/DinValidatorStake.json index cb285322..633a4523 100644 --- a/subgraph/abis/DinValidatorStake.json +++ b/subgraph/abis/DinValidatorStake.json @@ -1,639 +1,941 @@ { "abi": [ { - "inputs": [ - { - "internalType": "address", - "name": "dinToken", - "type": "address" - }, - { - "internalType": "address", - "name": "dinCoordinator", - "type": "address" - } - ], - "stateMutability": "nonpayable", - "type": "constructor" - }, - { - "inputs": [], - "name": "AmountLessThanMinStake", - "type": "error" - }, - { - "inputs": [], - "name": "InvalidAddress", - "type": "error" - }, - { - "inputs": [], - "name": "InvalidSlashAmount", - "type": "error" - }, - { + "type": "constructor", "inputs": [], - "name": "InvalidUnstakeAmount", - "type": "error" + "stateMutability": "nonpayable" }, { + "type": "function", + "name": "DIN_COORDINATOR", "inputs": [], - "name": "NoPendingWithdrawal", - "type": "error" + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" }, { + "type": "function", + "name": "DIN_TOKEN", "inputs": [], - "name": "NotDINCoordinator", - "type": "error" + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract IERC20" + } + ], + "stateMutability": "view" }, { + "type": "function", + "name": "MIN_STAKE", "inputs": [], - "name": "NotEnoughStake", - "type": "error" + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" }, { + "type": "function", + "name": "UNBONDING_PERIOD", "inputs": [], - "name": "NotSlasherContract", - "type": "error" + "outputs": [ + { + "name": "", + "type": "uint64", + "internalType": "uint64" + } + ], + "stateMutability": "view" }, { + "type": "function", + "name": "addSlasherContract", "inputs": [ { - "internalType": "address", - "name": "owner", - "type": "address" + "name": "slasherContract", + "type": "address", + "internalType": "address" } ], - "name": "OwnableInvalidOwner", - "type": "error" + "outputs": [], + "stateMutability": "nonpayable" }, { + "type": "function", + "name": "blacklistValidator", "inputs": [ { - "internalType": "address", - "name": "account", - "type": "address" + "name": "validator", + "type": "address", + "internalType": "address" } ], - "name": "OwnableUnauthorizedAccount", - "type": "error" + "outputs": [], + "stateMutability": "nonpayable" }, { + "type": "function", + "name": "claimUnstaked", "inputs": [], - "name": "PendingWithdrawalExists", - "type": "error" + "outputs": [], + "stateMutability": "nonpayable" }, { - "inputs": [], - "name": "ReentrancyGuardReentrantCall", - "type": "error" + "type": "function", + "name": "getStake", + "inputs": [ + { + "name": "validator", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" }, { + "type": "function", + "name": "initialize", "inputs": [ { - "internalType": "address", - "name": "token", - "type": "address" + "name": "dinToken", + "type": "address", + "internalType": "address" + }, + { + "name": "dinCoordinator", + "type": "address", + "internalType": "address" } ], - "name": "SafeERC20FailedOperation", - "type": "error" + "outputs": [], + "stateMutability": "nonpayable" }, { - "inputs": [], - "name": "SlasherContractAlreadyAdded", - "type": "error" + "type": "function", + "name": "isSlasherContract", + "inputs": [ + { + "name": "slasherContract", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" }, { - "inputs": [], - "name": "SlasherContractNotAdded", - "type": "error" + "type": "function", + "name": "isValidatorActive", + "inputs": [ + { + "name": "validator", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" }, { - "inputs": [], - "name": "ValidatorIsBlacklisted", - "type": "error" + "type": "function", + "name": "jailValidator", + "inputs": [ + { + "name": "validator", + "type": "address", + "internalType": "address" + }, + { + "name": "duration", + "type": "uint64", + "internalType": "uint64" + }, + { + "name": "reason", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [], + "stateMutability": "nonpayable" }, { + "type": "function", + "name": "maxConcurrentRegistrationsPerStakeUnit", "inputs": [], - "name": "ValidatorNotBlacklisted", - "type": "error" + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" }, { + "type": "function", + "name": "minStake", "inputs": [], - "name": "WithdrawalNotReady", - "type": "error" + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" }, { - "anonymous": false, + "type": "function", + "name": "modelMinStakeBounds", "inputs": [ { - "indexed": true, - "internalType": "address", - "name": "previousOwner", - "type": "address" + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "min", + "type": "uint256", + "internalType": "uint256" }, { - "indexed": true, - "internalType": "address", - "name": "newOwner", - "type": "address" + "name": "max", + "type": "uint256", + "internalType": "uint256" } ], - "name": "OwnershipTransferred", - "type": "event" + "stateMutability": "view" }, { - "anonymous": false, - "inputs": [ + "type": "function", + "name": "owner", + "inputs": [], + "outputs": [ { - "indexed": true, - "internalType": "address", - "name": "slasher", - "type": "address" + "name": "", + "type": "address", + "internalType": "address" } ], - "name": "SlasherContractAdded", - "type": "event" + "stateMutability": "view" + }, + { + "type": "function", + "name": "reactivate", + "inputs": [], + "outputs": [], + "stateMutability": "nonpayable" }, { - "anonymous": false, + "type": "function", + "name": "removeSlasherContract", "inputs": [ { - "indexed": true, - "internalType": "address", - "name": "slasher", - "type": "address" + "name": "slasherContract", + "type": "address", + "internalType": "address" } ], - "name": "SlasherContractRemoved", - "type": "event" + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "renounceOwnership", + "inputs": [], + "outputs": [], + "stateMutability": "nonpayable" }, { - "anonymous": false, + "type": "function", + "name": "setMaxConcurrentRegistrationsPerStakeUnit", "inputs": [ { - "indexed": true, - "internalType": "address", - "name": "validator", - "type": "address" + "name": "value", + "type": "uint256", + "internalType": "uint256" } ], - "name": "ValidatorBlacklisted", - "type": "event" + "outputs": [], + "stateMutability": "nonpayable" }, { - "anonymous": false, + "type": "function", + "name": "setMinStake", "inputs": [ { - "indexed": true, - "internalType": "address", - "name": "validator", - "type": "address" - }, - { - "indexed": false, - "internalType": "uint256", - "name": "amount", - "type": "uint256" - }, - { - "indexed": true, - "internalType": "bytes32", - "name": "reason", - "type": "bytes32" - }, - { - "indexed": true, - "internalType": "address", - "name": "slasher", - "type": "address" + "name": "newMinStake", + "type": "uint256", + "internalType": "uint256" } ], - "name": "ValidatorSlashed", - "type": "event" + "outputs": [], + "stateMutability": "nonpayable" }, { - "anonymous": false, + "type": "function", + "name": "setModelStakeBounds", "inputs": [ { - "indexed": true, - "internalType": "address", - "name": "validator", - "type": "address" + "name": "modelId", + "type": "uint256", + "internalType": "uint256" }, { - "indexed": false, - "internalType": "uint256", - "name": "amount", - "type": "uint256" + "name": "min", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "max", + "type": "uint256", + "internalType": "uint256" } ], - "name": "ValidatorStaked", - "type": "event" + "outputs": [], + "stateMutability": "nonpayable" }, { - "anonymous": false, + "type": "function", + "name": "setUnbondingPeriod", "inputs": [ { - "indexed": true, - "internalType": "address", - "name": "validator", - "type": "address" + "name": "newPeriod", + "type": "uint64", + "internalType": "uint64" } ], - "name": "ValidatorUnblacklisted", - "type": "event" + "outputs": [], + "stateMutability": "nonpayable" }, { - "anonymous": false, + "type": "function", + "name": "slash", "inputs": [ { - "indexed": true, - "internalType": "address", "name": "validator", - "type": "address" + "type": "address", + "internalType": "address" }, { - "indexed": false, - "internalType": "uint256", "name": "amount", - "type": "uint256" + "type": "uint256", + "internalType": "uint256" }, { - "indexed": false, - "internalType": "uint64", - "name": "withdrawAvailableAt", - "type": "uint64" + "name": "reason", + "type": "bytes32", + "internalType": "bytes32" } ], - "name": "ValidatorUnstakeRequested", - "type": "event" + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "nonpayable" }, { - "anonymous": false, + "type": "function", + "name": "slashableStakeOf", "inputs": [ { - "indexed": true, - "internalType": "address", "name": "validator", - "type": "address" - }, - { - "indexed": false, - "internalType": "uint256", - "name": "amount", - "type": "uint256" + "type": "address", + "internalType": "address" } ], - "name": "ValidatorWithdrawalClaimed", - "type": "event" - }, - { - "inputs": [], - "name": "DIN_COORDINATOR", "outputs": [ { - "internalType": "address", "name": "", - "type": "address" + "type": "uint256", + "internalType": "uint256" } ], - "stateMutability": "view", - "type": "function" + "stateMutability": "view" }, { - "inputs": [], - "name": "DIN_TOKEN", - "outputs": [ + "type": "function", + "name": "slasherContracts", + "inputs": [ { - "internalType": "contract IERC20", "name": "", - "type": "address" + "type": "address", + "internalType": "address" } ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [], - "name": "MIN_STAKE", "outputs": [ { - "internalType": "uint256", "name": "", - "type": "uint256" + "type": "bool", + "internalType": "bool" } ], - "stateMutability": "view", - "type": "function" + "stateMutability": "view" }, { - "inputs": [], - "name": "UNBONDING_PERIOD", - "outputs": [ + "type": "function", + "name": "stake", + "inputs": [ { - "internalType": "uint64", - "name": "", - "type": "uint64" + "name": "amount", + "type": "uint256", + "internalType": "uint256" } ], - "stateMutability": "view", - "type": "function" + "outputs": [], + "stateMutability": "nonpayable" }, { + "type": "function", + "name": "transferOwnership", "inputs": [ { - "internalType": "address", - "name": "slasherContract", - "type": "address" + "name": "newOwner", + "type": "address", + "internalType": "address" } ], - "name": "addSlasherContract", "outputs": [], - "stateMutability": "nonpayable", - "type": "function" + "stateMutability": "nonpayable" }, { + "type": "function", + "name": "unblacklistValidator", "inputs": [ { - "internalType": "address", "name": "validator", - "type": "address" + "type": "address", + "internalType": "address" } ], - "name": "blacklistValidator", "outputs": [], - "stateMutability": "nonpayable", - "type": "function" + "stateMutability": "nonpayable" }, { - "inputs": [], - "name": "claimUnstaked", + "type": "function", + "name": "unstake", + "inputs": [ + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + } + ], "outputs": [], - "stateMutability": "nonpayable", - "type": "function" + "stateMutability": "nonpayable" }, { + "type": "function", + "name": "validators", "inputs": [ { - "internalType": "address", - "name": "validator", - "type": "address" + "name": "", + "type": "address", + "internalType": "address" } ], - "name": "getStake", "outputs": [ { - "internalType": "uint256", - "name": "", - "type": "uint256" + "name": "activeStake", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "pendingWithdrawals", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "withdrawAvailableAt", + "type": "uint64", + "internalType": "uint64" + }, + { + "name": "jailedUntil", + "type": "uint64", + "internalType": "uint64" + }, + { + "name": "status", + "type": "uint8", + "internalType": "enum DinValidatorStake.ValidatorStatus" } ], - "stateMutability": "view", - "type": "function" + "stateMutability": "view" }, { + "type": "event", + "name": "Initialized", "inputs": [ { - "internalType": "address", - "name": "slasherContract", - "type": "address" + "name": "version", + "type": "uint64", + "indexed": false, + "internalType": "uint64" } ], - "name": "isSlasherContract", - "outputs": [ + "anonymous": false + }, + { + "type": "event", + "name": "MaxConcurrentRegistrationsPerStakeUnitUpdated", + "inputs": [ { - "internalType": "bool", - "name": "", - "type": "bool" + "name": "value", + "type": "uint256", + "indexed": false, + "internalType": "uint256" } ], - "stateMutability": "view", - "type": "function" + "anonymous": false }, { + "type": "event", + "name": "MinStakeUpdated", "inputs": [ { - "internalType": "address", - "name": "validator", - "type": "address" + "name": "newMinStake", + "type": "uint256", + "indexed": false, + "internalType": "uint256" } ], - "name": "isValidatorActive", - "outputs": [ + "anonymous": false + }, + { + "type": "event", + "name": "ModelStakeBoundsUpdated", + "inputs": [ { - "internalType": "bool", - "name": "", - "type": "bool" + "name": "modelId", + "type": "uint256", + "indexed": true, + "internalType": "uint256" + }, + { + "name": "min", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + }, + { + "name": "max", + "type": "uint256", + "indexed": false, + "internalType": "uint256" } ], - "stateMutability": "view", - "type": "function" + "anonymous": false }, { - "inputs": [], - "name": "minStake", - "outputs": [ + "type": "event", + "name": "OwnershipTransferred", + "inputs": [ { - "internalType": "uint256", - "name": "", - "type": "uint256" + "name": "previousOwner", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "newOwner", + "type": "address", + "indexed": true, + "internalType": "address" } ], - "stateMutability": "pure", - "type": "function" + "anonymous": false }, { - "inputs": [], - "name": "owner", - "outputs": [ + "type": "event", + "name": "SlasherContractAdded", + "inputs": [ { - "internalType": "address", - "name": "", - "type": "address" + "name": "slasher", + "type": "address", + "indexed": true, + "internalType": "address" } ], - "stateMutability": "view", - "type": "function" + "anonymous": false }, { + "type": "event", + "name": "SlasherContractRemoved", "inputs": [ { - "internalType": "address", - "name": "slasherContract", - "type": "address" + "name": "slasher", + "type": "address", + "indexed": true, + "internalType": "address" } ], - "name": "removeSlasherContract", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" + "anonymous": false }, { - "inputs": [], - "name": "renounceOwnership", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" + "type": "event", + "name": "UnbondingPeriodUpdated", + "inputs": [ + { + "name": "newPeriod", + "type": "uint64", + "indexed": false, + "internalType": "uint64" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "ValidatorBlacklisted", + "inputs": [ + { + "name": "validator", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false }, { + "type": "event", + "name": "ValidatorJailed", "inputs": [ { - "internalType": "address", "name": "validator", - "type": "address" + "type": "address", + "indexed": true, + "internalType": "address" }, { - "internalType": "uint256", - "name": "amount", - "type": "uint256" + "name": "jailedUntil", + "type": "uint64", + "indexed": false, + "internalType": "uint64" }, { - "internalType": "bytes32", "name": "reason", - "type": "bytes32" - } - ], - "name": "slash", - "outputs": [ + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + }, { - "internalType": "uint256", - "name": "", - "type": "uint256" + "name": "slasher", + "type": "address", + "indexed": true, + "internalType": "address" } ], - "stateMutability": "nonpayable", - "type": "function" + "anonymous": false }, { + "type": "event", + "name": "ValidatorReactivated", "inputs": [ { - "internalType": "address", "name": "validator", - "type": "address" - } - ], - "name": "slashableStakeOf", - "outputs": [ - { - "internalType": "uint256", - "name": "", - "type": "uint256" + "type": "address", + "indexed": true, + "internalType": "address" } ], - "stateMutability": "view", - "type": "function" + "anonymous": false }, { + "type": "event", + "name": "ValidatorSlashed", "inputs": [ { - "internalType": "address", - "name": "", - "type": "address" - } - ], - "name": "slasherContracts", - "outputs": [ + "name": "validator", + "type": "address", + "indexed": true, + "internalType": "address" + }, { - "internalType": "bool", - "name": "", - "type": "bool" + "name": "amount", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + }, + { + "name": "reason", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + }, + { + "name": "slasher", + "type": "address", + "indexed": true, + "internalType": "address" } ], - "stateMutability": "view", - "type": "function" + "anonymous": false }, { + "type": "event", + "name": "ValidatorStaked", "inputs": [ { - "internalType": "uint256", + "name": "validator", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { "name": "amount", - "type": "uint256" + "type": "uint256", + "indexed": false, + "internalType": "uint256" } ], - "name": "stake", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" + "anonymous": false }, { + "type": "event", + "name": "ValidatorUnblacklisted", "inputs": [ { - "internalType": "address", - "name": "newOwner", - "type": "address" + "name": "validator", + "type": "address", + "indexed": true, + "internalType": "address" } ], - "name": "transferOwnership", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" + "anonymous": false }, { + "type": "event", + "name": "ValidatorUnstakeRequested", "inputs": [ { - "internalType": "address", "name": "validator", - "type": "address" + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + }, + { + "name": "withdrawAvailableAt", + "type": "uint64", + "indexed": false, + "internalType": "uint64" } ], - "name": "unblacklistValidator", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" + "anonymous": false }, { + "type": "event", + "name": "ValidatorWithdrawalClaimed", "inputs": [ { - "internalType": "uint256", + "name": "validator", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { "name": "amount", - "type": "uint256" + "type": "uint256", + "indexed": false, + "internalType": "uint256" } ], - "name": "unstake", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" + "anonymous": false + }, + { + "type": "error", + "name": "AmountLessThanMinStake", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidAddress", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidInitialization", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidJailDuration", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidMinStake", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidSlashAmount", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidStakeBounds", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidUnbondingPeriod", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidUnstakeAmount", + "inputs": [] + }, + { + "type": "error", + "name": "JailPeriodNotExpired", + "inputs": [] + }, + { + "type": "error", + "name": "NoPendingWithdrawal", + "inputs": [] + }, + { + "type": "error", + "name": "NotDINCoordinator", + "inputs": [] }, { + "type": "error", + "name": "NotEnoughStake", + "inputs": [] + }, + { + "type": "error", + "name": "NotInitializing", + "inputs": [] + }, + { + "type": "error", + "name": "NotJailed", + "inputs": [] + }, + { + "type": "error", + "name": "NotSlasherContract", + "inputs": [] + }, + { + "type": "error", + "name": "OwnableInvalidOwner", "inputs": [ { - "internalType": "address", - "name": "", - "type": "address" + "name": "owner", + "type": "address", + "internalType": "address" } - ], - "name": "validators", - "outputs": [ - { - "internalType": "uint256", - "name": "activeStake", - "type": "uint256" - }, - { - "internalType": "uint256", - "name": "pendingWithdrawals", - "type": "uint256" - }, - { - "internalType": "uint64", - "name": "withdrawAvailableAt", - "type": "uint64" - }, + ] + }, + { + "type": "error", + "name": "OwnableUnauthorizedAccount", + "inputs": [ { - "internalType": "uint64", - "name": "jailedUntil", - "type": "uint64" - }, + "name": "account", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "PendingWithdrawalExists", + "inputs": [] + }, + { + "type": "error", + "name": "ReentrancyGuardReentrantCall", + "inputs": [] + }, + { + "type": "error", + "name": "SafeERC20FailedOperation", + "inputs": [ { - "internalType": "enum DinValidatorStake.ValidatorStatus", - "name": "status", - "type": "uint8" + "name": "token", + "type": "address", + "internalType": "address" } - ], - "stateMutability": "view", - "type": "function" + ] + }, + { + "type": "error", + "name": "SlasherContractAlreadyAdded", + "inputs": [] + }, + { + "type": "error", + "name": "SlasherContractNotAdded", + "inputs": [] + }, + { + "type": "error", + "name": "StakeBelowFloor", + "inputs": [] + }, + { + "type": "error", + "name": "ValidatorIsBlacklisted", + "inputs": [] + }, + { + "type": "error", + "name": "ValidatorNotBlacklisted", + "inputs": [] + }, + { + "type": "error", + "name": "WithdrawalNotReady", + "inputs": [] } ] } \ No newline at end of file diff --git a/subgraph/schema.graphql b/subgraph/schema.graphql index aeadc110..36fee210 100644 --- a/subgraph/schema.graphql +++ b/subgraph/schema.graphql @@ -202,10 +202,14 @@ type Validator @entity { blacklisted: Boolean! + "Timestamp until which the validator is jailed, or null if not jailed. Cleared on reactivation." + jailedUntil: BigInt + stakeEvents: [StakeEvent!]! @derivedFrom(field: "validator") slashEvents: [SlashEvent!]! @derivedFrom(field: "validator") unstakeRequests: [UnstakeRequest!]! @derivedFrom(field: "validator") withdrawalClaims: [WithdrawalClaim!]! @derivedFrom(field: "validator") + jailEvents: [JailEvent!]! @derivedFrom(field: "validator") } # ───────────────────────────────────────────────────────────────────────────── @@ -271,6 +275,71 @@ type WithdrawalClaim @entity { transactionHash: Bytes! } +""" +A jail applied to a validator (extend-only). A validator stays Jailed until +they call reactivate() after the jail period expires — there is no silent +auto-return (see DinValidatorStake._syncValidatorStatus()). +""" +type JailEvent @entity { + "txHash-logIndex" + id: ID! + + validator: Validator! + + "Timestamp until which the validator is jailed" + jailedUntil: BigInt! + + "Arbitrary slasher-supplied reason (e.g. keccak('offline'))" + reason: Bytes! + + "Address of the slasher contract that issued the jail" + slasherContract: Bytes! + + blockNumber: BigInt! + blockTimestamp: BigInt! + transactionHash: Bytes! +} + +# ───────────────────────────────────────────────────────────────────────────── +# Validator stake — governance parameters +# ───────────────────────────────────────────────────────────────────────────── + +""" +Singleton (id "singleton") mirroring the DAO-settable parameters on +DinValidatorStake. Kept up to date by the MinStakeUpdated, +UnbondingPeriodUpdated, and MaxConcurrentRegistrationsPerStakeUnitUpdated +handlers. Defaults to the initialize() values (minStake = 10 DIN, +unbondingPeriod = 7 days). +""" +type StakeGovernance @entity { + "singleton" + id: ID! + + minStake: BigInt! + unbondingPeriod: BigInt! + maxConcurrentRegistrationsPerStakeUnit: BigInt! + + updatedAtBlock: BigInt! + updatedAtTimestamp: BigInt! +} + +""" +DAO-level per-model stake bounds. Inert storage on the contract (not yet +consumed by task-contract registration); indexed here for governance +audit-trail visibility. +""" +type ModelStakeBounds @entity { + "modelId as string" + id: ID! + + modelId: BigInt! + min: BigInt! + max: BigInt! + + updatedAtBlock: BigInt! + updatedAtTimestamp: BigInt! +} + # ───────────────────────────────────────────────────────────────────────────── # Validator stake — slasher registry # ───────────────────────────────────────────────────────────────────────────── @@ -409,9 +478,9 @@ type GlobalIteration @entity { "Back-reference to the platform-level Model entity" model: Model! - "Current GIstates enum value as uint8. + """Current GIstates enum value as uint8. Populated once GIStateChanged event lands [PENDING — audit §4.1]. - Starts at 5 (GIstarted) — the first state reachable after model registration." + Starts at 5 (GIstarted) — the first state reachable after model registration.""" currentState: Int! aggregatorRegistrations: [AggregatorRegistration!]! @derivedFrom(field: "globalIteration") diff --git a/subgraph/src/staking.ts b/subgraph/src/staking.ts index 9d896c8e..452947da 100644 --- a/subgraph/src/staking.ts +++ b/subgraph/src/staking.ts @@ -6,6 +6,12 @@ import { ValidatorWithdrawalClaimed, ValidatorBlacklisted, ValidatorUnblacklisted, + ValidatorJailed, + ValidatorReactivated, + MinStakeUpdated, + UnbondingPeriodUpdated, + ModelStakeBoundsUpdated, + MaxConcurrentRegistrationsPerStakeUnitUpdated, SlasherContractAdded, SlasherContractRemoved, } from "../generated/DinValidatorStake/DinValidatorStake" @@ -14,7 +20,10 @@ import { SlashEvent, UnstakeRequest, WithdrawalClaim, + JailEvent, SlasherRegistration, + StakeGovernance, + ModelStakeBounds, } from "../generated/schema" import { eventId, loadOrCreateValidator, syncValidatorStatus } from "./utils" @@ -137,6 +146,94 @@ export function handleValidatorUnblacklisted( v.save() } +// ─── Jail / reactivate ──────────────────────────────────────────────────────── + +export function handleValidatorJailed(event: ValidatorJailed): void { + let v = loadOrCreateValidator(event.params.validator) + v.jailedUntil = BigInt.fromI64(event.params.jailedUntil as i64) + syncValidatorStatus(v) + v.save() + + let j = new JailEvent(eventId(event.transaction.hash, event.logIndex)) + j.validator = event.params.validator.toHexString() + j.jailedUntil = BigInt.fromI64(event.params.jailedUntil as i64) + j.reason = event.params.reason + j.slasherContract = event.params.slasher + j.blockNumber = event.block.number + j.blockTimestamp = event.block.timestamp + j.transactionHash = event.transaction.hash + j.save() +} + +export function handleValidatorReactivated( + event: ValidatorReactivated +): void { + let v = loadOrCreateValidator(event.params.validator) + v.jailedUntil = null + syncValidatorStatus(v) + v.save() +} + +// ─── Governable parameters ──────────────────────────────────────────────────── + +/** Load the StakeGovernance singleton, seeding initialize() defaults. */ +function loadOrCreateStakeGovernance(): StakeGovernance { + let g = StakeGovernance.load("singleton") + if (g == null) { + g = new StakeGovernance("singleton") + g.minStake = BigInt.fromString("10000000000000000000") // 10 DIN — initialize() default + g.unbondingPeriod = BigInt.fromI64(7 * 24 * 60 * 60 as i64) // 7 days + g.maxConcurrentRegistrationsPerStakeUnit = BigInt.zero() + g.updatedAtBlock = BigInt.zero() + g.updatedAtTimestamp = BigInt.zero() + } + return g! +} + +export function handleMinStakeUpdated(event: MinStakeUpdated): void { + let g = loadOrCreateStakeGovernance() + g.minStake = event.params.newMinStake + g.updatedAtBlock = event.block.number + g.updatedAtTimestamp = event.block.timestamp + g.save() +} + +export function handleUnbondingPeriodUpdated( + event: UnbondingPeriodUpdated +): void { + let g = loadOrCreateStakeGovernance() + g.unbondingPeriod = BigInt.fromI64(event.params.newPeriod as i64) + g.updatedAtBlock = event.block.number + g.updatedAtTimestamp = event.block.timestamp + g.save() +} + +export function handleMaxConcurrentRegistrationsPerStakeUnitUpdated( + event: MaxConcurrentRegistrationsPerStakeUnitUpdated +): void { + let g = loadOrCreateStakeGovernance() + g.maxConcurrentRegistrationsPerStakeUnit = event.params.value + g.updatedAtBlock = event.block.number + g.updatedAtTimestamp = event.block.timestamp + g.save() +} + +export function handleModelStakeBoundsUpdated( + event: ModelStakeBoundsUpdated +): void { + let id = event.params.modelId.toString() + let b = ModelStakeBounds.load(id) + if (b == null) { + b = new ModelStakeBounds(id) + b.modelId = event.params.modelId + } + b.min = event.params.min + b.max = event.params.max + b.updatedAtBlock = event.block.number + b.updatedAtTimestamp = event.block.timestamp + b.save() +} + // ─── Slasher registry ───────────────────────────────────────────────────────── // // DinValidatorStake is the canonical source of the active slasher set. diff --git a/subgraph/src/utils.ts b/subgraph/src/utils.ts index 383a8bc2..fd18493f 100644 --- a/subgraph/src/utils.ts +++ b/subgraph/src/utils.ts @@ -1,13 +1,27 @@ import { BigInt, Bytes } from "@graphprotocol/graph-ts" -import { Validator } from "../generated/schema" +import { StakeGovernance, Validator } from "../generated/schema" -export const MIN_STAKE = BigInt.fromString("10000000000000000000") // 10 DIN (18 decimals) +export const INITIAL_MIN_STAKE = BigInt.fromString("10000000000000000000") // 10 DIN (18 decimals) /** Stable entity ID for event-based entities: txHash-logIndex. */ export function eventId(txHash: Bytes, logIndex: BigInt): string { return txHash.toHex() + "-" + logIndex.toString() } +/** + * Current network-wide minimum stake floor, read from the StakeGovernance + * singleton. Falls back to the initialize() default (10 DIN) until the first + * MinStakeUpdated event is indexed — MIN_STAKE is DAO-settable storage now, + * not a constant (see PR #65). + */ +export function getMinStake(): BigInt { + let g = StakeGovernance.load("singleton") + if (g != null) { + return g.minStake + } + return INITIAL_MIN_STAKE +} + /** Load a Validator aggregate entity, initialising it on first encounter. */ export function loadOrCreateValidator(address: Bytes): Validator { let id = address.toHexString() @@ -23,13 +37,16 @@ export function loadOrCreateValidator(address: Bytes): Validator { v.totalWithdrawn = BigInt.zero() v.status = "None" v.blacklisted = false + v.jailedUntil = null } return v! } /** * Derive the validator status string from the aggregate's current field - * values, mirroring DinValidatorStake._syncValidatorStatus(). + * values, mirroring DinValidatorStake._syncValidatorStatus() — check order is + * Blacklisted → Jailed → Exiting/Active. A validator whose jailedUntil is set + * stays Jailed until reactivate() clears it; there is no silent auto-return. * Called after every mutation that can change stake or withdrawal state. */ export function syncValidatorStatus(v: Validator): void { @@ -37,11 +54,15 @@ export function syncValidatorStatus(v: Validator): void { v.status = "Blacklisted" return } + if (v.jailedUntil != null) { + v.status = "Jailed" + return + } if (v.pendingWithdrawal.gt(BigInt.zero())) { v.status = "Exiting" return } - if (v.activeStake.ge(MIN_STAKE)) { + if (v.activeStake.ge(getMinStake())) { v.status = "Active" return } diff --git a/subgraph/subgraph.yaml b/subgraph/subgraph.yaml index 8b327ac3..568828af 100644 --- a/subgraph/subgraph.yaml +++ b/subgraph/subgraph.yaml @@ -116,7 +116,10 @@ dataSources: - SlashEvent - UnstakeRequest - WithdrawalClaim + - JailEvent - SlasherRegistration + - StakeGovernance + - ModelStakeBounds abis: - name: DinValidatorStake file: ./abis/DinValidatorStake.json @@ -133,6 +136,18 @@ dataSources: handler: handleValidatorBlacklisted - event: ValidatorUnblacklisted(indexed address) handler: handleValidatorUnblacklisted + - event: ValidatorJailed(indexed address,uint64,indexed bytes32,indexed address) + handler: handleValidatorJailed + - event: ValidatorReactivated(indexed address) + handler: handleValidatorReactivated + - event: MinStakeUpdated(uint256) + handler: handleMinStakeUpdated + - event: UnbondingPeriodUpdated(uint64) + handler: handleUnbondingPeriodUpdated + - event: ModelStakeBoundsUpdated(indexed uint256,uint256,uint256) + handler: handleModelStakeBoundsUpdated + - event: MaxConcurrentRegistrationsPerStakeUnitUpdated(uint256) + handler: handleMaxConcurrentRegistrationsPerStakeUnitUpdated # SlasherContractAdded/Removed are emitted by both DinCoordinator and # DinValidatorStake with identical signatures (see event-coverage-audit.md §5). From d32d353fc331c3bb1885fb7b20ef6d3d29392680 Mon Sep 17 00:00:00 2001 From: robertocarlous Date: Mon, 26 Oct 2026 13:04:20 +0100 Subject: [PATCH 48/48] =?UTF-8?q?fix(tests):=20update=20test=5Flist=5Fpend?= =?UTF-8?q?ing=5Frequests=20for=20dindao=E2=86=92dinrep=20rename=20and=20T?= =?UTF-8?q?yper=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dincli/cli/dindao.py was renamed to dinrep.py on develop; update the import. Newer Typer no longer wraps @app.command functions in a TyperCommand with a .callback attribute — call the function directly. --- tests/test_list_pending_requests.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/test_list_pending_requests.py b/tests/test_list_pending_requests.py index f9a87b40..ab490027 100644 --- a/tests/test_list_pending_requests.py +++ b/tests/test_list_pending_requests.py @@ -13,7 +13,7 @@ import pytest -import dincli.cli.dindao as dindao_module +import dincli.cli.dinrep as dindao_module # --------------------------------------------------------------------------- @@ -80,7 +80,7 @@ def test_graphql_model_path(): ] } with patch.object(dindao_module._requests, "post", return_value=_make_response(fake_data)): - dindao_module.list_pending_requests.callback(ctx, req_type="model") + dindao_module.list_pending_requests(ctx, req_type="model") output = "\n".join(str(m) for m in msgs) assert "Request ID 0" in output @@ -95,7 +95,7 @@ def test_graphql_model_path_empty(): ctx, msgs, registry = _make_ctx() fake_data = {"modelRegistrationRequests": []} with patch.object(dindao_module._requests, "post", return_value=_make_response(fake_data)): - dindao_module.list_pending_requests.callback(ctx, req_type="model") + dindao_module.list_pending_requests(ctx, req_type="model") output = "\n".join(str(m) for m in msgs) assert "No pending model registration requests" in output @@ -120,7 +120,7 @@ def test_graphql_manifest_path(): ] } with patch.object(dindao_module._requests, "post", return_value=_make_response(fake_data)): - dindao_module.list_pending_requests.callback(ctx, req_type="manifest") + dindao_module.list_pending_requests(ctx, req_type="manifest") output = "\n".join(str(m) for m in msgs) assert "Request ID 0" in output @@ -140,7 +140,7 @@ def test_rpc_fallback_on_connection_error(): with patch.object( dindao_module._requests, "post", side_effect=ConnectionError("refused") ): - dindao_module.list_pending_requests.callback(ctx, req_type="model") + dindao_module.list_pending_requests(ctx, req_type="model") # RPC loop must have been invoked registry.functions.totalModelRequests.return_value.call.assert_called_once() @@ -150,7 +150,7 @@ def test_rpc_fallback_on_graphql_error_body(): ctx, msgs, registry = _make_ctx() error_resp = _make_response(errors=[{"message": "subgraph not found"}]) with patch.object(dindao_module._requests, "post", return_value=error_resp): - dindao_module.list_pending_requests.callback(ctx, req_type="model") + dindao_module.list_pending_requests(ctx, req_type="model") # GraphQL returned errors — must fall through to RPC registry.functions.totalModelRequests.return_value.call.assert_called_once() @@ -160,6 +160,6 @@ def test_rpc_fallback_on_http_error(): ctx, msgs, registry = _make_ctx() bad_resp = _make_response(status=503) with patch.object(dindao_module._requests, "post", return_value=bad_resp): - dindao_module.list_pending_requests.callback(ctx, req_type="model") + dindao_module.list_pending_requests(ctx, req_type="model") registry.functions.totalModelRequests.return_value.call.assert_called_once()