diff --git a/.gitignore b/.gitignore index e509701..ef7fa49 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 a6a92de..5b27be8 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) (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 54df37f..acc9abc 100644 --- a/Documentation/public/getting-started.md +++ b/Documentation/public/getting-started.md @@ -218,6 +218,40 @@ If you are unable to obtain funds through the faucets above, you may also reques --- +# ๐Ÿฉบ Environment Preflight Check + +> **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 +``` + +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 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 +# 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 0000000..f4af3bf --- /dev/null +++ b/scripts/preflight.py @@ -0,0 +1,311 @@ +#!/usr/bin/env python3 +""" +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 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: + 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 Exception: # catches ImportError, OSError (native libs), etc. + 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 .", + ) + + +def _sanitize_url(url: str) -> str: + """Return URL with path, query, and credentials stripped โ€” safe for logging.""" + try: + 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 "" + + +# โ”€โ”€ network checks (require --network, may use dincli) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +def _read_env_value(env_file: Path, key: str) -> str: + """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 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, 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, + 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) + 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: + _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: + _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 in cwd + rpc_url = os.environ.get(env_key, "") + if not rpc_url: + env_file = Path.cwd() / ".env" + if env_file.exists(): + 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 in cwd)", + fix=f"Set {env_key}= in .env in your working directory", + ) + 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 โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +_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 verify the configured RPC endpoint and IPFS config for NAME. " + "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() + 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 0000000..44fec6a --- /dev/null +++ b/tests/test_preflight.py @@ -0,0 +1,216 @@ +"""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 shutil +import subprocess +import sys +import types +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: + 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() + + +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: + 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}" + ) + + 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): + """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) + + assert preflight._network_checks("local") == 0 + + assert checked == [True] + output = capsys.readouterr().out + 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(): + assert isinstance(preflight.run.__annotations__["network"], str)