diff --git a/Developer/BACK_LOG.md b/Developer/BACK_LOG.md index 9579aae6..c3b99c9f 100644 --- a/Developer/BACK_LOG.md +++ b/Developer/BACK_LOG.md @@ -39,6 +39,6 @@ | 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) | πŸ†• Decided, unimplemented β€” Abraham's GI-abort policy (Oct 26, [#222](https://github.com/InfiniteZeroFoundation/DevNet/issues/222)): anyone may abort after a stall timeout, locked rewards go back to their funders, slots are released, non-revealers are still penalised; the timeout value is set with the other testnet parameters in [#155](https://github.com/InfiniteZeroFoundation/DevNet/issues/155). Adds code to `DINTaskCoordinator`, which has 55 B above the size gate ([#201](https://github.com/InfiniteZeroFoundation/DevNet/issues/201)) | | 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) | ⏸️ Parked for testnet β€” Abraham (Oct 26, [#224](https://github.com/InfiniteZeroFoundation/DevNet/issues/224)): S1/S2 plus S5 already cover missed work; leave S6 unwired (or remove it and its parameters) and revisit after testnet. Needs a removal PR or a "parked" note in the docs | | 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) | 🟑 Mostly fixed β€” [PR #255](https://github.com/InfiniteZeroFoundation/DevNet/pull/255) added `scripts/preflight.py` (Python/forge/anvil/node_modules/package checks, `--network` RPC/IPFS checks, 13 tests in `tests/test_preflight.py`, a preflight section in `Documentation/public/getting-started.md`). Remaining (exit non-zero on `node_modules`, eth-account/py-multibase, `--network` RPC/IPFS failures) tracked in [#245](https://github.com/InfiniteZeroFoundation/DevNet/issues/245) | -| 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-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/` | βœ… Fixed β€” PR [#254](https://github.com/InfiniteZeroFoundation/DevNet/pull/254) (issue [#244](https://github.com/InfiniteZeroFoundation/DevNet/issues/244)). `dincli system verify-deployment` added: reads the deployments JSON, checks EIP-1967 impl/admin slots, extcodehash, ProxyAdmin owner(), DinCoordinator wiring, and slasher authorizations. `DeployPlatform.s.sol` and `UpgradePlatform.s.sol` extended to write impl addresses, codehashes, deployer, and git commit. 16 tests in `tests/test_verify_deployment.py`. | | 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`) | πŸ†• Decided, unimplemented β€” Abraham (Oct 26, [#224](https://github.com/InfiniteZeroFoundation/DevNet/issues/224)): make the kill-switch real before the public testnet (at minimum stop new GIs on a disabled model's task contracts), preferably in `DINTaskAuditor` or a small external guard given `DINTaskCoordinator`'s size headroom | | 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`) | βœ… Decided β€” keep as intended (Abraham, Oct 26, [#224](https://github.com/InfiniteZeroFoundation/DevNet/issues/224)): the registration fee is a pay-to-apply anti-spam cost, refunded on neither approval nor rejection. Docs pending (`DINModelRegistry.md`, dinrep role docs) | diff --git a/Documentation/public/guides/verify-deployment.md b/Documentation/public/guides/verify-deployment.md new file mode 100644 index 00000000..1f238df4 --- /dev/null +++ b/Documentation/public/guides/verify-deployment.md @@ -0,0 +1,77 @@ +# Verifying a Platform Deployment + +`dincli system verify-deployment` is a read-only drift check that compares +`foundry/deployments/.json` against the live chain and reports +`[OK]` / `[DRIFT]` / `[ERROR]` per item. + +Run it after every `DeployPlatform.s.sol` or `UpgradePlatform.s.sol` invocation, +and before each Global Iteration if you want to rule out a stale deployment file. + +## Prerequisites + +- `dincli` installed and a network configured (`dincli system configure-network`). +- `foundry/deployments/.json` present (written by the deploy scripts). +- An RPC endpoint reachable for the active network. + +No wallet or signing key is required β€” the command never sends transactions. + +## Quick start + +```bash +# Check the active network (default: local β†’ foundry/deployments/localhost.json) +dincli system verify-deployment + +# Explicit file +dincli system verify-deployment --file foundry/deployments/sepolia_op_devnet.json + +# CI / no color +dincli system verify-deployment --no-color + +# Also verify expected task-coordinator slashers +dincli system verify-deployment --slashers 0xTaskCoord1,0xTaskCoord2 +``` + +## What is checked + +| # | Check | DRIFT condition | +|---|-------|-----------------| +| 1 | EIP-1967 implementation slot | Slot value β‰  `dinXxxImpl` in the JSON | +| 2 | EIP-1967 admin slot | Slot value β‰  `proxyAdminXxx` in the JSON | +| 3 | Implementation extcodehash | `keccak256(eth_getCode(impl))` β‰  `dinXxxImplCodehash` in the JSON | +| 4 | ProxyAdmin `owner()` | On-chain owner β‰  `owner` in the JSON | +| 5 | `DinCoordinator` wiring | `dinToken` / `dinValidatorStakeContract` / `feeRouter` addresses mismatch | +| 5b | `DinFeeRouter.treasury()` wiring | Return value β‰  `dinTreasury` in the JSON (checked independently of `dinCoordinator`) | +| 5c | `DINModelRegistry.dinValidatorStake()` wiring | Return value β‰  `dinValidatorStake` in the JSON | +| 6 | Slasher authorizations | Expected slasher not authorized (only when `--slashers` is given) | + +Checks 1–4 require that `DeployPlatform.s.sol` / `UpgradePlatform.s.sol` was run +at the correct version (issue #244 or later), which writes `dinXxxImpl`, +`dinXxxImplCodehash`, and `owner` into the JSON. Files written by an older +deploy script are missing these keys β€” affected items are reported as `[INFO]` +rather than `[DRIFT]`, and the command still exits 0. + +## Recording metadata at deploy time + +The deploy scripts record the implementation address and its extcodehash +automatically. To also record the deploying git commit, set `GIT_COMMIT_HASH` +before running the script: + +```bash +GIT_COMMIT_HASH=$(git rev-parse HEAD) \ + forge script foundry/script/DeployPlatform.s.sol \ + --rpc-url "$RPC_URL" --broadcast ... +``` + +The same variable works for `UpgradePlatform.s.sol`. + +## Exit codes + +| Code | Meaning | +|------|---------| +| 0 | All checks passed (or reported as `[INFO]` due to missing recorded values) | +| 1 | At least one `[DRIFT]` or `[ERROR]` item | + +## Limitations + +A clean run does not prove that nothing else was deployed or authorized outside +the deployments file. Only the contracts recorded in the file are checked. diff --git a/dincli/cli/system.py b/dincli/cli/system.py index 1f779b81..98953184 100644 --- a/dincli/cli/system.py +++ b/dincli/cli/system.py @@ -72,7 +72,7 @@ def system( ), ): # If the subcommand is one that doesn't need an account, we skip the default setup logic - if ctx.invoked_subcommand in ["register-wallet", "connect-wallet", "connect-demo-wallet", "init", "welcome", "where", "configure-network", "configure-demo", "configure-ipfs", "read-wallet", "show-index", "din-info", "import-deployments", "configure-logging", "dump-abi", "reset-all", "todo", "dataset", "send-eth", "run-worker-counting", "run-node-counting", "list-accounts", "set-wallet", "bridge-eth"]: + if ctx.invoked_subcommand in ["register-wallet", "connect-wallet", "connect-demo-wallet", "init", "welcome", "where", "configure-network", "configure-demo", "configure-ipfs", "read-wallet", "show-index", "din-info", "import-deployments", "configure-logging", "dump-abi", "reset-all", "todo", "dataset", "send-eth", "run-worker-counting", "run-node-counting", "list-accounts", "set-wallet", "bridge-eth", "verify-deployment"]: return effective_network, w3, account, console = ctx.obj.get_en_w3_account_console() @@ -1138,6 +1138,341 @@ def import_deployments(ctx: typer.Context, console.print(f"[magenta]{_label}:[/magenta] {entry[_key]}") +# ── EIP-1967 storage slots ────────────────────────────────────────────────── +# implementation: bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1) +_EIP1967_IMPL_SLOT = "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc" +# admin: bytes32(uint256(keccak256("eip1967.proxy.admin")) - 1) +_EIP1967_ADMIN_SLOT = "0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103" + +# Minimal ABI fragments used by verify-deployment +_OWNER_ABI = [{"inputs": [], "name": "owner", "outputs": [{"type": "address"}], "stateMutability": "view", "type": "function"}] +_WIRING_ABI = [ + {"inputs": [], "name": "dinToken", "outputs": [{"type": "address"}], "stateMutability": "view", "type": "function"}, + {"inputs": [], "name": "dinValidatorStakeContract","outputs": [{"type": "address"}], "stateMutability": "view", "type": "function"}, + {"inputs": [], "name": "feeRouter", "outputs": [{"type": "address"}], "stateMutability": "view", "type": "function"}, +] +_SLASHER_ABI = [{"inputs": [{"type": "address"}], "name": "slasherContracts", "outputs": [{"type": "bool"}], "stateMutability": "view", "type": "function"}] +_FEE_ROUTER_ABI = [{"inputs": [], "name": "treasury", "outputs": [{"type": "address"}], "stateMutability": "view", "type": "function"}] +_REGISTRY_ABI = [{"inputs": [], "name": "dinValidatorStake", "outputs": [{"type": "address"}], "stateMutability": "view", "type": "function"}] + +# Proxy names and the matching JSON keys for proxy / proxyAdmin / impl / codehash +_PROXIES = [ + ("DinTreasury", "dinTreasury", "proxyAdminTreasury", "dinTreasuryImpl", "dinTreasuryImplCodehash"), + ("DinToken", "dinToken", "proxyAdminToken", "dinTokenImpl", "dinTokenImplCodehash"), + ("DinCoordinator", "dinCoordinator", "proxyAdminCoordinator", "dinCoordinatorImpl", "dinCoordinatorImplCodehash"), + ("DinFeeRouter", "dinFeeRouter", "proxyAdminFeeRouter", "dinFeeRouterImpl", "dinFeeRouterImplCodehash"), + ("DinValidatorStake", "dinValidatorStake", "proxyAdminStake", "dinValidatorStakeImpl", "dinValidatorStakeImplCodehash"), + ("DINModelRegistry", "dinModelRegistry", "proxyAdminRegistry", "dinModelRegistryImpl", "dinModelRegistryImplCodehash"), + ("DinEmission", "dinEmission", "proxyAdminEmission", "dinEmissionImpl", "dinEmissionImplCodehash"), +] + + +def _slot_to_address(w3, raw: bytes) -> str: + """Convert a 32-byte EIP-1967 slot value to a checksummed address.""" + return w3.to_checksum_address("0x" + raw.hex()[-40:]) + + +def _read_impl_slot(w3, proxy: str) -> str: + raw = w3.eth.get_storage_at(proxy, _EIP1967_IMPL_SLOT) + return _slot_to_address(w3, raw) + + +def _read_admin_slot(w3, proxy: str) -> str: + raw = w3.eth.get_storage_at(proxy, _EIP1967_ADMIN_SLOT) + return _slot_to_address(w3, raw) + + +def _codehash(w3, addr: str) -> str: + """keccak256 of the runtime bytecode at addr, matching EVM extcodehash.""" + from eth_utils import keccak + code = w3.eth.get_code(addr) + if not code: + return "0x" + "00" * 32 + return "0x" + keccak(code).hex() + + +@app.command("verify-deployment") +def verify_deployment( + ctx: typer.Context, + file: Optional[Path] = typer.Option( + None, "--file", "-f", + help="Explicit deployments JSON path. Defaults to foundry/deployments/.json.", + ), + no_color: bool = typer.Option( + False, "--no-color", + help="Plain-text output without Rich markup (auto-detected when stdout is not a tty).", + ), + slashers: Optional[str] = typer.Option( + None, "--slashers", + help="Comma-separated list of expected slasher (task-coordinator) addresses. " + "If omitted the on-chain slasher list is reported as informational only.", + ), +): + """ + Verify that foundry/deployments/.json matches the live chain. + + Reads the deployments file (written by DeployPlatform.s.sol / UpgradePlatform.s.sol), + connects to the configured RPC, and checks: + + \b + 1. EIP-1967 implementation slot of each proxy + 2. EIP-1967 admin slot of each proxy + 3. extcodehash of each implementation vs the recorded codehash + 4. owner() of each ProxyAdmin + 5. DinCoordinator cross-contract wiring (dinToken / dinValidatorStakeContract / feeRouter) + 5b. DinFeeRouter.treasury() points at the recorded dinTreasury proxy + 5c. DINModelRegistry.dinValidatorStake() points at the recorded dinValidatorStake proxy + 6. DinValidatorStake slasher authorizations (informational when no --slashers given) + + Exits 0 when all checks pass, 1 on any DRIFT or ERROR. + + This check is read-only; it never sends transactions. + It covers only what the deployments file records β€” a clean run does not + prove that nothing else was deployed or authorized outside that file. + """ + import sys + from eth_utils import keccak + + use_plain = no_color or not sys.stdout.isatty() + raw_console = ctx.obj.console + + def _emit(status: str, msg: str): + tag = f"[{status}]".ljust(7) + if use_plain: + raw_console.print(f"{tag} {msg}", markup=False, highlight=False) + else: + color = {"OK": "green", "DRIFT": "red", "ERROR": "red", "INFO": "cyan", "WARN": "yellow"}.get(status, "white") + raw_console.print(f"[{color}]{tag}[/{color}] {msg}") + + network = ctx.obj.network + w3 = ctx.obj.w3 + + if file is None: + network_key = _DEPLOYMENT_CHAIN_NETWORK_FOR.get(network, network) + file = Path("foundry") / "deployments" / f"{network_key}.json" + + file = file.expanduser() + if not file.exists(): + _emit("ERROR", f"Deployments file not found: {file}") + raise typer.Exit(1) + + try: + with open(file) as fh: + deps = json.load(fh) + except (json.JSONDecodeError, OSError) as e: + _emit("ERROR", f"Failed to read deployments file: {e}") + raise typer.Exit(1) + + commit = deps.get("deployedAtCommit", "") + if commit: + _emit("INFO", f"deployedAtCommit: {commit}") + recorded_owner = deps.get("owner", "") + + drift = False + + # ── Check 1 + 2: EIP-1967 impl and admin slots ────────────────────────── + for label, proxy_key, admin_key, impl_key, _codehash_key in _PROXIES: + proxy_addr = deps.get(proxy_key) + if not proxy_addr: + _emit("WARN", f"{label}: proxy address not in deployments file β€” skipped") + continue + try: + proxy_addr = w3.to_checksum_address(proxy_addr) + except Exception: + _emit("ERROR", f"{label}: invalid proxy address in file: {proxy_addr}") + drift = True + continue + + # Check 1 β€” implementation slot + recorded_impl = deps.get(impl_key, "") + try: + on_chain_impl = _read_impl_slot(w3, proxy_addr) + except Exception as exc: + _emit("ERROR", f"{label} impl slot read failed: {exc}") + drift = True + else: + if recorded_impl and w3.to_checksum_address(recorded_impl) == on_chain_impl: + _emit("OK", f"{label} proxy β†’ impl {on_chain_impl}") + elif not recorded_impl: + _emit("INFO", f"{label} impl (no recorded value): on-chain {on_chain_impl}") + else: + _emit("DRIFT", f"{label} impl: recorded {recorded_impl} actual {on_chain_impl}") + drift = True + + # Check 2 β€” admin slot + recorded_admin = deps.get(admin_key, "") + try: + on_chain_admin = _read_admin_slot(w3, proxy_addr) + except Exception as exc: + _emit("ERROR", f"{label} admin slot read failed: {exc}") + drift = True + else: + if recorded_admin and w3.to_checksum_address(recorded_admin) == on_chain_admin: + _emit("OK", f"{label} proxyAdmin {on_chain_admin}") + elif not recorded_admin: + _emit("INFO", f"{label} proxyAdmin (no recorded value): on-chain {on_chain_admin}") + else: + _emit("DRIFT", f"{label} proxyAdmin: recorded {recorded_admin} actual {on_chain_admin}") + drift = True + + # ── Check 3: extcodehash of each implementation ────────────────────────── + for label, _proxy_key, _admin_key, impl_key, codehash_key in _PROXIES: + impl_addr = deps.get(impl_key, "") + recorded_ch = deps.get(codehash_key, "") + if not impl_addr or not recorded_ch: + continue + try: + impl_addr = w3.to_checksum_address(impl_addr) + actual_ch = _codehash(w3, impl_addr) + except Exception as exc: + _emit("ERROR", f"{label} codehash check failed: {exc}") + drift = True + continue + if actual_ch.lower() == recorded_ch.lower(): + _emit("OK", f"{label} impl codehash {actual_ch[:18]}…") + else: + _emit("DRIFT", f"{label} impl codehash: recorded {recorded_ch[:18]}… actual {actual_ch[:18]}…") + drift = True + + # ── Check 4: owner() of each ProxyAdmin ────────────────────────────────── + for label, _proxy_key, admin_key, _impl_key, _codehash_key in _PROXIES: + admin_addr = deps.get(admin_key, "") + if not admin_addr: + continue + try: + admin_addr = w3.to_checksum_address(admin_addr) + contract = w3.eth.contract(address=admin_addr, abi=_OWNER_ABI) + actual_owner = contract.functions.owner().call() + except Exception as exc: + _emit("ERROR", f"{label} proxyAdmin.owner() failed: {exc}") + drift = True + continue + if recorded_owner and w3.to_checksum_address(recorded_owner) == actual_owner: + _emit("OK", f"{label} proxyAdmin owner {actual_owner}") + elif not recorded_owner: + _emit("INFO", f"{label} proxyAdmin owner (no recorded value): on-chain {actual_owner}") + else: + _emit("DRIFT", f"{label} proxyAdmin owner: recorded {recorded_owner} actual {actual_owner}") + drift = True + + # ── Check 4b: owner() of each application proxy ────────────────────────── + for label, proxy_key, _admin_key, _impl_key, _codehash_key in _PROXIES: + proxy_addr = deps.get(proxy_key, "") + if not proxy_addr: + continue + try: + proxy_addr = w3.to_checksum_address(proxy_addr) + contract = w3.eth.contract(address=proxy_addr, abi=_OWNER_ABI) + actual_owner = contract.functions.owner().call() + except Exception as exc: + _emit("ERROR", f"{label} owner() failed: {exc}") + drift = True + continue + if recorded_owner and w3.to_checksum_address(recorded_owner) == actual_owner: + _emit("OK", f"{label} owner {actual_owner}") + elif not recorded_owner: + _emit("INFO", f"{label} owner (no recorded value): on-chain {actual_owner}") + else: + _emit("DRIFT", f"{label} owner: recorded {recorded_owner} actual {actual_owner}") + drift = True + + # ── Check 5: DinCoordinator cross-contract wiring ──────────────────────── + coordinator_addr = deps.get("dinCoordinator", "") + if coordinator_addr: + try: + coordinator_addr = w3.to_checksum_address(coordinator_addr) + coord = w3.eth.contract(address=coordinator_addr, abi=_WIRING_ABI) + wiring_checks = [ + ("dinToken", "dinToken", coord.functions.dinToken().call()), + ("dinValidatorStakeContract", "dinValidatorStake", coord.functions.dinValidatorStakeContract().call()), + ("feeRouter", "dinFeeRouter", coord.functions.feeRouter().call()), + ] + for desc, dep_key, actual in wiring_checks: + recorded = deps.get(dep_key, "") + if recorded and w3.to_checksum_address(recorded) == w3.to_checksum_address(actual): + _emit("OK", f"wiring: DinCoordinator.{desc} β†’ {actual}") + elif not recorded: + _emit("INFO", f"wiring: DinCoordinator.{desc} on-chain {actual}") + else: + _emit("DRIFT", f"wiring: DinCoordinator.{desc}: recorded {recorded} actual {actual}") + drift = True + except Exception as exc: + _emit("ERROR", f"DinCoordinator wiring check failed: {exc}") + drift = True + + # ── Check 5b: FeeRouter.treasury wiring (independent of coordinator) ───── + fee_router_addr = deps.get("dinFeeRouter", "") + treasury_addr = deps.get("dinTreasury", "") + if fee_router_addr and treasury_addr: + try: + fee_router_cs = w3.to_checksum_address(fee_router_addr) + treasury_cs = w3.to_checksum_address(treasury_addr) + if int(fee_router_cs, 16) == 0: + _emit("WARN", "DinFeeRouter address is zero β€” treasury wiring check skipped") + else: + fee_router = w3.eth.contract(address=fee_router_cs, abi=_FEE_ROUTER_ABI) + actual_treasury = w3.to_checksum_address(fee_router.functions.treasury().call()) + if treasury_cs == actual_treasury: + _emit("OK", f"wiring: DinFeeRouter.treasury β†’ {actual_treasury}") + else: + _emit("DRIFT", f"wiring: DinFeeRouter.treasury: recorded {treasury_cs} actual {actual_treasury}") + drift = True + except Exception as exc: + _emit("ERROR", f"DinFeeRouter.treasury() check failed: {exc}") + drift = True + + # ── Check 5c: DINModelRegistry.dinValidatorStake wiring ────────────────── + registry_addr = deps.get("dinModelRegistry", "") + stake_dep = deps.get("dinValidatorStake", "") + if registry_addr and stake_dep: + try: + registry_cs = w3.to_checksum_address(registry_addr) + if int(registry_cs, 16) == 0: + pass # no registry recorded β€” skip + else: + stake_cs = w3.to_checksum_address(stake_dep) + registry = w3.eth.contract(address=registry_cs, abi=_REGISTRY_ABI) + actual_stake = w3.to_checksum_address(registry.functions.dinValidatorStake().call()) + if stake_cs == actual_stake: + _emit("OK", f"wiring: DINModelRegistry.dinValidatorStake β†’ {actual_stake}") + else: + _emit("DRIFT", f"wiring: DINModelRegistry.dinValidatorStake: recorded {stake_cs} actual {actual_stake}") + drift = True + except Exception as exc: + _emit("ERROR", f"DINModelRegistry.dinValidatorStake check failed: {exc}") + drift = True + + # ── Check 6: slasher authorizations ────────────────────────────────────── + stake_addr = deps.get("dinValidatorStake", "") + if stake_addr: + try: + stake_addr = w3.to_checksum_address(stake_addr) + stake_contract = w3.eth.contract(address=stake_addr, abi=_SLASHER_ABI) + if slashers: + expected_list = [s.strip() for s in slashers.split(",") if s.strip()] + for addr in expected_list: + try: + addr = w3.to_checksum_address(addr) + authorized = stake_contract.functions.slasherContracts(addr).call() + except Exception as exc: + _emit("ERROR", f"slasher check for {addr} failed: {exc}") + drift = True + continue + if authorized: + _emit("OK", f"slasher {addr} authorized on DinValidatorStake") + else: + _emit("DRIFT", f"slasher {addr} NOT authorized on DinValidatorStake") + drift = True + else: + _emit("INFO", "slasher list: pass --slashers to verify; on-chain list not enumerated") + except Exception as exc: + _emit("ERROR", f"DinValidatorStake slasher check failed: {exc}") + drift = True + + if drift: + raise typer.Exit(1) + + @app.command("reset-all") def reset_all( ctx: typer.Context, diff --git a/foundry/script/DeployPlatform.s.sol b/foundry/script/DeployPlatform.s.sol index 826231b7..a2cbbef1 100644 --- a/foundry/script/DeployPlatform.s.sol +++ b/foundry/script/DeployPlatform.s.sol @@ -66,6 +66,9 @@ import {DeploymentsPath} from "./DeploymentsPath.sol"; /// --account \ /// --sender /// +/// To record the deploying commit in the JSON, set GIT_COMMIT_HASH before running: +/// GIT_COMMIT_HASH=$(git rev-parse HEAD) forge script script/DeployPlatform.s.sol ... +/// /// Then import into dincli (reads foundry/deployments/.json for the /// active dincli network): /// dincli system import-deployments @@ -127,22 +130,7 @@ contract DeployPlatform is DeploymentsPath { Deployment memory d = deploy(t, msg.sender); // 16. Write deployments JSON β€” read by `dincli system import-deployments` - _writeDeployments( - d.dinTreasury, - d.dinToken, - d.dinCoordinator, - d.dinFeeRouter, - d.dinValidatorStake, - d.dinModelRegistry, - d.dinEmission, - d.proxyAdminTreasury, - d.proxyAdminToken, - d.proxyAdminCoordinator, - d.proxyAdminFeeRouter, - d.proxyAdminStake, - d.proxyAdminRegistry, - d.proxyAdminEmission - ); + _writeDeployments(d, msg.sender); } /// @notice The in-code defaults, i.e. what readTokenomics() returns with no env set. @@ -408,40 +396,66 @@ contract DeployPlatform is DeploymentsPath { } } - function _writeDeployments( - address dinTreasury, - address dinToken, - address dinCoordinator, - address dinFeeRouter, - address dinValidatorStake, - address dinModelRegistry, - address dinEmission, - address proxyAdminTreasury, - address proxyAdminToken, - address proxyAdminCoordinator, - address proxyAdminFeeRouter, - address proxyAdminStake, - address proxyAdminRegistry, - address proxyAdminEmission - ) internal { + /// @dev Fetches the EVM extcodehash of an implementation address. + function _implCodehash(address impl) internal view returns (bytes32 ch) { + assembly { ch := extcodehash(impl) } + } + + /// @dev Writes the deployments JSON. + /// Includes proxy addresses, per-proxy implementation addresses and their + /// extcodehashes (for `dincli system verify-deployment` check 1 and 3), + /// the deployer (ProxyAdmin owner, check 4), and the optional git commit + /// hash recorded via GIT_COMMIT_HASH env var (check meta). + function _writeDeployments(Deployment memory d, address deployer) internal { + // Resolve implementations via EIP-1967 slot reads. + address implTreasury = Upgrades.getImplementationAddress(d.dinTreasury); + address implToken = Upgrades.getImplementationAddress(d.dinToken); + address implCoordinator = Upgrades.getImplementationAddress(d.dinCoordinator); + address implFeeRouter = Upgrades.getImplementationAddress(d.dinFeeRouter); + address implStake = Upgrades.getImplementationAddress(d.dinValidatorStake); + address implRegistry = Upgrades.getImplementationAddress(d.dinModelRegistry); + address implEmission = Upgrades.getImplementationAddress(d.dinEmission); + string memory json = "deployments"; - vm.serializeAddress(json, "dinTreasury", dinTreasury); - vm.serializeAddress(json, "dinToken", dinToken); - vm.serializeAddress(json, "dinCoordinator", dinCoordinator); - vm.serializeAddress(json, "dinFeeRouter", dinFeeRouter); - vm.serializeAddress(json, "dinValidatorStake", dinValidatorStake); - vm.serializeAddress(json, "dinModelRegistry", dinModelRegistry); - vm.serializeAddress(json, "dinEmission", dinEmission); - vm.serializeAddress(json, "proxyAdminTreasury", proxyAdminTreasury); - vm.serializeAddress(json, "proxyAdminToken", proxyAdminToken); - vm.serializeAddress(json, "proxyAdminCoordinator", proxyAdminCoordinator); - vm.serializeAddress(json, "proxyAdminFeeRouter", proxyAdminFeeRouter); - vm.serializeAddress(json, "proxyAdminStake", proxyAdminStake); - vm.serializeAddress(json, "proxyAdminRegistry", proxyAdminRegistry); - string memory finalJson = vm.serializeAddress( + // Proxy addresses + vm.serializeAddress(json, "dinTreasury", d.dinTreasury); + vm.serializeAddress(json, "dinToken", d.dinToken); + vm.serializeAddress(json, "dinCoordinator", d.dinCoordinator); + vm.serializeAddress(json, "dinFeeRouter", d.dinFeeRouter); + vm.serializeAddress(json, "dinValidatorStake", d.dinValidatorStake); + vm.serializeAddress(json, "dinModelRegistry", d.dinModelRegistry); + vm.serializeAddress(json, "dinEmission", d.dinEmission); + // ProxyAdmin addresses + vm.serializeAddress(json, "proxyAdminTreasury", d.proxyAdminTreasury); + vm.serializeAddress(json, "proxyAdminToken", d.proxyAdminToken); + vm.serializeAddress(json, "proxyAdminCoordinator", d.proxyAdminCoordinator); + vm.serializeAddress(json, "proxyAdminFeeRouter", d.proxyAdminFeeRouter); + vm.serializeAddress(json, "proxyAdminStake", d.proxyAdminStake); + vm.serializeAddress(json, "proxyAdminRegistry", d.proxyAdminRegistry); + vm.serializeAddress(json, "proxyAdminEmission", d.proxyAdminEmission); + // Implementation addresses (for verify-deployment check 1) + vm.serializeAddress(json, "dinTreasuryImpl", implTreasury); + vm.serializeAddress(json, "dinTokenImpl", implToken); + vm.serializeAddress(json, "dinCoordinatorImpl", implCoordinator); + vm.serializeAddress(json, "dinFeeRouterImpl", implFeeRouter); + vm.serializeAddress(json, "dinValidatorStakeImpl", implStake); + vm.serializeAddress(json, "dinModelRegistryImpl", implRegistry); + vm.serializeAddress(json, "dinEmissionImpl", implEmission); + // Implementation extcodehashes (for verify-deployment check 3) + vm.serializeBytes32(json, "dinTreasuryImplCodehash", _implCodehash(implTreasury)); + vm.serializeBytes32(json, "dinTokenImplCodehash", _implCodehash(implToken)); + vm.serializeBytes32(json, "dinCoordinatorImplCodehash", _implCodehash(implCoordinator)); + vm.serializeBytes32(json, "dinFeeRouterImplCodehash", _implCodehash(implFeeRouter)); + vm.serializeBytes32(json, "dinValidatorStakeImplCodehash", _implCodehash(implStake)); + vm.serializeBytes32(json, "dinModelRegistryImplCodehash", _implCodehash(implRegistry)); + vm.serializeBytes32(json, "dinEmissionImplCodehash", _implCodehash(implEmission)); + // Deployer = ProxyAdmin owner (for verify-deployment check 4) + vm.serializeAddress(json, "owner", deployer); + // Git commit at deploy time (optional; set GIT_COMMIT_HASH env var) + string memory finalJson = vm.serializeString( json, - "proxyAdminEmission", - proxyAdminEmission + "deployedAtCommit", + vm.envOr("GIT_COMMIT_HASH", string("")) ); string memory outDir = string.concat(vm.projectRoot(), "/deployments"); diff --git a/foundry/script/UpgradePlatform.s.sol b/foundry/script/UpgradePlatform.s.sol index 2cc18cb4..41126359 100644 --- a/foundry/script/UpgradePlatform.s.sol +++ b/foundry/script/UpgradePlatform.s.sol @@ -65,6 +65,30 @@ contract UpgradePlatform is DeploymentsPath { address newImpl = Upgrades.getImplementationAddress(proxyAddress); console.log("Implementation: ", newImpl); + + _writeUpgradeMetadata(newImpl, proxyKey, deploymentsPath, vm.envOr("GIT_COMMIT_HASH", string(""))); + } + + /// @dev Writes the new implementation address, its extcodehash, and (when + /// non-empty) the git commit hash to the deployments JSON. + /// Extracted so UpgradePlatformScriptTest can call it directly without + /// going through the broadcast/upgrade path. + function _writeUpgradeMetadata( + address newImpl, + string memory proxyKey, + string memory deploymentsPath, + string memory gitCommit + ) internal { + bytes32 newCodehash; + assembly { newCodehash := extcodehash(newImpl) } + string memory implKey = string.concat(proxyKey, "Impl"); + string memory codehashKey = string.concat(proxyKey, "ImplCodehash"); + vm.writeJson(vm.toString(newImpl), deploymentsPath, string.concat(".", implKey)); + vm.writeJson(vm.toString(newCodehash), deploymentsPath, string.concat(".", codehashKey)); + if (bytes(gitCommit).length > 0) { + vm.writeJson(gitCommit, deploymentsPath, ".deployedAtCommit"); + } + console.log("Deployments updated:", deploymentsPath); } function _proxyKey( diff --git a/foundry/test/DeployPlatformScript.t.sol b/foundry/test/DeployPlatformScript.t.sol index cf218fb6..ce8aac9c 100644 --- a/foundry/test/DeployPlatformScript.t.sol +++ b/foundry/test/DeployPlatformScript.t.sol @@ -7,6 +7,13 @@ import {ProxyAdmin} from "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.s import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; import {DeployPlatform} from "../script/DeployPlatform.s.sol"; + +/// @dev Exposes the internal _writeDeployments for white-box testing. +contract DeployPlatformHarness is DeployPlatform { + function writeDeployments(Deployment memory d, address deployer) external { + _writeDeployments(d, deployer); + } +} import {DinCoordinator} from "../src/DinCoordinator.sol"; import {DinValidatorStake} from "../src/DinValidatorStake.sol"; import {DinEmission} from "../src/DinEmission.sol"; @@ -20,15 +27,20 @@ import {DinEmission} from "../src/DinEmission.sol"; /// deploy() takes the values as an argument instead of reading the /// environment, so no deploy test depends on env. The env-read path /// (readTokenomics) is covered by exactly one test, which is the only -/// place in foundry/test that sets these keys; see its comment. No test -/// calls run(), which would overwrite foundry/deployments/*.json. Run with: +/// place in foundry/test that sets these tokenomics keys; see its comment. +/// test_writeDeployments_writesImplAndCodehashMetadata calls _writeDeployments +/// via DeployPlatformHarness using DEPLOYMENTS_NETWORK=test-impl-metadata, +/// so it writes foundry/deployments/test-impl-metadata.json rather than +/// clobbering foundry/deployments/localhost.json. Run with: /// forge test --match-contract DeployPlatformScriptTest -vv contract DeployPlatformScriptTest is Test { DeployPlatform internal script; + DeployPlatformHarness internal harness; address internal deployer = makeAddr("deployer"); function setUp() public { script = new DeployPlatform(); + harness = new DeployPlatformHarness(); } function _overrides() internal pure returns (DeployPlatform.Tokenomics memory) { @@ -156,6 +168,34 @@ contract DeployPlatformScriptTest is Test { script.deploy(t, deployer); } + /// @dev Verifies the JSON-writing cycle used by dincli system verify-deployment. + /// Calls _writeDeployments via DeployPlatformHarness (which exposes the + /// internal function) with a DEPLOYMENTS_NETWORK override to avoid + /// clobbering foundry/deployments/localhost.json. + /// + /// vm.setEnv cannot unset a key, so DEPLOYMENTS_NETWORK stays set for the + /// rest of the run. Any future test that reads DEPLOYMENTS_NETWORK MUST + /// go into this function, or it will see "test-impl-metadata". + function test_writeDeployments_writesImplAndCodehashMetadata() public { + vm.setEnv("DEPLOYMENTS_NETWORK", "test-impl-metadata"); + DeployPlatform.Tokenomics memory t = script.defaultTokenomics(); + DeployPlatform.Deployment memory d = script.deploy(t, deployer); + harness.writeDeployments(d, deployer); + + string memory path = string.concat( + vm.projectRoot(), "/deployments/test-impl-metadata.json" + ); + string memory content = vm.readFile(path); + + address implTreasury = vm.parseJsonAddress(content, ".dinTreasuryImpl"); + bytes32 chTreasury = vm.parseJsonBytes32(content, ".dinTreasuryImplCodehash"); + address owner_ = vm.parseJsonAddress(content, ".owner"); + + assertNotEq(implTreasury, address(0), "dinTreasuryImpl not written"); + assertNotEq(chTreasury, bytes32(0), "dinTreasuryImplCodehash not written"); + assertEq(owner_, deployer, "owner must be the deployer"); + } + /// @dev readTokenomics() returns the defaults with no key set, and exactly /// the env values once every key is set. /// diff --git a/foundry/test/UpgradePlatformScript.t.sol b/foundry/test/UpgradePlatformScript.t.sol new file mode 100644 index 00000000..db40e131 --- /dev/null +++ b/foundry/test/UpgradePlatformScript.t.sol @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.28; + +import {Test} from "forge-std/Test.sol"; + +import {DeployPlatform} from "../script/DeployPlatform.s.sol"; +import {UpgradePlatform} from "../script/UpgradePlatform.s.sol"; +import {DinTokenV2} from "../src/upgrade/DinTokenV2.sol"; + +/// @dev Exposes _writeUpgradeMetadata for white-box testing. +contract UpgradePlatformHarness is UpgradePlatform { + function writeUpgradeMetadata( + address newImpl, + string memory proxyKey, + string memory deploymentsPath, + string memory gitCommit + ) external { + _writeUpgradeMetadata(newImpl, proxyKey, deploymentsPath, gitCommit); + } +} + +/// @dev Exposes _writeDeployments to create the initial deployments JSON needed +/// by the upgrade metadata test, without coupling to DeployPlatformScript.t.sol. +contract DeployHarness is DeployPlatform { + function writeDeployments(Deployment memory d, address deployer_) external { + _writeDeployments(d, deployer_); + } +} + +/// @notice Script-level coverage for UpgradePlatform.s.sol's metadata-writing +/// block (_writeUpgradeMetadata). Verifies that after an upgrade the +/// deployments JSON records the correct implementation address, +/// extcodehash, and (when GIT_COMMIT_HASH is supplied) commit hash. +/// +/// test_writeUpgradeMetadata_writesImplCodehashAndCommit sets +/// DEPLOYMENTS_NETWORK=test-upgrade-metadata, which stays set for the +/// entire forge process. Any future test in this contract that reads +/// DEPLOYMENTS_NETWORK MUST go into that function. +/// Run with: forge test --match-contract UpgradePlatformScriptTest -vv +contract UpgradePlatformScriptTest is Test { + UpgradePlatformHarness internal harness; + DeployHarness internal deployHarness; + DeployPlatform internal deployScript; + address internal deployer = makeAddr("deployer"); + + function setUp() public { + harness = new UpgradePlatformHarness(); + deployHarness = new DeployHarness(); + deployScript = new DeployPlatform(); + } + + /// @dev Verifies the metadata-writing block of UpgradePlatform.s.sol: + /// (a) recorded implementation address matches the upgraded impl, + /// (b) recorded codehash matches extcodehash(newImpl), + /// (c) .deployedAtCommit is written when a non-empty commit is passed. + /// + /// Both assertions live in one function because vm.setEnv cannot unset + /// a key, so DEPLOYMENTS_NETWORK stays as "test-upgrade-metadata" and + /// GIT_COMMIT_HASH stays set for the rest of the run. Any future test + /// that reads either key MUST go into this function. + function test_writeUpgradeMetadata_writesImplCodehashAndCommit() public { + vm.setEnv("DEPLOYMENTS_NETWORK", "test-upgrade-metadata"); + + // Write an initial deployments JSON so the three-arg vm.writeJson calls + // inside _writeUpgradeMetadata have an existing file to update. + DeployPlatform.Tokenomics memory t = deployScript.defaultTokenomics(); + DeployPlatform.Deployment memory d = deployScript.deploy(t, deployer); + deployHarness.writeDeployments(d, deployer); + + string memory path = string.concat( + vm.projectRoot(), "/deployments/test-upgrade-metadata.json" + ); + + // Deploy DinTokenV2 to get a real implementation address and codehash. + // We only need the bytecode to be present; the contract need not be + // initialized for extcodehash to return a non-zero value. + address newImpl = address(new DinTokenV2()); + bytes32 expectedCodehash; + assembly { expectedCodehash := extcodehash(newImpl) } + + // --- (a) & (b): impl address and codehash --- + harness.writeUpgradeMetadata(newImpl, "dinToken", path, ""); + + string memory content = vm.readFile(path); + address recordedImpl = vm.parseJsonAddress(content, ".dinTokenImpl"); + bytes32 recordedCodehash = vm.parseJsonBytes32(content, ".dinTokenImplCodehash"); + + assertEq(recordedImpl, newImpl, "dinTokenImpl must match upgraded implementation"); + assertNotEq(recordedCodehash, bytes32(0), "dinTokenImplCodehash must not be zero"); + assertEq(recordedCodehash, expectedCodehash,"dinTokenImplCodehash must match extcodehash(newImpl)"); + + // --- (c): commit hash written when non-empty --- + string memory commit = "abc1234def5678900000000000000000000000000"; + vm.setEnv("GIT_COMMIT_HASH", commit); + harness.writeUpgradeMetadata(newImpl, "dinToken", path, commit); + + content = vm.readFile(path); + string memory recordedCommit = vm.parseJsonString(content, ".deployedAtCommit"); + assertEq(recordedCommit, commit, ".deployedAtCommit must match GIT_COMMIT_HASH"); + } +} diff --git a/pyproject.toml b/pyproject.toml index 3ec1fb74..852a017e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,8 @@ dependencies = [ "py-multibase>=1.0.0", "cryptography>=42.0.0", "PyNaCl>=1.5.0", - "eth-abi>=4.0.0" + "eth-abi>=4.0.0", + "eth-utils>=2.0.0" ] [project.optional-dependencies] diff --git a/tests/test_develop_small_backports.py b/tests/test_develop_small_backports.py index b594feac..ee7de8ee 100644 --- a/tests/test_develop_small_backports.py +++ b/tests/test_develop_small_backports.py @@ -47,6 +47,7 @@ def _declared_distributions(): "multibase": "py-multibase", "eth_account": "eth-account", "eth_abi": "eth-abi", + "eth_utils": "eth-utils", "nacl": "pynacl", "yaml": "pyyaml", } diff --git a/tests/test_verify_deployment.py b/tests/test_verify_deployment.py new file mode 100644 index 00000000..f18fdeca --- /dev/null +++ b/tests/test_verify_deployment.py @@ -0,0 +1,426 @@ +""" +Tests for `dincli system verify-deployment`. + +Mocking pattern mirrors tests/test_dintoken.py: a DummyContextObj holds +a DummyWeb3 that returns scripted values; the command function is called +directly (not through CliRunner) so we can assert on exit codes without +subprocess overhead. +""" + +import json +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest +import typer +import click +from rich.console import Console + +from dincli.cli import system as system_mod +from dincli.cli.system import verify_deployment + + +# ── EIP-1967 slots (mirrors system.py) ────────────────────────────────────── +_IMPL_SLOT = "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc" +_ADMIN_SLOT = "0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103" + +# Deterministic fixture addresses +_OWNER = "0xDe1111111111111111111111111111111111111D" +_PROXY = "0xAaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaA" +_IMPL = "0xBbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" +_ADMIN = "0xCccccccccccccccccccccccccccccccccccccccc" +_CODE = b"\x60\x80\x60\x40" # minimal non-empty bytecode +_CODEHASH = "0x" + __import__("eth_utils").keccak(_CODE).hex() + +# ── Minimal deployments JSON (one proxy) ──────────────────────────────────── + +def _one_proxy_deps(impl_addr=_IMPL, admin_addr=_ADMIN, codehash=_CODEHASH, owner=_OWNER, + treasury=None, fee_router=None) -> dict: + return { + "dinCoordinator": "0x0000000000000000000000000000000000000000", + "dinToken": "0x0000000000000000000000000000000000000000", + "dinValidatorStake": "0x0000000000000000000000000000000000000000", + "dinModelRegistry": "0x0000000000000000000000000000000000000000", + "dinTreasury": treasury or _PROXY, + "dinTreasuryImpl": impl_addr, + "dinTreasuryImplCodehash": codehash, + "proxyAdminTreasury": admin_addr, + "dinFeeRouter": fee_router or "0x0000000000000000000000000000000000000000", + "dinEmission": "0x0000000000000000000000000000000000000000", + "owner": owner, + "deployedAtCommit": "abc1234", + } + + +def _wired_deps() -> dict: + """Deps with a real fee-router address so the treasury wiring check fires.""" + d = _one_proxy_deps(treasury=_TREASURY, fee_router=_FEE_ROUTER) + return d + + +# ── Dummy web3 ─────────────────────────────────────────────────────────────── + +_TREASURY = "0xDe2222222222222222222222222222222222222D" +_FEE_ROUTER = "0xDe3333333333333333333333333333333333333D" +_STAKE = "0xDe4444444444444444444444444444444444444D" +_REGISTRY = "0xDe5555555555555555555555555555555555555D" + + +class DummyEth: + def __init__(self, impl=_IMPL, admin=_ADMIN, code=_CODE, owner_ret=_OWNER, + wiring=None, slasher_authorized=True, treasury_ret=None, + registry_stake_ret=None): + self._impl = impl + self._admin = admin + self._code = code + self._owner = owner_ret + self._wiring = wiring or { + "dinToken": "0x0000000000000000000000000000000000000000", + "dinValidatorStakeContract": "0x0000000000000000000000000000000000000000", + "feeRouter": "0x0000000000000000000000000000000000000000", + } + self._slasher_authorized = slasher_authorized + self._treasury = treasury_ret or _TREASURY + self._registry_stake = registry_stake_ret or _STAKE + + def get_storage_at(self, addr, slot): + if slot == _IMPL_SLOT: + return bytes.fromhex(self._impl[2:].zfill(64)) + if slot == _ADMIN_SLOT: + return bytes.fromhex(self._admin[2:].zfill(64)) + return b"\x00" * 32 + + def get_code(self, addr): + return self._code + + def contract(self, address, abi): + outer = self + class _C: + class functions: + @staticmethod + def owner(): + class _R: + @staticmethod + def call(): + return outer._owner + return _R + @staticmethod + def dinToken(): + class _R: + @staticmethod + def call(): + return outer._wiring["dinToken"] + return _R + @staticmethod + def dinValidatorStakeContract(): + class _R: + @staticmethod + def call(): + return outer._wiring["dinValidatorStakeContract"] + return _R + @staticmethod + def feeRouter(): + class _R: + @staticmethod + def call(): + return outer._wiring["feeRouter"] + return _R + @staticmethod + def treasury(): + class _R: + @staticmethod + def call(): + return outer._treasury + return _R + @staticmethod + def dinValidatorStake(): + class _R: + @staticmethod + def call(): + return outer._registry_stake + return _R + @staticmethod + def slasherContracts(addr): + class _R: + @staticmethod + def call(): + return outer._slasher_authorized + return _R + return _C + + +class DummyWeb3: + def __init__(self, **kwargs): + self.eth = DummyEth(**kwargs) + + @staticmethod + def to_checksum_address(addr): + from eth_utils import to_checksum_address + return to_checksum_address(addr) + + +class DummyConsole: + def __init__(self): + self.lines = [] + + def print(self, *args, **kwargs): + self.lines.append(" ".join(str(a) for a in args)) + + +class DummyCtxObj: + def __init__(self, w3=None, network="local"): + self.console = DummyConsole() + self.w3 = w3 or DummyWeb3() + self.network = network + + +def make_ctx(**kwargs) -> SimpleNamespace: + return SimpleNamespace(obj=DummyCtxObj(**kwargs)) + + +# ── Helpers ────────────────────────────────────────────────────────────────── + +def run_cmd(tmp_path, deps: dict, w3=None, no_color=True, slashers=None): + """Write deps to a temp file and call verify_deployment directly. + Returns (exit_code, output_lines). exit_code is 0 on clean completion.""" + dep_file = tmp_path / "deployments.json" + dep_file.write_text(json.dumps(deps)) + ctx = make_ctx(w3=w3 or DummyWeb3()) + exit_code = 0 + try: + verify_deployment(ctx, file=dep_file, no_color=no_color, slashers=slashers) + except (SystemExit,) as exc: + exit_code = exc.code or 0 + except (click.exceptions.Exit, typer.Exit) as exc: + exit_code = exc.exit_code + return exit_code, ctx.obj.console.lines + + +def assert_all_ok(lines): + drifts = [l for l in lines if "[DRIFT]" in l or "[ERROR]" in l] + assert not drifts, f"Unexpected DRIFT/ERROR lines: {drifts}" + + +# ── Tests ───────────────────────────────────────────────────────────────────── + +class TestAllOK: + def test_exits_zero(self, tmp_path): + code, _lines = run_cmd(tmp_path, _one_proxy_deps()) + assert code == 0 + + def test_prints_only_ok_and_info(self, tmp_path): + _code, lines = run_cmd(tmp_path, _one_proxy_deps()) + assert_all_ok(lines) + + def test_reports_deploy_commit(self, tmp_path): + _code, lines = run_cmd(tmp_path, _one_proxy_deps()) + assert any("abc1234" in l for l in lines) + + +class TestDriftOnImplMismatch: + def test_exits_nonzero(self, tmp_path): + wrong_impl = "0xDddddddddddddddddddddddddddddddddddddddd" + w3 = DummyWeb3(impl=wrong_impl) + code, _lines = run_cmd(tmp_path, _one_proxy_deps(), w3=w3) + assert code != 0 + + def test_prints_drift_line(self, tmp_path): + wrong_impl = "0xDddddddddddddddddddddddddddddddddddddddd" + w3 = DummyWeb3(impl=wrong_impl) + _code, lines = run_cmd(tmp_path, _one_proxy_deps(), w3=w3) + assert any("[DRIFT]" in l and "impl" in l for l in lines) + + +class TestDriftOnCodehashMismatch: + def test_exits_nonzero(self, tmp_path): + w3 = DummyWeb3(code=b"\xfe") # different bytecode β†’ different hash + code, _lines = run_cmd(tmp_path, _one_proxy_deps(), w3=w3) + assert code != 0 + + def test_prints_drift_line(self, tmp_path): + w3 = DummyWeb3(code=b"\xfe") + _code, lines = run_cmd(tmp_path, _one_proxy_deps(), w3=w3) + assert any("[DRIFT]" in l and "codehash" in l for l in lines) + + +class TestDriftOnAdminMismatch: + def test_exits_nonzero(self, tmp_path): + wrong_admin = "0xEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE" + w3 = DummyWeb3(admin=wrong_admin) + code, _lines = run_cmd(tmp_path, _one_proxy_deps(), w3=w3) + assert code != 0 + + def test_prints_drift_line(self, tmp_path): + wrong_admin = "0xEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE" + w3 = DummyWeb3(admin=wrong_admin) + _code, lines = run_cmd(tmp_path, _one_proxy_deps(), w3=w3) + assert any("[DRIFT]" in l and "proxyAdmin" in l for l in lines) + + +class TestDriftOnOwnerMismatch: + def test_exits_nonzero(self, tmp_path): + wrong_owner = "0xFfffffffffffffffffffffffffffffffFfffffff" + w3 = DummyWeb3(owner_ret=wrong_owner) + code, _lines = run_cmd(tmp_path, _one_proxy_deps(), w3=w3) + assert code != 0 + + def test_prints_drift_line(self, tmp_path): + wrong_owner = "0xFfffffffffffffffffffffffffffffffFfffffff" + w3 = DummyWeb3(owner_ret=wrong_owner) + _code, lines = run_cmd(tmp_path, _one_proxy_deps(), w3=w3) + assert any("[DRIFT]" in l and "owner" in l for l in lines) + + +class TestSlasherCheck: + def test_ok_when_slasher_authorized(self, tmp_path): + slasher = "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + w3 = DummyWeb3(slasher_authorized=True) + code, lines = run_cmd(tmp_path, _one_proxy_deps(), w3=w3, slashers=slasher) + assert code == 0 + assert any("[OK]" in l and "slasher" in l for l in lines) + + def test_drift_when_slasher_not_authorized(self, tmp_path): + slasher = "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + w3 = DummyWeb3(slasher_authorized=False) + code, lines = run_cmd(tmp_path, _one_proxy_deps(), w3=w3, slashers=slasher) + assert code != 0 + assert any("[DRIFT]" in l and "slasher" in l for l in lines) + + def test_info_when_no_slashers_given(self, tmp_path): + _code, lines = run_cmd(tmp_path, _one_proxy_deps()) + assert any("[INFO]" in l and "slasher" in l for l in lines) + + +class TestFileHandling: + def test_missing_file_exits_nonzero(self, tmp_path): + ctx = make_ctx() + exit_code = 0 + try: + verify_deployment(ctx, file=tmp_path / "missing.json", no_color=True, slashers=None) + except (SystemExit,) as exc: + exit_code = exc.code or 0 + except (click.exceptions.Exit, typer.Exit) as exc: + exit_code = exc.exit_code + assert exit_code != 0 + + def test_malformed_json_exits_nonzero(self, tmp_path): + bad = tmp_path / "bad.json" + bad.write_text("not json") + ctx = make_ctx() + exit_code = 0 + try: + verify_deployment(ctx, file=bad, no_color=True, slashers=None) + except (SystemExit,) as exc: + exit_code = exc.code or 0 + except (click.exceptions.Exit, typer.Exit) as exc: + exit_code = exc.exit_code + assert exit_code != 0 + + +class TestApplicationOwner: + """Application proxy owner() checks (Check 4b).""" + + def test_ok_when_proxy_owner_matches(self, tmp_path): + code, lines = run_cmd(tmp_path, _one_proxy_deps()) + assert code == 0 + assert any("[OK]" in l and "owner" in l for l in lines) + + def test_drift_when_proxy_owner_wrong(self, tmp_path): + wrong_owner = "0xFfffffffffffffffffffffffffffffffFfffffff" + w3 = DummyWeb3(owner_ret=wrong_owner) + code, lines = run_cmd(tmp_path, _one_proxy_deps(), w3=w3) + assert code != 0 + owner_drifts = [l for l in lines if "[DRIFT]" in l and "owner" in l] + assert owner_drifts, f"Expected DRIFT owner line, got: {lines}" + + +_WIRED_WIRING = { + "dinToken": "0x0000000000000000000000000000000000000000", + "dinValidatorStakeContract": "0x0000000000000000000000000000000000000000", + "feeRouter": _FEE_ROUTER, +} + + +class TestFeeRouterTreasury: + """FeeRouter.treasury() wiring check.""" + + def test_ok_when_treasury_matches(self, tmp_path): + w3 = DummyWeb3(treasury_ret=_TREASURY, wiring=_WIRED_WIRING) + code, lines = run_cmd(tmp_path, _wired_deps(), w3=w3) + assert code == 0 + assert any("[OK]" in l and "treasury" in l for l in lines) + + def test_drift_when_treasury_wrong(self, tmp_path): + wrong_treasury = "0x0000000000000000000000000000000000000099" + w3 = DummyWeb3(treasury_ret=wrong_treasury, wiring=_WIRED_WIRING) + code, lines = run_cmd(tmp_path, _wired_deps(), w3=w3) + assert code != 0 + assert any("[DRIFT]" in l and "treasury" in l for l in lines) + + def test_skipped_when_fee_router_is_zero(self, tmp_path): + # zero dinFeeRouter address: treasury check is skipped (WARN), not DRIFT + deps = _one_proxy_deps() # dinFeeRouter = zero addr, dinTreasury = _PROXY + code, lines = run_cmd(tmp_path, deps) + assert code == 0 + assert not any("[DRIFT]" in l and "treasury" in l for l in lines) + + def test_fires_without_coordinator(self, tmp_path): + """Treasury check must run even when dinCoordinator is absent from deps.""" + deps = _wired_deps() + deps.pop("dinCoordinator", None) + w3 = DummyWeb3(treasury_ret=_TREASURY, wiring=_WIRED_WIRING) + code, lines = run_cmd(tmp_path, deps, w3=w3) + assert code == 0 + assert any("[OK]" in l and "treasury" in l for l in lines), ( + "treasury wiring check must fire even when dinCoordinator is absent" + ) + + +def _registry_deps() -> dict: + """Deps with real registry and stake addresses so the registry wiring check fires. + dinCoordinator is absent so coordinator wiring does not interfere.""" + d = _one_proxy_deps() + d.pop("dinCoordinator", None) + d["dinModelRegistry"] = _REGISTRY + d["dinValidatorStake"] = _STAKE + return d + + +class TestRegistryWiring: + """DINModelRegistry.dinValidatorStake() wiring check.""" + + def test_ok_when_stake_matches(self, tmp_path): + w3 = DummyWeb3(registry_stake_ret=_STAKE) + code, lines = run_cmd(tmp_path, _registry_deps(), w3=w3) + assert code == 0 + assert any("[OK]" in l and "DINModelRegistry" in l for l in lines) + + def test_drift_when_stake_wrong(self, tmp_path): + wrong_stake = "0x0000000000000000000000000000000000000099" + w3 = DummyWeb3(registry_stake_ret=wrong_stake) + code, lines = run_cmd(tmp_path, _registry_deps(), w3=w3) + assert code != 0 + assert any("[DRIFT]" in l and "DINModelRegistry" in l for l in lines) + + +class TestAccountFreeDispatch: + """verify-deployment must not trigger account loading via the system callback.""" + + def test_no_wallet_loaded(self, tmp_path): + dep_file = tmp_path / "deps.json" + dep_file.write_text(json.dumps(_one_proxy_deps())) + + class StrictDummy(DummyCtxObj): + def get_en_w3_account_console(self, *args, **kwargs): + raise AssertionError("account loading must not occur for verify-deployment") + + from typer.testing import CliRunner as TCliRunner + runner = TCliRunner() + result = runner.invoke( + system_mod.app, + ["verify-deployment", "--file", str(dep_file), "--no-color"], + obj=StrictDummy(), + ) + assert "AssertionError" not in (result.output or "") + assert result.exit_code == 0