From 959c012b43b384546c0c940ecf532d5f9a941b13 Mon Sep 17 00:00:00 2001 From: robertocarlous Date: Thu, 15 Oct 2026 09:22:36 +0100 Subject: [PATCH 1/4] =?UTF-8?q?feat(tooling):=20scripts/preflight.py=20?= =?UTF-8?q?=E2=80=94=20contributor=20environment=20preflight=20check=20(#2?= =?UTF-8?q?45)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit stdlib-only script that runs before pip install and checks: 1. Python >= 3.12 2. forge / anvil on PATH (MISSING → exit 1) 3. foundry/node_modules present (WARN → npm ci) 4. eth-account, py-multibase, web3 importable (WARN → pip install -e .) 5. torch importable (WARN optional → pip install -e .[test]) 6. --network : .env. exists, RPC chainId reachable, IPFS config resolves via dincli (all WARN, never hard failures) Other changes: - .gitignore: carve out scripts/preflight.py from the /scripts/* exclusion - tests/test_preflight.py: 5 tests; missing-forge → exit nonzero (empty PATH); happy-path → exit zero in a working dev environment - Documentation/public/getting-started.md: preflight section before DIN CLI Installation and Setup - Developer/BACK_LOG.md: BL-32 added as ✅ Fixed — PR # --- .gitignore | 1 + Developer/BACK_LOG.md | 2 +- Documentation/public/getting-started.md | 30 ++++ scripts/preflight.py | 210 ++++++++++++++++++++++++ tests/test_preflight.py | 51 ++++++ 5 files changed, 293 insertions(+), 1 deletion(-) create mode 100644 scripts/preflight.py create mode 100644 tests/test_preflight.py diff --git a/.gitignore b/.gitignore index e5097015..ef7fa490 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ tasks/local/0x4657105FC932625CD289107aAE7B2174a822b709/services/__pycache__/* /bashscripts/* /tasks/* /scripts/* +!scripts/preflight.py /hardhat/ignition/* # /hardhat/scripts/* # /hardhat/test/* diff --git a/Developer/BACK_LOG.md b/Developer/BACK_LOG.md index 71a9144c..bb595421 100644 --- a/Developer/BACK_LOG.md +++ b/Developer/BACK_LOG.md @@ -38,7 +38,7 @@ | BL-29 | Solidity/foundry (fees) | `DinFeeRouter`'s non-treasury shares accrue with no way out | `routeFeeETH`/`routeFeeDIN` pay the treasury share out immediately but only *count* the `validatorPool`, `storage` and `publicGoods` shares in `accruedEth`/`accruedDin`. No function withdraws or distributes them, so with the default 95% validator-pool split, 95% of every swept faucet/registry fee stays in the router until an upgrade adds a path. Needs a decision on who can pull each bucket and where the validator-pool share goes (reward pools? `DinEmission`?). | P3/P4 | PR No. 204 review (`DinCoordinator.md` §14 No. 2), Sep 30, 2026; related to issue #43's open items | 🆕 New — unaddressed; tracked in [#221](https://github.com/InfiniteZeroFoundation/DevNet/issues/221) | | BL-30 | Solidity/foundry (task contracts) | No recovery from a stalled GI | If a T1/T2 batch never reaches the reveal quorum, `finalizeT1Aggregation`/`finalizeT2Aggregation` revert forever (`TC_NoSubmissions`/`TC_InsufficientSubmissions`), and there is no owner path to skip the batch or abort the GI. `endGI` needs `AggregatorsSlashed` and `startGI` needs `GIended`, so the model is stuck: its reward pool and its validators' registration slots stay locked. This is also why BL-26's contract-side re-anchor cap was deferred. Needs a GI-abort design: who may abort, when, and where the reward pool goes. | P3/P4 | PR No. 191 review (BL-26); PR No. 204 review (`DINTaskCoordinator.md` §10 No. 6) | 🆕 New — unaddressed; tracked in [#222](https://github.com/InfiniteZeroFoundation/DevNet/issues/222) | | BL-31 | Solidity/foundry (slashing) | S6 no-participation slashing is implemented but not wired | `DinValidatorStake.recordNoParticipation` (escalating 10%-per-breach slash past `s6NoParticipationThreshold`) exists and is tested, but no task contract calls it. They deliberately skip it where `slashPartial` (S1/S2, with S5 escalation) already penalises the same missed work, to avoid stacking past `MIN_STAKE`. So S6 never fires. Decide whether S6 should cover something S1/S2 don't (e.g. registered but never assigned or never active), or whether to remove it and its parameters. | P3 | PR No. 204 review (`DinValidatorStake.md` §14 No. 1), Sep 30, 2026; slashing taxonomy S6 (issue #38) | 🆕 New — unaddressed; tracked in [#224](https://github.com/InfiniteZeroFoundation/DevNet/issues/224) | -| BL-32 | Tooling / onboarding | Contributor environment preflight check | No single command checks a dev machine before work starts. Each of these has already cost time: (1) Python ≥ 3.12; (2) `forge`/`anvil` installed and solc 0.8.28 available; (3) `foundry/node_modules` present. Without `npm ci`, `forge test`'s parallel tests race to fetch `@openzeppelin/upgrades-core` over npx (see [CLAUDE.md](../CLAUDE.md)); (4) `eth-account` and `py-multibase` import; (5) `torch` imports in the active venv, which the full `pytest` run needs; (6) `.env` / `.env.` exist, the RPC endpoint answers, and `resolve_ipfs_config()` resolves. Proposal: `scripts/preflight.sh` or `dincli system doctor`, one line per check, `OK` or `MISSING → `, non-zero exit on any failure. Also usable as the first CI step and in the onboarding docs. Read-only: it reports missing packages and never installs them. | P3/P4 (onboarding; small, ~half a day) | `preflight.sh` pattern from [Osomudeya/devops-scripting-labs](https://github.com/Osomudeya/devops-scripting-labs) (reviewed 2026-10-02); no existing equivalent (the only "preflight" in `dincli/cli/system.py` is the bridge's tx preflight) | 💭 Idea | +| BL-32 | Tooling / onboarding | Contributor environment preflight check | No single command checks a dev machine before work starts. Each of these has already cost time: (1) Python ≥ 3.12; (2) `forge`/`anvil` installed and solc 0.8.28 available; (3) `foundry/node_modules` present — without `npm ci`, `forge test`'s parallel tests race to fetch `@openzeppelin/upgrades-core` over npx (see [CLAUDE.md](../CLAUDE.md)); (4) `eth-account` and `py-multibase` import; (5) `torch` imports in the active venv (full `pytest` run); (6) `.env`/`.env.` exist, the RPC endpoint answers, and `resolve_ipfs_config()` resolves. | P3/P4 (onboarding; small, ~half a day) | `preflight.sh` pattern from [Osomudeya/devops-scripting-labs](https://github.com/Osomudeya/devops-scripting-labs) `03-drift-detection` (reviewed 2026-10-02); no existing equivalent | ✅ Fixed — PR [#255](https://github.com/InfiniteZeroFoundation/DevNet/pull/255) (issue [#245](https://github.com/InfiniteZeroFoundation/DevNet/issues/245)). `scripts/preflight.py` added: stdlib-only for Python/forge/anvil/node_modules/package checks; `--network` flag for .env, RPC, and IPFS checks; 5 tests in `tests/test_preflight.py`; preflight section added to `Documentation/public/getting-started.md`. | | BL-33 | Tooling / deploy | On-chain deployment drift check | Nothing verifies that `foundry/deployments/.json` (and the `din_info` that `dincli system import-deployments` writes) still matches the chain. Proposal: a read-only check, either a forge script or `dincli system verify-deployment`, that for each recorded contract compares (1) the proxy's EIP-1967 implementation and admin slots against the recorded implementation/`proxyAdmin*` addresses; (2) the deployed runtime code hash against the current `forge build` artifact (immutables masked); (3) owner / DIN-Representative addresses; (4) wiring: `DinCoordinator` ↔ `DinToken`/`DinValidatorStake`/`DinModelRegistry`/fee router/treasury links and slasher authorizations on `DinValidatorStake`. Report per-item `OK` / `DRIFT` with expected vs. actual. Caveat: it only covers what the deployments JSON records, so a clean run doesn't prove nothing else was deployed or authorized. | P3 (before the DevNet 3.0 deploy; re-run after every `UpgradePlatform.s.sol` run) | Terraform state-vs-live drift pattern from [Osomudeya/devops-scripting-labs](https://github.com/Osomudeya/devops-scripting-labs) `03-drift-detection` (reviewed 2026-10-02); no existing code reads EIP-1967 slots or code hashes in `dincli/` or `foundry/script/` | 💭 Idea | | BL-34 | Solidity/foundry (task contracts) | `DINModelRegistry.disableModel()` kill-switch doesn't reach the live task contracts | `modelDisabled[modelId]` only gates `requestModelRegistration`/`requestManifestUpdate` inside the registry itself. `DINTaskCoordinator`/`DINTaskAuditor` have no dependency on, or awareness of, the registry (confirmed: zero functional references; the auditor has two doc comments naming `DINModelRegistry` by name (L31, L434) plus two more referencing "model registry" generically without naming the type (L393, L405) — none are functional checks). A model the DIN-Representative has disabled — e.g. because its task contracts are wrongfully slashing honest validators — keeps running full GIs completely unaffected. The task contracts' own doc table calls this a "kill-switch," which it isn't. Needs a decision: wire the task contracts to check `modelDisabled` before state-changing calls (requires threading the model ID + registry address into contracts that don't know about the registry today), or document `disableModel` as registry-metadata-only. | P3/P4 | 2026-07 security review M-2 (`Documentation/technical/audits/foundry-src-security-review.md`) | 🆕 New — unaddressed; tracked in [#224](https://github.com/InfiniteZeroFoundation/DevNet/issues/224) | | BL-35 | Solidity/foundry (registry) | `rejectModel()`'s non-refund of the registration fee was never confirmed as intentional | `feePaid` (set at `requestModelRegistration`) is never refunded on `rejectModel()`, but it's also never refunded on `approveModel()` either — the fee appears to be a deliberate pay-to-apply cost regardless of outcome (anti-spam), not an oversight specific to rejection. Never written down as a deliberate choice anywhere. Needs confirmation and a doc note in `DINModelRegistry.md` / the dinrep role docs, so a future reader doesn't "fix" it into an inconsistent partial-refund state. | P4 (doc-only once confirmed) | 2026-07 security review L-4 (`Documentation/technical/audits/foundry-src-security-review.md`) | 🆕 New — unaddressed; tracked in [#224](https://github.com/InfiniteZeroFoundation/DevNet/issues/224) | diff --git a/Documentation/public/getting-started.md b/Documentation/public/getting-started.md index 54df37ff..25ee9701 100644 --- a/Documentation/public/getting-started.md +++ b/Documentation/public/getting-started.md @@ -218,6 +218,36 @@ If you are unable to obtain funds through the faucets above, you may also reques --- +# 🩺 Environment Preflight Check + +Before installing or running `dincli`, verify your environment with: + +```bash +python3 scripts/preflight.py +``` + +The script checks each of the following and prints `[OK]`, `[WARN]`, or `[MISSING]` with a fix hint: + +| Check | Pass condition | +|-------|----------------| +| Python version | ≥ 3.12 | +| `forge` on PATH | Foundry installed | +| `anvil` on PATH | Foundry installed | +| `foundry/node_modules` | `cd foundry && npm ci` has been run | +| `eth-account`, `py-multibase`, `web3` | importable after `pip install -e .` | +| `torch` | importable after `pip install -e .[test]` (optional — tests skip without it) | + +Exits 0 when all required checks pass; exits 1 on Python version or Foundry failures. + +To also verify `.env.`, the configured RPC endpoint, and the IPFS config: + +```bash +python3 scripts/preflight.py --network sepolia_op_devnet +# or: python3 scripts/preflight.py --network local +``` + +--- + # ⚙️ DIN CLI Installation and Setup Before participating, ensure dincli is correctly installed and configured. diff --git a/scripts/preflight.py b/scripts/preflight.py new file mode 100644 index 00000000..e604e420 --- /dev/null +++ b/scripts/preflight.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +""" +scripts/preflight.py — environment preflight check for DevNet contributors. + +Stdlib-only for the core checks (Python, forge, anvil, node_modules, package +importability). Pass --network to also verify .env., the +configured RPC endpoint, and the IPFS config (requires a working dincli install). + +Exit 0 — all required checks passed (warnings may still be printed). +Exit 1 — at least one required check failed. +""" +import argparse +import importlib +import json +import shutil +import sys +import urllib.error +import urllib.request +from pathlib import Path + +_REPO_ROOT = Path(__file__).resolve().parent.parent + +# ── output helpers ──────────────────────────────────────────────────────────── + +def _ok(msg: str) -> None: + print(f"[OK] {msg}") + + +def _warn(msg: str, fix: str = "") -> None: + print(f"[WARN] {msg}") + if fix: + print(f" → {fix}") + + +def _missing(msg: str, fix: str = "") -> None: + print(f"[MISSING] {msg}") + if fix: + print(f" → {fix}") + + +# ── individual checks ───────────────────────────────────────────────────────── + +def _check_python() -> int: + vi = sys.version_info + label = f"Python {vi.major}.{vi.minor}.{vi.micro}" + if (vi.major, vi.minor) >= (3, 12): + _ok(f"{label} (>= 3.12 required)") + return 0 + _missing(f"{label} is below 3.12", fix="Install Python 3.12+: https://www.python.org/downloads/") + return 1 + + +def _check_binary(name: str, install_hint: str) -> int: + path = shutil.which(name) + if path: + _ok(f"{name} found: {path}") + return 0 + _missing(f"{name} not found on PATH", fix=install_hint) + return 1 + + +def _check_node_modules() -> None: + node_modules = _REPO_ROOT / "foundry" / "node_modules" + if node_modules.is_dir(): + _ok("foundry/node_modules present") + else: + _warn( + "foundry/node_modules not found — forge test will race fetching @openzeppelin/upgrades-core", + fix="cd foundry && npm ci", + ) + + +def _check_importable(module: str, package: str, optional: bool = False) -> None: + try: + importlib.import_module(module) + _ok(f"{package} importable") + except ImportError: + if optional: + _warn( + f"{package} not importable (optional — pytest skips torch-dependent tests without it)", + fix="pip install -e .[test]", + ) + else: + _warn( + f"{package} not importable", + fix="pip install -e .", + ) + + +# ── network checks (require --network, may use dincli) ──────────────────────── + +def _network_checks(network: str) -> int: + failures = 0 + env_file = _REPO_ROOT / f".env.{network}" + + if not env_file.exists(): + _warn( + f".env.{network} not found", + fix=f"Copy .env.{network}.example and fill in your RPC URL and private keys", + ) + return failures # can't proceed without the env file + + _ok(f".env.{network} found") + + rpc_url = _read_env_value(env_file, f"{network.upper()}_RPC_URL") + if not rpc_url: + _warn( + f"No RPC URL in .env.{network}", + fix=f"Set {network.upper()}_RPC_URL= in .env.{network}", + ) + else: + failures += _check_rpc(rpc_url) + + try: + from dincli.services.ipfs import resolve_ipfs_config + resolve_ipfs_config() + _ok("resolve_ipfs_config() succeeded") + except ImportError: + _warn("dincli not importable — skipping IPFS config check", fix="pip install -e .") + except Exception as exc: + _warn(f"IPFS config not resolved: {exc}", fix="Configure IPFS_NODE_URL or Filebase credentials in .env") + + return failures + + +def _read_env_value(env_file: Path, key: str) -> str: + prefix = key + "=" + with open(env_file) as fh: + for line in fh: + stripped = line.strip() + if stripped.startswith(prefix): + return stripped[len(prefix):].strip() + return "" + + +def _check_rpc(url: str) -> int: + payload = json.dumps({"jsonrpc": "2.0", "method": "eth_chainId", "params": [], "id": 1}).encode() + try: + req = urllib.request.Request( + url, + data=payload, + headers={"Content-Type": "application/json"}, + ) + with urllib.request.urlopen(req, timeout=5) as resp: + result = json.loads(resp.read()) + chain_id = int(result["result"], 16) + _ok(f"RPC reachable: {url} (chainId {chain_id})") + return 0 + except Exception as exc: + _warn(f"RPC not reachable ({url}): {exc}", fix="Check your RPC URL and internet connection") + return 0 # network connectivity is always a warning, not a hard failure + + +# ── main ────────────────────────────────────────────────────────────────────── + +_FOUNDRY_INSTALL = ( + "curl -L https://foundry.paradigm.xyz | bash && foundryup" + " (then restart your shell)" +) + + +def run(network: str | None = None) -> int: + failures = 0 + + failures += _check_python() + failures += _check_binary("forge", _FOUNDRY_INSTALL) + failures += _check_binary("anvil", _FOUNDRY_INSTALL) + + _check_node_modules() + + _check_importable("eth_account", "eth-account") + _check_importable("multibase", "py-multibase") + _check_importable("web3", "web3") + _check_importable("torch", "torch", optional=True) + + if network: + failures += _network_checks(network) + + return failures + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Preflight check for DevNet contributor environment.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=( + "Examples:\n" + " python scripts/preflight.py\n" + " python scripts/preflight.py --network sepolia_op_devnet\n" + " python scripts/preflight.py --network local\n" + ), + ) + parser.add_argument( + "--network", metavar="NAME", + help=( + "Also check .env., the configured RPC endpoint, and the IPFS config. " + "Requires a working dincli install for the IPFS check. " + "Example values: local, sepolia_op_devnet." + ), + ) + args = parser.parse_args() + failures = run(network=args.network) + if failures: + print(f"\n{failures} required check(s) failed. Fix the issues above before continuing.") + sys.exit(1) + print("\nAll required checks passed.") + + +if __name__ == "__main__": + main() diff --git a/tests/test_preflight.py b/tests/test_preflight.py new file mode 100644 index 00000000..bc68ecf8 --- /dev/null +++ b/tests/test_preflight.py @@ -0,0 +1,51 @@ +"""Tests for scripts/preflight.py. + +Runs the script as a subprocess so we test the real exit-code behaviour, +including the case where a required binary is absent from PATH. +""" +import os +import subprocess +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +PREFLIGHT = REPO_ROOT / "scripts" / "preflight.py" + + +def _run(extra_env: dict | None = None, args: list[str] | None = None) -> subprocess.CompletedProcess: + env = {**os.environ, **(extra_env or {})} + cmd = [sys.executable, str(PREFLIGHT)] + (args or []) + return subprocess.run(cmd, capture_output=True, text=True, env=env) + + +class TestMissingBinaries: + def test_missing_forge_exits_nonzero(self, tmp_path): + """Exit 1 when forge/anvil are not on PATH.""" + result = _run(extra_env={"PATH": str(tmp_path)}) + assert result.returncode != 0 + + def test_missing_forge_prints_missing_tag(self, tmp_path): + result = _run(extra_env={"PATH": str(tmp_path)}) + assert "[MISSING]" in result.stdout + + def test_missing_forge_mentions_foundry(self, tmp_path): + result = _run(extra_env={"PATH": str(tmp_path)}) + assert "foundryup" in result.stdout.lower() or "foundry" in result.stdout.lower() + + +class TestHappyPath: + def test_exits_zero_when_forge_present(self): + """Smoke test: exits 0 in a working dev environment.""" + result = _run() + # If forge/anvil are present and Python >= 3.12 (which it must be to run + # the test suite), this should exit 0. A missing optional package is only + # a WARN and must not cause a non-zero exit. + assert result.returncode == 0, ( + f"preflight.py exited {result.returncode}.\nstdout:\n{result.stdout}\n" + f"stderr:\n{result.stderr}" + ) + + def test_all_required_checks_passed_message(self): + result = _run() + if result.returncode == 0: + assert "All required checks passed" in result.stdout From 232d52e1a35982365221dd0185b32a1557df6458 Mon Sep 17 00:00:00 2001 From: robertocarlous Date: Thu, 15 Oct 2026 10:41:35 +0100 Subject: [PATCH 2/4] fix(ci): skip happy-path preflight tests when forge not on PATH --- tests/test_preflight.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/tests/test_preflight.py b/tests/test_preflight.py index bc68ecf8..286aea34 100644 --- a/tests/test_preflight.py +++ b/tests/test_preflight.py @@ -4,10 +4,13 @@ including the case where a required binary is absent from PATH. """ import os +import shutil import subprocess import sys from pathlib import Path +import pytest + REPO_ROOT = Path(__file__).resolve().parents[1] PREFLIGHT = REPO_ROOT / "scripts" / "preflight.py" @@ -33,19 +36,21 @@ def test_missing_forge_mentions_foundry(self, tmp_path): assert "foundryup" in result.stdout.lower() or "foundry" in result.stdout.lower() +_FORGE_PRESENT = shutil.which("forge") is not None +_needs_forge = pytest.mark.skipif(not _FORGE_PRESENT, reason="forge not on PATH — Foundry not installed in this environment") + + class TestHappyPath: + @_needs_forge def test_exits_zero_when_forge_present(self): - """Smoke test: exits 0 in a working dev environment.""" + """Smoke test: exits 0 in a working dev environment with Foundry installed.""" result = _run() - # If forge/anvil are present and Python >= 3.12 (which it must be to run - # the test suite), this should exit 0. A missing optional package is only - # a WARN and must not cause a non-zero exit. assert result.returncode == 0, ( f"preflight.py exited {result.returncode}.\nstdout:\n{result.stdout}\n" f"stderr:\n{result.stderr}" ) + @_needs_forge def test_all_required_checks_passed_message(self): result = _run() - if result.returncode == 0: - assert "All required checks passed" in result.stdout + assert "All required checks passed" in result.stdout From 8cc61f88ddf8747c849c82a7c46330a2d941ae65 Mon Sep 17 00:00:00 2001 From: robertocarlous Date: Sat, 24 Oct 2026 15:04:47 +0100 Subject: [PATCH 3/4] fix(preflight): address all P2/P3 review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2 fixes - Catch Exception (not just ImportError) in _check_importable so native-lib OSErrors remain advisory warnings instead of crashing the checker - Sanitize RPC URLs before printing: _sanitize_url() strips path/query/ credentials; exception text is never printed (no credential exposure in either OK or WARN paths) - Resolve _RPC_URL via os.environ → .env, matching dincli's own get_env_key lookup; dincli never reads .env.; hint now points at .env.example (file that exists) not .env..example (does not exist) - Extract _check_ipfs_config() and validate per-provider required fields (env: IPFS_API_URL_ADD + IPFS_API_URL_RETRIEVE; filebase: api_key; custom: service_path); fix hint from IPFS_NODE_URL to the real vars P3 fixes - Add from __future__ import annotations so str | None in run() signature is postponed and the Python-version diagnostic runs on 3.9 - Dotenv-compatible parsing in _read_env_value: handles export prefix, spaces around =, single/double quotes, inline comments - IPFS check no longer skipped when env file is missing; both checks run independently after the env file warning - Expected chainId map (sepolia_op_devnet: 11155420, local: 1337); WARN on mismatch - Doc scope note: for contributors from a clone; wheel participants skip - --network paragraph updated to match actual env resolution - BACK_LOG.md BL-32 Source cell: remove 03-drift-detection (that is BL-33's source); restore bridge-preflight note; update test count to 13 Tests - TestHappyPath: drop skipif; use forge/anvil executable stubs in tmp_path prepended to PATH — hermetic and runs in CI - All 13 tests pass --- Developer/BACK_LOG.md | 2 +- Documentation/public/getting-started.md | 8 +- scripts/preflight.py | 197 ++++++++++++++++++------ tests/test_preflight.py | 106 +++++++++++-- 4 files changed, 254 insertions(+), 59 deletions(-) diff --git a/Developer/BACK_LOG.md b/Developer/BACK_LOG.md index 441949f4..b5dcbb65 100644 --- a/Developer/BACK_LOG.md +++ b/Developer/BACK_LOG.md @@ -38,7 +38,7 @@ | BL-29 | Solidity/foundry (fees) | `DinFeeRouter`'s non-treasury shares accrue with no way out | `routeFeeETH`/`routeFeeDIN` pay the treasury share out immediately but only *count* the `validatorPool`, `storage` and `publicGoods` shares in `accruedEth`/`accruedDin`. No function withdraws or distributes them, so with the default 95% validator-pool split, 95% of every swept faucet/registry fee stays in the router until an upgrade adds a path. Needs a decision on who can pull each bucket and where the validator-pool share goes (reward pools? `DinEmission`?). | P3/P4 | PR No. 204 review (`DinCoordinator.md` §14 No. 2), Sep 30, 2026; related to issue #43's open items | 🆕 New — unaddressed; tracked in [#221](https://github.com/InfiniteZeroFoundation/DevNet/issues/221) | | BL-30 | Solidity/foundry (task contracts) | No recovery from a stalled GI | If a T1/T2 batch never reaches the reveal quorum, `finalizeT1Aggregation`/`finalizeT2Aggregation` revert forever (`TC_NoSubmissions`/`TC_InsufficientSubmissions`), and there is no owner path to skip the batch or abort the GI. `endGI` needs `AggregatorsSlashed` and `startGI` needs `GIended`, so the model is stuck: its reward pool and its validators' registration slots stay locked. This is also why BL-26's contract-side re-anchor cap was deferred. Needs a GI-abort design: who may abort, when, and where the reward pool goes. | P3/P4 | PR No. 191 review (BL-26); PR No. 204 review (`DINTaskCoordinator.md` §10 No. 6) | 🆕 New — unaddressed; tracked in [#222](https://github.com/InfiniteZeroFoundation/DevNet/issues/222) | | BL-31 | Solidity/foundry (slashing) | S6 no-participation slashing is implemented but not wired | `DinValidatorStake.recordNoParticipation` (escalating 10%-per-breach slash past `s6NoParticipationThreshold`) exists and is tested, but no task contract calls it. They deliberately skip it where `slashPartial` (S1/S2, with S5 escalation) already penalises the same missed work, to avoid stacking past `MIN_STAKE`. So S6 never fires. Decide whether S6 should cover something S1/S2 don't (e.g. registered but never assigned or never active), or whether to remove it and its parameters. | P3 | PR No. 204 review (`DinValidatorStake.md` §14 No. 1), Sep 30, 2026; slashing taxonomy S6 (issue #38) | 🆕 New — unaddressed; tracked in [#224](https://github.com/InfiniteZeroFoundation/DevNet/issues/224) | -| BL-32 | Tooling / onboarding | Contributor environment preflight check | No single command checks a dev machine before work starts. Each of these has already cost time: (1) Python ≥ 3.12; (2) `forge`/`anvil` installed and solc 0.8.28 available; (3) `foundry/node_modules` present — without `npm ci`, `forge test`'s parallel tests race to fetch `@openzeppelin/upgrades-core` over npx (see [CLAUDE.md](../CLAUDE.md)); (4) `eth-account` and `py-multibase` import; (5) `torch` imports in the active venv (full `pytest` run); (6) `.env`/`.env.` exist, the RPC endpoint answers, and `resolve_ipfs_config()` resolves. | P3/P4 (onboarding; small, ~half a day) | `preflight.sh` pattern from [Osomudeya/devops-scripting-labs](https://github.com/Osomudeya/devops-scripting-labs) `03-drift-detection` (reviewed 2026-10-02); no existing equivalent | ✅ Fixed — PR [#255](https://github.com/InfiniteZeroFoundation/DevNet/pull/255) (issue [#245](https://github.com/InfiniteZeroFoundation/DevNet/issues/245)). `scripts/preflight.py` added: stdlib-only for Python/forge/anvil/node_modules/package checks; `--network` flag for .env, RPC, and IPFS checks; 5 tests in `tests/test_preflight.py`; preflight section added to `Documentation/public/getting-started.md`. | +| BL-32 | Tooling / onboarding | Contributor environment preflight check | No single command checks a dev machine before work starts. Each of these has already cost time: (1) Python ≥ 3.12; (2) `forge`/`anvil` installed and solc 0.8.28 available; (3) `foundry/node_modules` present — without `npm ci`, `forge test`'s parallel tests race to fetch `@openzeppelin/upgrades-core` over npx (see [CLAUDE.md](../CLAUDE.md)); (4) `eth-account` and `py-multibase` import; (5) `torch` imports in the active venv (full `pytest` run); (6) `.env`/`.env.` exist, the RPC endpoint answers, and `resolve_ipfs_config()` resolves. | P3/P4 (onboarding; small, ~half a day) | `preflight.sh` pattern from [Osomudeya/devops-scripting-labs](https://github.com/Osomudeya/devops-scripting-labs) (reviewed 2026-10-02); no existing equivalent (note: the only "preflight" in `dincli/cli/system.py` is the bridge's tx preflight — unrelated) | ✅ Fixed — PR [#255](https://github.com/InfiniteZeroFoundation/DevNet/pull/255) (issue [#245](https://github.com/InfiniteZeroFoundation/DevNet/issues/245)). `scripts/preflight.py` added: stdlib-only for Python/forge/anvil/node_modules/package checks; `--network` flag for RPC and IPFS checks; 13 tests in `tests/test_preflight.py`; preflight section added to `Documentation/public/getting-started.md`. | | BL-33 | Tooling / deploy | On-chain deployment drift check | Nothing verifies that `foundry/deployments/.json` (and the `din_info` that `dincli system import-deployments` writes) still matches the chain. Proposal: a read-only check, either a forge script or `dincli system verify-deployment`, that for each recorded contract compares (1) the proxy's EIP-1967 implementation and admin slots against the recorded implementation/`proxyAdmin*` addresses; (2) the deployed runtime code hash against the current `forge build` artifact (immutables masked); (3) owner / DIN-Representative addresses; (4) wiring: `DinCoordinator` ↔ `DinToken`/`DinValidatorStake`/`DinModelRegistry`/fee router/treasury links and slasher authorizations on `DinValidatorStake`. Report per-item `OK` / `DRIFT` with expected vs. actual. Caveat: it only covers what the deployments JSON records, so a clean run doesn't prove nothing else was deployed or authorized. | P3 (before the DevNet 3.0 deploy; re-run after every `UpgradePlatform.s.sol` run) | Terraform state-vs-live drift pattern from [Osomudeya/devops-scripting-labs](https://github.com/Osomudeya/devops-scripting-labs) `03-drift-detection` (reviewed 2026-10-02); no existing code reads EIP-1967 slots or code hashes in `dincli/` or `foundry/script/` | 💭 Idea | | BL-34 | Solidity/foundry (task contracts) | `DINModelRegistry.disableModel()` kill-switch doesn't reach the live task contracts | `modelDisabled[modelId]` only gates `requestModelRegistration`/`requestManifestUpdate` inside the registry itself. `DINTaskCoordinator`/`DINTaskAuditor` have no dependency on, or awareness of, the registry (confirmed: zero functional references; the auditor has two doc comments naming `DINModelRegistry` by name (L31, L434) plus two more referencing "model registry" generically without naming the type (L393, L405) — none are functional checks). A model the DIN-Representative has disabled — e.g. because its task contracts are wrongfully slashing honest validators — keeps running full GIs completely unaffected. The task contracts' own doc table calls this a "kill-switch," which it isn't. Needs a decision: wire the task contracts to check `modelDisabled` before state-changing calls (requires threading the model ID + registry address into contracts that don't know about the registry today), or document `disableModel` as registry-metadata-only. | P3/P4 | 2026-07 security review M-2 (`Documentation/technical/audits/foundry-src-security-review.md`) | 🆕 New — unaddressed; tracked in [#224](https://github.com/InfiniteZeroFoundation/DevNet/issues/224) | | BL-35 | Solidity/foundry (registry) | `rejectModel()`'s non-refund of the registration fee was never confirmed as intentional | `feePaid` (set at `requestModelRegistration`) is never refunded on `rejectModel()`, but it's also never refunded on `approveModel()` either — the fee appears to be a deliberate pay-to-apply cost regardless of outcome (anti-spam), not an oversight specific to rejection. Never written down as a deliberate choice anywhere. Needs confirmation and a doc note in `DINModelRegistry.md` / the dinrep role docs, so a future reader doesn't "fix" it into an inconsistent partial-refund state. | P4 (doc-only once confirmed) | 2026-07 security review L-4 (`Documentation/technical/audits/foundry-src-security-review.md`) | 🆕 New — unaddressed; tracked in [#224](https://github.com/InfiniteZeroFoundation/DevNet/issues/224) | diff --git a/Documentation/public/getting-started.md b/Documentation/public/getting-started.md index 25ee9701..acc9abc5 100644 --- a/Documentation/public/getting-started.md +++ b/Documentation/public/getting-started.md @@ -220,7 +220,11 @@ If you are unable to obtain funds through the faucets above, you may also reques # 🩺 Environment Preflight Check -Before installing or running `dincli`, verify your environment with: +> **For contributors working from a clone of this repo.** Participants who +> installed dincli from the wheel (per [setup.md](setup.md)) can skip this +> section — forge and anvil are not required for network participation. + +Before starting development, verify your environment with: ```bash python3 scripts/preflight.py @@ -239,7 +243,7 @@ The script checks each of the following and prints `[OK]`, `[WARN]`, or `[MISSIN Exits 0 when all required checks pass; exits 1 on Python version or Foundry failures. -To also verify `.env.`, the configured RPC endpoint, and the IPFS config: +To also verify the configured RPC endpoint (read from `os.environ` or `.env`, the same lookup dincli uses) and the IPFS config: ```bash python3 scripts/preflight.py --network sepolia_op_devnet diff --git a/scripts/preflight.py b/scripts/preflight.py index e604e420..51744080 100644 --- a/scripts/preflight.py +++ b/scripts/preflight.py @@ -2,24 +2,38 @@ """ scripts/preflight.py — environment preflight check for DevNet contributors. +For contributors working from a clone of this repo. Participants who installed +dincli from the wheel (per Documentation/public/setup.md) can skip this script +— forge and anvil are not in their setup requirements. + Stdlib-only for the core checks (Python, forge, anvil, node_modules, package -importability). Pass --network to also verify .env., the -configured RPC endpoint, and the IPFS config (requires a working dincli install). +importability). Pass --network to also verify the configured RPC +endpoint and the IPFS config (requires a working dincli install). Exit 0 — all required checks passed (warnings may still be printed). Exit 1 — at least one required check failed. """ +from __future__ import annotations + import argparse import importlib import json +import os import shutil import sys import urllib.error +import urllib.parse import urllib.request from pathlib import Path _REPO_ROOT = Path(__file__).resolve().parent.parent +# Expected chain IDs per network (WARN on mismatch, not hard fail) +_EXPECTED_CHAIN_IDS: dict[str, int] = { + "sepolia_op_devnet": 11155420, + "local": 1337, +} + # ── output helpers ──────────────────────────────────────────────────────────── def _ok(msg: str) -> None: @@ -74,7 +88,7 @@ def _check_importable(module: str, package: str, optional: bool = False) -> None try: importlib.import_module(module) _ok(f"{package} importable") - except ImportError: + except Exception: # catches ImportError, OSError (native libs), etc. if optional: _warn( f"{package} not importable (optional — pytest skips torch-dependent tests without it)", @@ -87,54 +101,54 @@ def _check_importable(module: str, package: str, optional: bool = False) -> None ) -# ── network checks (require --network, may use dincli) ──────────────────────── - -def _network_checks(network: str) -> int: - failures = 0 - env_file = _REPO_ROOT / f".env.{network}" - - if not env_file.exists(): - _warn( - f".env.{network} not found", - fix=f"Copy .env.{network}.example and fill in your RPC URL and private keys", - ) - return failures # can't proceed without the env file - - _ok(f".env.{network} found") - - rpc_url = _read_env_value(env_file, f"{network.upper()}_RPC_URL") - if not rpc_url: - _warn( - f"No RPC URL in .env.{network}", - fix=f"Set {network.upper()}_RPC_URL= in .env.{network}", - ) - else: - failures += _check_rpc(rpc_url) - +def _sanitize_url(url: str) -> str: + """Return URL with path, query, and credentials stripped — safe for logging.""" try: - from dincli.services.ipfs import resolve_ipfs_config - resolve_ipfs_config() - _ok("resolve_ipfs_config() succeeded") - except ImportError: - _warn("dincli not importable — skipping IPFS config check", fix="pip install -e .") - except Exception as exc: - _warn(f"IPFS config not resolved: {exc}", fix="Configure IPFS_NODE_URL or Filebase credentials in .env") + p = urllib.parse.urlparse(url) + netloc = p.hostname or "" + if p.port: + netloc += f":{p.port}" + return urllib.parse.urlunparse((p.scheme, netloc, "***", "", "", "")) + except Exception: + return "" - return failures +# ── network checks (require --network, may use dincli) ──────────────────────── def _read_env_value(env_file: Path, key: str) -> str: - prefix = key + "=" - with open(env_file) as fh: + """Read a single env var value from a .env-style file (dotenv-compatible).""" + with open(env_file, encoding="utf-8") as fh: for line in fh: stripped = line.strip() - if stripped.startswith(prefix): - return stripped[len(prefix):].strip() + if not stripped or stripped.startswith("#"): + continue + # strip optional 'export ' prefix + if stripped.startswith("export "): + stripped = stripped[7:].lstrip() + if "=" not in stripped: + continue + k, _, v = stripped.partition("=") + if k.strip() != key: + continue + v = v.strip() + # quoted value: extract between matching quotes + if v and v[0] in ('"', "'"): + quote = v[0] + end = v.find(quote, 1) + return v[1:end] if end != -1 else v[1:] + # unquoted: strip trailing inline comment (` #` pattern) + comment_idx = v.find(" #") + if comment_idx != -1: + v = v[:comment_idx].rstrip() + return v return "" -def _check_rpc(url: str) -> int: - payload = json.dumps({"jsonrpc": "2.0", "method": "eth_chainId", "params": [], "id": 1}).encode() +def _check_rpc(url: str, network: str | None = None) -> int: + safe_url = _sanitize_url(url) + payload = json.dumps( + {"jsonrpc": "2.0", "method": "eth_chainId", "params": [], "id": 1} + ).encode() try: req = urllib.request.Request( url, @@ -144,11 +158,99 @@ def _check_rpc(url: str) -> int: with urllib.request.urlopen(req, timeout=5) as resp: result = json.loads(resp.read()) chain_id = int(result["result"], 16) - _ok(f"RPC reachable: {url} (chainId {chain_id})") - return 0 + expected = _EXPECTED_CHAIN_IDS.get(network or "") if network else None + if expected is not None and chain_id != expected: + _warn( + f"RPC reachable: {safe_url} (chainId {chain_id}, expected {expected} for {network})", + fix=f"Verify {network.upper()}_RPC_URL points at the right network", + ) + else: + _ok(f"RPC reachable: {safe_url} (chainId {chain_id})") + except Exception: + _warn(f"RPC not reachable: {safe_url}", fix="Check your RPC URL and internet connection") + return 0 # RPC connectivity is advisory — never a hard failure + + +def _check_ipfs_config() -> int: + """Check that the resolved IPFSConfig has the required fields for its provider.""" + try: + from dincli.services.ipfs import resolve_ipfs_config # type: ignore[import] + from dincli.cli.utils import IPFSConfig # type: ignore[import] + cfg: IPFSConfig = resolve_ipfs_config() + provider = cfg.provider + if provider == "env": + missing = [ + v for v, f in ( + ("IPFS_API_URL_ADD", cfg.api_url_add), + ("IPFS_API_URL_RETRIEVE", cfg.api_url_retrieve), + ) if not f + ] + if missing: + _warn( + f"IPFS env provider: {', '.join(missing)} not set", + fix=( + "Set IPFS_API_URL_ADD / IPFS_API_URL_RETRIEVE in .env, " + "or run: dincli system configure-ipfs" + ), + ) + else: + _ok("IPFS config resolved (env provider)") + elif provider == "filebase": + if not cfg.api_key: + _warn( + "IPFS filebase provider: ipfs_api_key not configured", + fix="dincli system configure-ipfs --provider filebase", + ) + else: + _ok("IPFS config resolved (filebase provider)") + elif provider == "custom": + if not cfg.service_path: + _warn( + "IPFS custom provider: ipfs_service_path not configured", + fix="dincli system configure-ipfs --provider custom --service-path ", + ) + else: + _ok("IPFS config resolved (custom provider)") + else: + _ok(f"IPFS config resolved ({provider} provider)") + except ImportError: + _warn("dincli not importable — skipping IPFS config check", fix="pip install -e .") except Exception as exc: - _warn(f"RPC not reachable ({url}): {exc}", fix="Check your RPC URL and internet connection") - return 0 # network connectivity is always a warning, not a hard failure + _warn(f"IPFS config not resolved: {exc}", fix="dincli system configure-ipfs") + return 0 # IPFS config is advisory + + +def _network_checks(network: str) -> int: + failures = 0 + env_key = f"{network.upper()}_RPC_URL" + + # Resolve RPC URL the same way dincli does: os.environ → .env + rpc_url = os.environ.get(env_key, "") + if not rpc_url: + env_file = _REPO_ROOT / ".env" + if env_file.exists(): + rpc_url = _read_env_value(env_file, env_key) + + # Also check for a network-specific file and warn if absent + net_env_file = _REPO_ROOT / f".env.{network}" + if not net_env_file.exists(): + _warn( + f".env.{network} not found", + fix=f"Copy .env.example to .env.{network} and fill in {env_key}", + ) + + if not rpc_url: + _warn( + f"No RPC URL found for {network} (checked os.environ and .env)", + fix=f"Set {env_key}= in .env", + ) + else: + failures += _check_rpc(rpc_url, network=network) + + # IPFS check is independent of the env file — always run it + failures += _check_ipfs_config() + + return failures # ── main ────────────────────────────────────────────────────────────────────── @@ -193,8 +295,9 @@ def main() -> None: parser.add_argument( "--network", metavar="NAME", help=( - "Also check .env., the configured RPC endpoint, and the IPFS config. " - "Requires a working dincli install for the IPFS check. " + "Also verify the configured RPC endpoint and IPFS config for NAME. " + "Reads _RPC_URL from os.environ then .env (same lookup " + "dincli uses). Requires a working dincli install for the IPFS check. " "Example values: local, sepolia_op_devnet." ), ) diff --git a/tests/test_preflight.py b/tests/test_preflight.py index 286aea34..ee6d6dca 100644 --- a/tests/test_preflight.py +++ b/tests/test_preflight.py @@ -7,12 +7,16 @@ import shutil import subprocess import sys +from importlib.util import module_from_spec, spec_from_file_location from pathlib import Path import pytest REPO_ROOT = Path(__file__).resolve().parents[1] PREFLIGHT = REPO_ROOT / "scripts" / "preflight.py" +_SPEC = spec_from_file_location("preflight", PREFLIGHT) +preflight = module_from_spec(_SPEC) +_SPEC.loader.exec_module(preflight) def _run(extra_env: dict | None = None, args: list[str] | None = None) -> subprocess.CompletedProcess: @@ -36,21 +40,105 @@ def test_missing_forge_mentions_foundry(self, tmp_path): assert "foundryup" in result.stdout.lower() or "foundry" in result.stdout.lower() -_FORGE_PRESENT = shutil.which("forge") is not None -_needs_forge = pytest.mark.skipif(not _FORGE_PRESENT, reason="forge not on PATH — Foundry not installed in this environment") +def _make_stubs(tmp_path: Path, *names: str) -> str: + """Write minimal executable stubs and return a PATH string containing them.""" + for name in names: + stub = tmp_path / name + stub.write_text("#!/bin/sh\necho stub\n") + stub.chmod(0o755) + return str(tmp_path) + os.pathsep + os.environ.get("PATH", "") class TestHappyPath: - @_needs_forge - def test_exits_zero_when_forge_present(self): - """Smoke test: exits 0 in a working dev environment with Foundry installed.""" - result = _run() + def test_exits_zero_when_forge_present(self, tmp_path): + result = _run(extra_env={"PATH": _make_stubs(tmp_path, "forge", "anvil")}) assert result.returncode == 0, ( f"preflight.py exited {result.returncode}.\nstdout:\n{result.stdout}\n" f"stderr:\n{result.stderr}" ) - @_needs_forge - def test_all_required_checks_passed_message(self): - result = _run() + def test_all_required_checks_passed_message(self, tmp_path): + result = _run(extra_env={"PATH": _make_stubs(tmp_path, "forge", "anvil")}) assert "All required checks passed" in result.stdout + + +def test_import_time_oserror_is_advisory_and_does_not_abort(monkeypatch, capsys): + def fail_import(_module): + raise OSError("native library unavailable") + + monkeypatch.setattr(preflight.importlib, "import_module", fail_import) + + preflight._check_importable("torch", "torch", optional=True) + + assert "[WARN]" in capsys.readouterr().out + + +def test_rpc_success_output_does_not_expose_credentials(monkeypatch, capsys): + secret = "SENTINEL_RPC_CREDENTIAL" + url = f"https://rpc.example/{secret}" + + class Response: + def __enter__(self): + return self + + def __exit__(self, *args): + return None + + def read(self): + return b'{"result":"0x1"}' + + monkeypatch.setattr(preflight.urllib.request, "urlopen", lambda *args, **kwargs: Response()) + + assert preflight._check_rpc(url) == 0 + output = capsys.readouterr().out + assert secret not in output + assert url not in output + assert "chainId 1" in output + + +def test_rpc_failure_output_does_not_expose_credentials_or_exception(monkeypatch, capsys): + secret = "SENTINEL_RPC_CREDENTIAL" + url = f"https://rpc.example/{secret}" + + def fail_request(*args, **kwargs): + raise OSError(f"failed for {url}") + + monkeypatch.setattr(preflight.urllib.request, "urlopen", fail_request) + + assert preflight._check_rpc(url) == 0 + output = capsys.readouterr().out + assert secret not in output + assert url not in output + assert "failed for" not in output + + +@pytest.mark.parametrize( + ("assignment", "expected"), + [ + ('export SEPOLIA_OP_DEVNET_RPC_URL = "https://rpc.example/a#b" # comment', "https://rpc.example/a#b"), + ("SEPOLIA_OP_DEVNET_RPC_URL='https://rpc.example/a#b'", "https://rpc.example/a#b"), + ("SEPOLIA_OP_DEVNET_RPC_URL = https://rpc.example/a # comment", "https://rpc.example/a"), + ], +) +def test_env_reader_accepts_dotenv_assignment_forms(tmp_path, assignment, expected): + env_file = tmp_path / ".env.network" + env_file.write_text(f"{assignment}\n", encoding="utf-8") + + assert preflight._read_env_value(env_file, "SEPOLIA_OP_DEVNET_RPC_URL") == expected + + +def test_missing_network_env_still_checks_ipfs(monkeypatch, tmp_path, capsys): + monkeypatch.setattr(preflight, "_REPO_ROOT", tmp_path) + checked = [] + monkeypatch.setattr(preflight, "_check_ipfs_config", lambda: checked.append(True) or 0) + + assert preflight._network_checks("local") == 0 + + assert checked == [True] + output = capsys.readouterr().out + assert ".env.local not found" in output + assert "Copy .env.example to .env.local" in output + + +def test_run_annotations_are_postponed_for_python_39_compatibility(): + assert isinstance(preflight.run.__annotations__["network"], str) From e8ad0d3f5dac7d794443f9e68345f8d1a6ab016c Mon Sep 17 00:00:00 2001 From: robertocarlous Date: Tue, 27 Oct 2026 09:16:02 +0100 Subject: [PATCH 4/4] fix(preflight): address remaining review findings (cwd .env, IPFS provider, decode safety) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Configuration mismatch (main finding): _network_checks now reads .env from Path.cwd() / ".env" instead of _REPO_ROOT / ".env", matching dincli's own lookup (Path(os.getcwd()) / ".env" in dincli/cli/utils.py). The .env. existence warning is removed — the script never reads that file. Unknown IPFS provider: The else branch in _check_ipfs_config now calls _warn() instead of _ok(). An unrecognised provider will be rejected by dincli's IPFS operations, so reporting OK was misleading. .env decode safety: _read_env_value is now wrapped in a try/except inside _network_checks. A file-read or encoding error becomes a [WARN] and the independent IPFS check continues. Tests: - test_missing_network_env_still_checks_ipfs: updated to use monkeypatch.chdir instead of the removed _REPO_ROOT patch; now checks "No RPC URL found" rather than the removed .env. warning. - test_env_read_from_cwd_not_repo_root: regression for the cwd vs repo- root divergence — places .env only in a tmp cwd and asserts the URL reaches _check_rpc. - test_env_read_failure_still_runs_ipfs_check: regression for the decode-safety fix — a binary .env triggers a [WARN] and the IPFS check still runs. - test_unknown_ipfs_provider_warns: regression for the provider fallthrough fix — an unknown provider produces [WARN], not [OK]. All 16 tests pass (Python 3.12). --- scripts/preflight.py | 32 ++++++++--------- tests/test_preflight.py | 78 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 90 insertions(+), 20 deletions(-) diff --git a/scripts/preflight.py b/scripts/preflight.py index 51744080..f4af3bf9 100644 --- a/scripts/preflight.py +++ b/scripts/preflight.py @@ -212,7 +212,10 @@ def _check_ipfs_config() -> int: else: _ok("IPFS config resolved (custom provider)") else: - _ok(f"IPFS config resolved ({provider} provider)") + _warn( + f"IPFS unknown provider '{provider}' — IPFS operations will fail", + fix="dincli system configure-ipfs", + ) except ImportError: _warn("dincli not importable — skipping IPFS config check", fix="pip install -e .") except Exception as exc: @@ -224,25 +227,20 @@ def _network_checks(network: str) -> int: failures = 0 env_key = f"{network.upper()}_RPC_URL" - # Resolve RPC URL the same way dincli does: os.environ → .env + # Resolve RPC URL the same way dincli does: os.environ → .env in cwd rpc_url = os.environ.get(env_key, "") if not rpc_url: - env_file = _REPO_ROOT / ".env" + env_file = Path.cwd() / ".env" if env_file.exists(): - rpc_url = _read_env_value(env_file, env_key) - - # Also check for a network-specific file and warn if absent - net_env_file = _REPO_ROOT / f".env.{network}" - if not net_env_file.exists(): - _warn( - f".env.{network} not found", - fix=f"Copy .env.example to .env.{network} and fill in {env_key}", - ) + try: + rpc_url = _read_env_value(env_file, env_key) + except Exception as exc: + _warn(f".env read failed: {exc} — skipping .env lookup") if not rpc_url: _warn( - f"No RPC URL found for {network} (checked os.environ and .env)", - fix=f"Set {env_key}= in .env", + f"No RPC URL found for {network} (checked os.environ and .env in cwd)", + fix=f"Set {env_key}= in .env in your working directory", ) else: failures += _check_rpc(rpc_url, network=network) @@ -296,9 +294,9 @@ def main() -> None: "--network", metavar="NAME", help=( "Also verify the configured RPC endpoint and IPFS config for NAME. " - "Reads _RPC_URL from os.environ then .env (same lookup " - "dincli uses). Requires a working dincli install for the IPFS check. " - "Example values: local, sepolia_op_devnet." + "Reads _RPC_URL from os.environ then .env in the current " + "working directory (same lookup dincli uses). Requires a working dincli " + "install for the IPFS check. Example values: local, sepolia_op_devnet." ), ) args = parser.parse_args() diff --git a/tests/test_preflight.py b/tests/test_preflight.py index ee6d6dca..44fec6a9 100644 --- a/tests/test_preflight.py +++ b/tests/test_preflight.py @@ -7,6 +7,7 @@ import shutil import subprocess import sys +import types from importlib.util import module_from_spec, spec_from_file_location from pathlib import Path @@ -128,7 +129,9 @@ def test_env_reader_accepts_dotenv_assignment_forms(tmp_path, assignment, expect def test_missing_network_env_still_checks_ipfs(monkeypatch, tmp_path, capsys): - monkeypatch.setattr(preflight, "_REPO_ROOT", tmp_path) + """IPFS check must run even when there is no RPC URL in env or cwd/.env.""" + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("LOCAL_RPC_URL", raising=False) checked = [] monkeypatch.setattr(preflight, "_check_ipfs_config", lambda: checked.append(True) or 0) @@ -136,8 +139,77 @@ def test_missing_network_env_still_checks_ipfs(monkeypatch, tmp_path, capsys): assert checked == [True] output = capsys.readouterr().out - assert ".env.local not found" in output - assert "Copy .env.example to .env.local" in output + assert "No RPC URL found" in output + + +def test_env_read_from_cwd_not_repo_root(monkeypatch, tmp_path, capsys): + """RPC URL must be read from .env in the current working directory, not the + repository root (which may differ when dincli is installed from a wheel).""" + env_file = tmp_path / ".env" + env_file.write_text("LOCAL_RPC_URL=https://rpc.example/cwd-sentinel\n", encoding="utf-8") + + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("LOCAL_RPC_URL", raising=False) + + rpc_calls: list[str] = [] + monkeypatch.setattr(preflight, "_check_rpc", lambda url, **kw: rpc_calls.append(url) or 0) + monkeypatch.setattr(preflight, "_check_ipfs_config", lambda: 0) + + preflight._network_checks("local") + + assert rpc_calls == ["https://rpc.example/cwd-sentinel"], ( + "_network_checks must read .env from cwd, not from the repository root" + ) + + +def test_env_read_failure_still_runs_ipfs_check(monkeypatch, tmp_path, capsys): + """.env decode errors must produce a warning and not abort the IPFS check.""" + env_file = tmp_path / ".env" + env_file.write_bytes(b"\xff\xfe invalid utf-8 \xff") + + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("LOCAL_RPC_URL", raising=False) + + checked: list[bool] = [] + monkeypatch.setattr(preflight, "_check_ipfs_config", lambda: checked.append(True) or 0) + + preflight._network_checks("local") + + assert checked == [True], "IPFS check must run even when .env read fails" + output = capsys.readouterr().out + assert "[WARN]" in output + + +def test_unknown_ipfs_provider_warns(monkeypatch, capsys): + """An unrecognised IPFS provider must produce a [WARN], not an [OK].""" + + class _FakeCfg: + provider = "unknown_backend" + api_url_add = "" + api_url_retrieve = "" + api_key = "" + service_path = "" + + fake_ipfs = types.ModuleType("dincli.services.ipfs") + fake_ipfs.resolve_ipfs_config = lambda: _FakeCfg() + fake_utils = types.ModuleType("dincli.cli.utils") + fake_utils.IPFSConfig = _FakeCfg + + for mod_name, mod_obj in ( + ("dincli", types.ModuleType("dincli")), + ("dincli.services", types.ModuleType("dincli.services")), + ("dincli.services.ipfs", fake_ipfs), + ("dincli.cli", types.ModuleType("dincli.cli")), + ("dincli.cli.utils", fake_utils), + ): + monkeypatch.setitem(sys.modules, mod_name, mod_obj) + + preflight._check_ipfs_config() + + output = capsys.readouterr().out + assert "[WARN]" in output, "unknown provider must produce a warning" + assert "unknown_backend" in output + assert "[OK]" not in output def test_run_annotations_are_postponed_for_python_39_compatibility():