second-prover: the ProofSystem trait in igneum-prove-core (one trait, no SDK; versions 1 sp1 and 2 risc0), Risc0ProofSystem behind it, the shadow runner and its tunnel, the pinned-guests check over both manifests, the plan, the ledger rows P7 and D6, the design note
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
082fab7a4d
commit
98742d6cd6
14 changed files with 634 additions and 66 deletions
|
|
@ -29,7 +29,7 @@ row() { # candidate backend host mode-env verify-mode
|
|||
local extra=""; if [ "$cand" = r0 ] && [ -n "$R0_PO2" ]; then extra="--po2 $R0_PO2"; fi
|
||||
env $env $CAP $TIME "$host" "$FIXTURE" --mode compressed --shard "$SHARD" --prover 0xCAfc6e74000000000000000000000000000000c2 $extra --out "$res" > "$plog" 2> "$tlog"
|
||||
local rc=$?; [ $rc -eq 124 ] || [ $rc -eq 137 ] && echo "RESULT timed out after $CAP_S s" >> "$plog"
|
||||
pkill -f sp1-gpu-server 2>/dev/null; rm -f /tmp/sp1-cuda-*.sock local wall; wall=$(python3 -c "import time; print(round(time.time() - $t0, 2))")
|
||||
pkill -x sp1-gpu-server 2>/dev/null; rm -f /tmp/sp1-cuda-*.sock; local wall; wall=$(python3 -c "import time; print(round(time.time() - $t0, 2))")
|
||||
[ -n "$smi" ] && { kill $smi 2>/dev/null; sleep 1; }
|
||||
local peak; if [ "$backend" = cuda ] && [ -s "$dir/smi.csv" ]; then peak=$(sort -n "$dir/smi.csv" | tail -1); else peak=$(peak_mib "$tlog"); fi
|
||||
# a failed run keeps its logs beside the row (6 October 2026: the 4060's three failures kept one line each)
|
||||
|
|
|
|||
|
|
@ -312,6 +312,8 @@ Swap procedure, fixed by the design document and spelled out here:
|
|||
| 4 | At the end of the overlap the latest segment proof under N is wrapped once under N+1 (a proof that verifies the N proof) so the chain of proofs continues and light clients keep only the N+1 verifier |
|
||||
| 5 | Version N verification code is removed in the following release |
|
||||
|
||||
Mission item 9 (7 October 2026, `docs/plans/second-prover.md`): the trait lives in `igneum-prove-core` and version 2 is RISC Zero 3.0.6 (`proving/igneum-prove-r0`), proving the same shard statement. The node keeps an ACTIVE LIST of proof systems with their pinned program ids (`igneum_exec::proving::ProvingConfig::active_systems`, version 1 alone by default); a record names its system in `version` and is checked, verified and paid only when its system is on the list. A shadow runner proves every segment with every active system; a disagreement is a public alert and the pool's STOP SWITCH: no proof record is carried by a stopped producer until one third of the weight window's blue blocks signal which system to trust (the `IGNT` coinbase section), after which only that system's records are carried. The switch is a producer's rule, not a validity rule: a block carrying records stays valid, and the native-execution veto of 5.5 is untouched. Widening the list on a live network is a consensus-shaped change (a version-2 proof has no verifier on a version-1 node under the 0.3.16 body rule) and follows the swap procedure above.
|
||||
|
||||
### 5.7 Devnet v1 scope
|
||||
|
||||
Devnet v1 runs with a stub `ProofSystem` whose `prove_shard` returns a signed claim and whose `verify_segment` checks the signature, so the whole pipeline (trace, shard plan, sortition, records, veto, pool credit) runs on the devnet before SP1 proving is fast enough. The stub is never a mainnet version. Phase 2's SP1 implementation replaces it behind the same trait.
|
||||
|
|
|
|||
|
|
@ -413,6 +413,8 @@ Evidence: design doc "Proof system churn" risk item. Rule: not yet. Fix: overcla
|
|||
|
||||
Sweep (5 October 2026, evening): stated. `site/litepaper.html`, Abstract (overclaim 3): no scheduled human release, an emergency fix to the proof system takes a person and activates on signalling. Proving, "How a block gets proven" (overclaim 26): no node accepts a wrong state root, full nodes reject a proof record that disagrees with native execution, label Implemented with spec section 7 named, and the human emergency path stated. The rule this entry asked for is spec 7.2 item 5 and 7.4 item 3 (the native-execution veto), already in the spec.
|
||||
|
||||
Round 5, mission item 9 (7 October 2026): the second proof system is on the active list and the stop switch exists (`docs/plans/second-prover.md`). RISC Zero 3.0.6 proves the same 328-byte shard statement behind the one `ProofSystem` trait (`igneum-prove-core`), a shadow runner proves every segment with both systems on one fleet box and records agreement, a disagreement raises a public alert (the node's `PROOF DISAGREEMENT` line, `igneum_getProofTrust`, the Discord incident hook) and flips the pool's stop switch: no proof record is carried until one third of the weight window's blue blocks signal which system to trust (`IGNT`). The veto is untouched. What this changes for P7: a soundness bug in one system is now DETECTED by the other on the shadow box, not only vetoed by full nodes, so a light client's exposure runs from the bad proof to the next shadow row instead of to the next human. Still true: carrying version 2 records on a live network is a change every node makes together (section 2.2 of the plan), held for the project lead's word. Gate result: see the plan's section 4.
|
||||
|
||||
### P8. Fastest prover wins all the shards
|
||||
"Aleo's lesson was that proving as a race centralises to the fastest. Your shards are claimed first-come with a bond. The lowest-latency datacentre claims every shard before a home card sees it."
|
||||
|
||||
|
|
@ -1895,6 +1897,8 @@ Round 2 (5 October 2026, night): reviewed by the cryptographer agent, `docs/revi
|
|||
|
||||
Review: `docs/review/round-4-2026-10-04.md`. Scope: devnet v4 through `a21ff239` (HEAD `3bfe346f`), the prebuilt workers, the Igneum Miner app 0.3.1 to 0.3.3, the relay, the Windows CI, the downloads host, the live site and the economics after the day's measurements. No secret value appears in any entry; comparisons were count-only.
|
||||
|
||||
Round 5, mission item 9 (7 October 2026): the containment this entry asked for in section 6 ("under a deliberately broken verifier") has its first mechanism: two proof systems on the active list and the one-third stop switch (`docs/plans/second-prover.md`). For a job (where no native veto exists) the shadow row is the only check that can catch a forged output before a callback consumes it, so the second system matters more here than for segments: a job proven by one system and refuted by the other is a disagreement, the alert fires and the pool stops. Not yet: the precompile of design 6 does not exist, so no job has been shadowed; the three devnet checks of the review stay owed.
|
||||
|
||||
### X23. One shipped key is an administrator channel to the founder's PCs
|
||||
"Your relay accepts either the URL token or the `x-igneum-key` header for everything, including queuing PowerShell that the PC agent runs as administrator. The key is the log intake key, a literal in six tracked files and inside every Windows and prover package you have handed out. And the zip with both relay secrets sits on the downloads host behind the dl token, which is in your git history and in every installed app. One token, four hops, no privilege boundary."
|
||||
|
||||
|
|
|
|||
101
docs/plans/second-prover.md
Normal file
101
docs/plans/second-prover.md
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
# Mission item 9: the second proof system on the active list and the one-third stop switch
|
||||
|
||||
7 October 2026, from 10:1x UK (the project lead: build it now). The proving lane, branch `second-prover` (worktree
|
||||
`igneum-wt-second-prover`), node side on the fork branch `second-prover-node` (from `release-0.3.19-node` e69e8a39).
|
||||
What the mission asks (`docs/analysis/mission/mission.md` 2.9; `future.md` sections 5 and 9; frontier I4; ledger
|
||||
P7 and D6): a second zkVM behind the `ProofSystem` trait proving the same segments on one fleet box as a shadow; a
|
||||
disagreement is a public alert and the pool stops paying on proofs until one third of weight signals which system to
|
||||
trust; full nodes keep the native-execution veto whatever happens. Gate: 1,000 segments agree across both systems;
|
||||
one injected bad proof disagrees, pays nothing and raises the alert.
|
||||
|
||||
## 1. The system chosen: RISC Zero 3.0.6, and why
|
||||
|
||||
The choice was made on evidence that already existed in the tree, not from memory.
|
||||
|
||||
| Criterion | RISC Zero 3.0.6 | OpenVM 2.0.2 | Source |
|
||||
|---|---|---|---|
|
||||
| Independence from SP1's code | its own circuit stack (rv32im circuit, BabyBear, Poseidon2, its own FRI and recursion); shares no prover code with SP1 | built on Plonky3, the stack SP1 came from before Hypercube; a bug in a shared layer hits both | `docs/analysis/proving-methods.md` 1.5 and 2.1; the SP1 v3.4.0 disclosure was partly a Plonky3 evaluation gap (`future.md` 5.3) |
|
||||
| Toolchain maturity | rzup, cargo-risczero, r0vm, a release line since 2022; the May 2025 soundness fix shipped with an estop | 2.0.2 of 14 Aug 2026; no shipped recursion-to-constant-size path measured here | `docs/analysis/amd-proving.md` table; `proving-methods.md` 2.1 |
|
||||
| Guest portability of the shard program | `igneum-prove-core` ports as is: `methods/guest/src/main.rs` is 14 lines (read the frame, run `shard_statement`, commit the 328 bytes); RISC Zero's own patches for keccak (tiny-keccak), sha2, k256 | the same port would work; no measurement exists here | `proving/igneum-prove-r0/methods/guest` |
|
||||
| Proof size | succinct receipt 223,882 bytes, constant | not measured here | bench rows on the 4090, 6 October 2026 |
|
||||
| Prover time on a 4090 | v1 shard (10.0 M cycles): 23.1 s at po2 20, 32.7 s at po2 19; the 89.5 M-cycle shard 178 s; the 134.7 M-cycle shard 355 s | not measured here | `/root/rows` on rz-4090 and `bench/proof-systems` rows, 6 October 2026 13:35Z to 14:01Z |
|
||||
| What already existed | a pinned guest (image id `0x9ae0f416...`), a host with the SP1 host's CLI, a CUDA build on the fleet box | nothing | branch `proving-v2` (the proving-methods route D measurement) |
|
||||
|
||||
RISC Zero. The independence row decides it: the second system exists to disagree with the first when the first is
|
||||
wrong, and a shared layer is a shared bug. The rest is cost: the guest, the pin and the measurements were in the tree.
|
||||
|
||||
## 2. What was built
|
||||
|
||||
| Part | Where | State |
|
||||
|---|---|---|
|
||||
| The `ProofSystem` trait in one SDK-free crate | `proving/igneum-prove/core/src/proof_system.rs` (trait, `SegmentClaim`, `ShardWitness`, `PgasTable`, `VERSION_SP1` = 1, `VERSION_RISC0` = 2, `system_name`); the SP1 host re-exports it; the RISC Zero host implements it (`proving/igneum-prove-r0/host/src/proof_system.rs`, `Risc0ProofSystem`, and `--mode compressed` runs through `prove_shard`) | built on the box |
|
||||
| The second system's host, guest and pin | `proving/igneum-prove-r0/` from `proving-v2` (RISC Zero 3.0.6, proof system 2, image id `0x9ae0f416ee43e9ea8908c555d424fe23e3592677ffc42a763476623d0eb5cf72`); `tools/ci/pinned-guests-check.sh` now checks its manifest and allows only its pin script to build it | as measured on 6 October |
|
||||
| The shadow runner | `tools/shadow/shadow.py` (both hosts on every shard of every exported segment, each proof re-verified by its own host, the row, the summary, the alert; `--inject soundness|bytes`), `tools/shadow/tunnel.sh` (the read-only line to a node) | ran on rz-4090 against Devnet 2's dn2-1, see section 4 |
|
||||
| The active list | `igneum_exec::proving::ProvingConfig::active_systems` (version 1 alone by default; `IGNEUM_PROOF_ACTIVE_SYSTEMS=1,2` with `IGNEUM_PROOF_VERIFIER_R0=<igneum-prove-r0-host>` adds version 2 with its image id from the host's `--mode id`); `check_record` refuses a version off the list; the verifier thread and the consensus verdict dispatch by version (version 2 through its host); `ProofRecord::version` 2 = RISC Zero (`kaspa_consensus_core::proving::VERSION_RISC0`); the status carries `activeSystems` | fork branch `second-prover-node` |
|
||||
| The stop switch | `ProofPool::stop_on_disagreement` (RPC `igneum_proofDisagreement`): the node's templates carry NO proof record until one third of the weight window's blue blocks signal a system (`allowed_systems`, `trust_tally`), then only that system's; `igneum_proofTrustClear` resets; `igneum_getProofTrust` and `igneum_getProvingStatus.trust` show it | fork branch `second-prover-node`, unit-tested |
|
||||
| The trust signal | a coinbase section `IGNT` (`version_le16 || 2_le32 || "IGNT"`) before `IGNS`; the node's templates carry it when `IGNEUM_PROOF_TRUST=sp1|risc0`; every mergeset entry records its block's signal; counted by blue blocks over `(tip_daa - W, tip_daa]`, the finality weight's convention | fork branch `second-prover-node`, unit-tested |
|
||||
| The alert | the node's `PROOF DISAGREEMENT` warn line and status; the runner's `ALERT` line, `out/alert.json`, `out/alerts.jsonl`, the RPC post, and the Discord incident hook when `--discord` names `tools/community/discord-hooks.mjs` (dry run unless `--discord-live`) | runner and node |
|
||||
|
||||
### 2.1 Why the stop switch is a pool rule and not a consensus rule
|
||||
|
||||
A record pays at the chain block that carries it (`carried_payouts`, spec 7.7). Whether a block carries a record is
|
||||
the producer's choice: `template_sections` builds the coinbase sections from the pool, and a block with no record
|
||||
section is as valid as one with eight. So "the pool stops paying" is implemented as "no stopped node carries a
|
||||
record", which every node can do without a rule change and which a block carrying a record never violates. The limit,
|
||||
stated: a producer that ignores the switch can still carry records and the chain still pays them, exactly as the pool
|
||||
pays today; the switch is the honest producers' rule, the alert is public, and the veto stays. Nothing in consensus
|
||||
moved. Checked against the reading of this task: the switch is a pool rule and an alert.
|
||||
|
||||
### 2.2 What IS a consensus-shaped change, held for the project lead's word
|
||||
|
||||
Since 0.3.16 the body rule asks the proof oracle about every carried proof and a proof that does not verify makes
|
||||
the carrying block invalid. A version 2 record carried on a network of version-1-only nodes is such a proof. So
|
||||
widening the active list on a live network (carrying RISC Zero records) is a change every node makes together, and
|
||||
the node refuses to start with version 2 on its list unless its operator names the RISC Zero host. The code ships
|
||||
with version 1 alone as the default and nothing on any live network changes. When the project lead says so, the activation goes
|
||||
into the override object as `proving_second_system_activation_daa` with a floor height, under the 95 percent signal
|
||||
rule, and crosses Devnet 2 first. Not done here, on purpose.
|
||||
|
||||
## 3. The guest statement, with the fin-proof lane
|
||||
|
||||
Agreed with the fin-proof lane on 7 October (09:3x UK): the shard layout is untouched (`ShardOutput`, 328 bytes),
|
||||
the aggregator's 340-byte `BlockOutput` prefix stays byte for byte, with an optional 164-byte finality extension when
|
||||
the aggregation takes a finality input (never until `finality_in_proof_activation_daa` is set). The second system
|
||||
commits the same 328 bytes today; it has no aggregator (a block's shards are one family, `proving-methods.md` route
|
||||
D), so the aggregated statement stays SP1's. The trait's `aggregate(prev, shards)` signature is unchanged; the
|
||||
fin-proof lane's `aggregate_with(prev, shards, fin)` stays an inherent method.
|
||||
|
||||
## 4. The gate
|
||||
|
||||
Filled in from `rows.jsonl` and `summary.json` on rz-4090 (RunPod RTX 4090 24 GB, the fleet's Ubuntu 24.04 CUDA
|
||||
12.8 image, the one-shot rule, USD 0.74 an hour), segments from Devnet 2's dn2-1 (read-only, tunnelled).
|
||||
|
||||
| Line | Result |
|
||||
|---|---|
|
||||
| 1,000 segments agree across both systems | PENDING |
|
||||
| One injected bad proof disagrees, pays nothing, raises the alert | PENDING |
|
||||
|
||||
Prover times and sizes: PENDING (section 5).
|
||||
|
||||
## 5. Numbers and their consequences
|
||||
|
||||
PENDING.
|
||||
|
||||
## 6. Operations
|
||||
|
||||
| Step | Command |
|
||||
|---|---|
|
||||
| The tunnel on the shadow box | `tools/shadow/tunnel.sh start /root/.ssh/fleet-internal 11340 root@<dn2-1> /root/sp-shadow/tunnel.pid` |
|
||||
| The run | `python3 tools/shadow/shadow.py --sp1-host <igneum-prove-host> --r0-host <igneum-prove-r0-host> --export <igneum-prove-export> --rpc http://127.0.0.1:26790 --out /root/sp-shadow/out --segments 1000 --sp1-env "HOME=/opt/igneum-floor/home SP1_PROVER=cuda SP1_GPU_ELEMENT_THRESHOLD=67108864 RUST_LOG=off"` (nohup; stop with `touch out/STOP` or `kill $(cat out/shadow.pid)`) |
|
||||
| The injected case | the same with `--inject soundness --inject-at 1 --segments 1 --alert-rpc http://127.0.0.1:<node evm rpc>` against a node of the fork build |
|
||||
| A node that verifies both systems | `IGNEUM_PROOF_ACTIVE_SYSTEMS=1,2 IGNEUM_PROOF_VERIFIER=<igneum-prove-host> IGNEUM_PROOF_VERIFIER_R0=<igneum-prove-r0-host> IGNEUM_PROOF_TRUST=sp1 igneumd ...` |
|
||||
| The switch by hand | `igneum_proofDisagreement [{number, shard, reason, systems, source}]`, `igneum_getProofTrust`, `igneum_proofTrustClear` on the EVM RPC |
|
||||
|
||||
## 7. What is still open
|
||||
|
||||
| Item | Why |
|
||||
|---|---|
|
||||
| In-process RISC Zero verification in the node | version 2 verifies through the host subprocess (the Windows path SP1 already uses); linking `risc0-zkvm` verify-only into the node is a build-weight decision |
|
||||
| A version 2 aggregator | composition over the shard receipts; a block's shards stay one family until then |
|
||||
| The activation on a live network | section 2.2, the project lead's word |
|
||||
| The shadow runner as a standing fleet role | today a one-shot box; `tools/fleet/box-prover.py` is where a standing shadow would live |
|
||||
|
|
@ -5,8 +5,15 @@ Igneum proving: v0 (3 October 2026, one SP1 proof per block) and the devnet v4 s
|
|||
- `igneum-prove/`: Cargo workspace. `core` (the port of the execution layer's executor at b7fca5a0; the cutter `plan.rs`, the partial-trie witnesses `trie.rs` and `witness.rs`, the shard statement `shard.rs`, the block statement `agg.rs`), `program` (the shard guest), `aggregator` (the aggregator guest, SP1 deferred proofs of the shard program), `host` (modes native, execute, shard, block, all; the versioned `ProofSystem` trait with the stub and the SP1 implementation in `host/src/proof_system.rs`), `export` (cuts a real block out of an `igneum_exportSegments` dump, checks the port against the node's state roots, plans the shards and checks every witness).
|
||||
- `fixtures/`: `block-338-shard1`, `block-341-shards2`, `block-344-shards4` (one, two and four shards at `S_p` = 7.5 M pgas, from the private simnet of `tools/prove-fixtures`), `block-78-increment` and `block-56-transfers` (the v0 blocks, one shard each), `block-56-transfers-3shards` (a test cut at 200 pgas for the CPU multi-shard check), `fees-switch-prototype`, `fees-v1-shards2` and `fees-v1-shards3` (5 October 2026: one simnet chain across the `fees_v1_activation_daa` switch at DAA 800, a prototype block below it and v1 blocks at `S_p` = 30,000 pgas above it; `docs/plans/fee-switch-devnet.md`).
|
||||
- The fee schedule (5 October 2026): every fixture and shard input carries the node's schedule (`fees`: both tables and the switch, `core/src/config.rs`) and the block's DAA score; the executor reads the set at that score and raises the base fees to its floors as the node does. A fixture without `fees` is the devnet as compiled (prototype, never). The exporter takes the schedule from the dump (`feesV1ActivationDaa`, `fees`, per-segment `daaScore`, written by `tools/prove-fixtures/gen.mjs`) or from `--fees-v1-activation-daa` and `--fees-base`.
|
||||
- `igneum-prove-r0/`: proof system version 2, RISC Zero 3.0.6 (mission item 9, 7 October 2026; `docs/plans/second-prover.md`). Its own Cargo workspace (RISC Zero's crate patches cannot share SP1's): `methods/guest` (the shard guest, the same `shard_statement` and the same 328 committed bytes), `host` (`igneum-prove-r0-host`, the SP1 host's CLI shape: `--mode native|execute|compressed`, `--mode verify --proof <file> --statement 0x..`, `--mode id`; `Risc0ProofSystem` in `host/src/proof_system.rs` implements the trait), `elf/` (the pinned guest `igneum-prove-r0-guest.bin` and `manifest-r0.json`, image id `0x9ae0f416...`; `pin-guest.sh` is the only script that builds it, `tools/ci/pinned-guests-check.sh` checks both manifests). CUDA: `--features cuda` on a machine with nvcc; a plain build proves on the CPU.
|
||||
- `windows-wsl2/`: SETUP-PROVER.bat, PROVE-SHARD.bat (the shard at `S_p` and the two- and four-shard blocks on the GPU), PROVE-BLOCK.bat (the small block) and the Linux scripts for the project lead's PC; `make-package.sh` builds the zip.
|
||||
|
||||
## The `ProofSystem` trait and the second system (7 October 2026)
|
||||
|
||||
The trait of design 5.6 lives in `igneum-prove-core` (`core/src/proof_system.rs`: `ProofSystem`, `SegmentClaim`, `ShardWitness`, `PgasTable`, `VERSION_SP1` = 1, `VERSION_RISC0` = 2), a crate with no zkVM SDK, so every proof system implements one trait: version 0 the stub and version 1 SP1 in `host/src/proof_system.rs`, version 2 RISC Zero in `../igneum-prove-r0/host/src/proof_system.rs`. Every version commits the same statement bytes; a record's `statement` is keccak256 of them, so two systems either agree on 32 bytes or they do not.
|
||||
|
||||
The shadow runner, `tools/shadow/shadow.py`, proves every shard of every exported segment with both hosts, re-verifies each proof with its own host, records agreement (`out/rows.jsonl`, `out/summary.json`) and raises the alert on a disagreement (`out/alert.json`, the node's `igneum_proofDisagreement`, the Discord incident hook). On the node (fork branch `second-prover-node`): the active list (`IGNEUM_PROOF_ACTIVE_SYSTEMS`, version 1 alone by default), the stop switch (no proof record carried until one third of the weight window's blue blocks signal a system through the `IGNT` coinbase section) and the status (`igneum_getProofTrust`). Plan, gate and numbers: `docs/plans/second-prover.md`.
|
||||
|
||||
## Pinned guest programs (5 October 2026)
|
||||
|
||||
The two SP1 guests are build artefacts committed under `igneum-prove/elf/`: `igneum-prove-program.elf` and `igneum-prove-aggregator.elf`, their verifying keys (`.vk`, bincode) and `manifest.json` (SHA-256 of every file, the program ids, the SP1 crate and circuit versions, when and where they were pinned). The host embeds these files (`host/src/pinned.rs`), never a guest it compiled itself, checks every hash against the manifest at each start, refuses in the prove modes when SP1's key setup does not derive the manifest's program id, and in `--mode verify` uses the pinned key with SP1's light verifier (no prover client, no key generation). `igneum-prove-host --mode id` prints the pinned ids with no setup.
|
||||
|
|
|
|||
|
|
@ -23,13 +23,16 @@
|
|||
//! pinned image id and keccak256(journal) is compared with the statement; exit 0 = verified, 3 = not.
|
||||
|
||||
mod pinned;
|
||||
mod proof_system;
|
||||
|
||||
use alloy_primitives::{Address, B256};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use igneum_prove_core::agg::{self, AggInput};
|
||||
use igneum_prove_core::proof_system::{ProofSystem, ShardWitness};
|
||||
use igneum_prove_core::shard::{build_shards, shard_statement, BuiltShard, ShardInput, ShardOutput};
|
||||
use proof_system::Risc0ProofSystem;
|
||||
use igneum_prove_core::Fixture;
|
||||
use risc0_zkvm::{default_executor, default_prover, ExecutorEnv, ProverOpts, Receipt};
|
||||
use risc0_zkvm::{default_executor, ExecutorEnv, Receipt};
|
||||
use sha2::Digest as _;
|
||||
use std::time::Instant;
|
||||
|
||||
|
|
@ -136,6 +139,7 @@ fn main() -> Result<()> {
|
|||
results.insert("proof_system".into(), PROOF_SYSTEM.into());
|
||||
results.insert("risc0_crate_version".into(), RISC0_CRATE_VERSION.into());
|
||||
results.insert("image_id".into(), pinned.image_id.to_string().into());
|
||||
results.insert("proof_system_version".into(), (Risc0ProofSystem::VERSION as u64).into());
|
||||
results.insert("po2".into(), po2.into());
|
||||
results.insert("shard_budget".into(), fixture.plan.shard_budget.into());
|
||||
results.insert("consensus_budget".into(), fixture.plan.consensus.into());
|
||||
|
|
@ -308,8 +312,7 @@ fn tamper_checks(shard: &BuiltShard) -> Result<()> {
|
|||
}
|
||||
|
||||
fn env_for(input: &ShardInput, po2: u32) -> Result<ExecutorEnv<'static>> {
|
||||
let bytes = bincode::serialize(input)?;
|
||||
ExecutorEnv::builder().segment_limit_po2(po2).write_frame(&bytes).build().map_err(|e| anyhow!("executor env: {e}"))
|
||||
Risc0ProofSystem::env_for(input, po2)
|
||||
}
|
||||
|
||||
fn check_shard_output(out: &ShardOutput, native: &ShardOutput) -> Result<()> {
|
||||
|
|
@ -360,17 +363,17 @@ fn run_compressed(pinned: &pinned::Pinned, shards: &[BuiltShard], index: usize,
|
|||
results.insert("execute_seconds".into(), dt.into());
|
||||
results.insert("execute_segments".into(), segments.iter().map(|(p, c)| serde_json::json!({"po2": p, "cycles": c})).collect::<Vec<_>>().into());
|
||||
|
||||
stage(&format!("compressed shard {i} (succinct receipt, po2 {po2})"));
|
||||
let env = env_for(&s.input, po2)?;
|
||||
let t = Instant::now();
|
||||
let info = default_prover().prove_with_opts(env, pinned.elf(), &ProverOpts::succinct()).map_err(|e| anyhow!("prove: {e}"))?;
|
||||
let dt = t.elapsed().as_secs_f64();
|
||||
let receipt = info.receipt;
|
||||
let stats = info.stats;
|
||||
let t = Instant::now();
|
||||
let ok = receipt.verify(pinned.image_digest()).is_ok();
|
||||
let vdt = t.elapsed().as_secs_f64();
|
||||
stage(&format!("compressed shard {i} (succinct receipt, po2 {po2}, ProofSystem v{})", Risc0ProofSystem::VERSION));
|
||||
// through the trait (mission item 9): the same call shape as the SP1 host's prove_shard
|
||||
let system = Risc0ProofSystem::new(pinned, po2);
|
||||
let proof = system.prove_shard(&ShardWitness { input: s.input.clone() })?;
|
||||
let dt = system.last_timing("compressed").unwrap_or_default().as_secs_f64();
|
||||
let receipt = proof.receipt;
|
||||
let stats = proof.stats;
|
||||
let (ok_and_journal, vdt) = system.verify_shard(&receipt, &s.output);
|
||||
let vdt = vdt.as_secs_f64();
|
||||
let journal_ok = receipt.journal.bytes == s.output.to_bytes();
|
||||
let ok = ok_and_journal || receipt.verify(pinned.image_digest()).is_ok();
|
||||
let bytes = bincode::serialize(&receipt)?;
|
||||
let statement = alloy_primitives::keccak256(&receipt.journal.bytes);
|
||||
let proof_hash: [u8; 32] = sha2::Sha256::digest(&bytes).into();
|
||||
|
|
|
|||
94
proving/igneum-prove-r0/host/src/proof_system.rs
Normal file
94
proving/igneum-prove-r0/host/src/proof_system.rs
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
//! Proof system version 2: RISC Zero 3.0.6 behind the `ProofSystem` trait of `igneum_prove_core::proof_system`
|
||||
//! (design 5.6; mission item 9, 7 October 2026). `prove_shard` proves the pinned shard guest to a SUCCINCT receipt
|
||||
//! (RISC Zero's constant-size STARK, the analogue of SP1's compressed proof) and checks the journal is the native
|
||||
//! statement; `verify_shard` verifies a receipt against the pinned image id. There is no version 2 aggregator yet
|
||||
//! (a block's shards are one family, docs/analysis/proving-methods.md route D), so `aggregate`, `wrap`,
|
||||
//! `verify_segment` and `verify_wrapped` answer with an error or false, honestly, instead of a stub.
|
||||
|
||||
use crate::pinned::Pinned;
|
||||
use alloy_primitives::B256;
|
||||
use anyhow::{anyhow, bail, Result};
|
||||
use igneum_prove_core::proof_system::{PgasTable, ProofSystem, SegmentClaim, ShardWitness, VERSION_RISC0};
|
||||
use igneum_prove_core::shard::{ShardInput, ShardOutput};
|
||||
use risc0_zkvm::{default_prover, ExecutorEnv, ProverOpts, Receipt};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub struct Risc0ProofSystem<'a> {
|
||||
pinned: &'a Pinned,
|
||||
/// The segment limit (2^po2 cycles a segment; RISC Zero's default is 20).
|
||||
pub po2: u32,
|
||||
table: PgasTable,
|
||||
pub timings: std::sync::Mutex<Vec<(String, Duration)>>,
|
||||
}
|
||||
|
||||
pub struct Risc0ShardProof {
|
||||
pub receipt: Receipt,
|
||||
pub output: ShardOutput,
|
||||
pub stats: risc0_zkvm::SessionStats,
|
||||
}
|
||||
|
||||
/// No version 2 aggregator exists; the type is named so the trait is implemented in full.
|
||||
pub struct Risc0SegmentProof;
|
||||
|
||||
impl<'a> Risc0ProofSystem<'a> {
|
||||
pub fn new(pinned: &'a Pinned, po2: u32) -> Self {
|
||||
Self { pinned, po2, table: PgasTable { name: "prototype-b7fca5a0", version: VERSION_RISC0 }, timings: std::sync::Mutex::new(Vec::new()) }
|
||||
}
|
||||
|
||||
pub fn env_for(input: &ShardInput, po2: u32) -> Result<ExecutorEnv<'static>> {
|
||||
let bytes = bincode::serialize(input)?;
|
||||
ExecutorEnv::builder().segment_limit_po2(po2).write_frame(&bytes).build().map_err(|e| anyhow!("executor env: {e}"))
|
||||
}
|
||||
|
||||
/// The cryptographic check under the pinned image id, and the journal against the expected statement.
|
||||
pub fn verify_shard(&self, receipt: &Receipt, expected: &ShardOutput) -> (bool, Duration) {
|
||||
let t = Instant::now();
|
||||
let ok = receipt.verify(self.pinned.image_digest()).is_ok();
|
||||
let dt = t.elapsed();
|
||||
(ok && receipt.journal.bytes == expected.to_bytes(), dt)
|
||||
}
|
||||
|
||||
pub fn last_timing(&self, what: &str) -> Option<Duration> {
|
||||
self.timings.lock().unwrap().iter().rev().find(|(k, _)| k == what).map(|(_, d)| *d)
|
||||
}
|
||||
}
|
||||
|
||||
impl ProofSystem for Risc0ProofSystem<'_> {
|
||||
const VERSION: u16 = VERSION_RISC0;
|
||||
type ShardProof = Risc0ShardProof;
|
||||
type SegmentProof = Risc0SegmentProof;
|
||||
type WrappedProof = Risc0SegmentProof;
|
||||
|
||||
fn program_id(&self) -> B256 {
|
||||
self.pinned.image_id
|
||||
}
|
||||
|
||||
fn prove_shard(&self, w: &ShardWitness) -> Result<Risc0ShardProof> {
|
||||
let env = Self::env_for(&w.input, self.po2)?;
|
||||
let t = Instant::now();
|
||||
let info = default_prover().prove_with_opts(env, self.pinned.elf(), &ProverOpts::succinct()).map_err(|e| anyhow!("prove: {e}"))?;
|
||||
self.timings.lock().unwrap().push(("compressed".into(), t.elapsed()));
|
||||
let output = ShardOutput::from_bytes(&info.receipt.journal.bytes).ok_or_else(|| anyhow!("the journal is not a shard statement"))?;
|
||||
Ok(Risc0ShardProof { receipt: info.receipt, output, stats: info.stats })
|
||||
}
|
||||
|
||||
fn aggregate(&self, _prev: Option<&Risc0SegmentProof>, _shards: &[Risc0ShardProof]) -> Result<Risc0SegmentProof> {
|
||||
bail!("no version 2 aggregator: a block's shards are one family and the RISC Zero aggregator guest (composition over the shard receipts) is open work")
|
||||
}
|
||||
|
||||
fn wrap(&self, _p: &Risc0SegmentProof) -> Result<Risc0SegmentProof> {
|
||||
bail!("wrap (Groth16 over bn254) is not run for version 2: RISC Zero's wrapper is x86 only and the ledger P3 measurement is SP1's")
|
||||
}
|
||||
|
||||
fn verify_segment(&self, _p: &Risc0SegmentProof, _claim: &SegmentClaim) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn verify_wrapped(&self, _p: &Risc0SegmentProof, _claim: &SegmentClaim) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn pgas_table(&self) -> &PgasTable {
|
||||
&self.table
|
||||
}
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ alloy-eips.workspace = true
|
|||
alloy-rlp.workspace = true
|
||||
alloy-trie.workspace = true
|
||||
serde.workspace = true
|
||||
anyhow.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json.workspace = true
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ pub mod executor;
|
|||
pub mod fixture;
|
||||
pub mod pgas;
|
||||
pub mod plan;
|
||||
pub mod proof_system;
|
||||
pub mod registry;
|
||||
pub mod shard;
|
||||
pub mod state;
|
||||
|
|
@ -31,6 +32,7 @@ pub mod trie;
|
|||
pub mod witness;
|
||||
|
||||
pub use executor::{execute_block, execute_range, BlockOutcome, Carry, ShardTx};
|
||||
pub use proof_system::{PgasTable, ProofSystem, SegmentClaim, ShardWitness};
|
||||
pub use fixture::{AccountFixture, BlockFixture, Expected, Fixture, FixtureEnv, IncludingBlock, Plan, ShardExpected};
|
||||
pub use shard::{ShardInput, ShardOutput};
|
||||
pub use state::IgneumDb;
|
||||
|
|
|
|||
111
proving/igneum-prove/core/src/proof_system.rs
Normal file
111
proving/igneum-prove/core/src/proof_system.rs
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
//! The versioned prover trait of `docs/design/execution-layer.md` section 5.6, lifted out of the SP1 host on
|
||||
//! 7 October 2026 (mission item 9) so that every proof system implements ONE trait from one crate that depends on
|
||||
//! no zkVM SDK: version 0 is the devnet stub and version 1 is SP1 (`proving/igneum-prove/host/src/proof_system.rs`),
|
||||
//! version 2 is RISC Zero (`proving/igneum-prove-r0/host/src/proof_system.rs`). The two SDK workspaces carry their
|
||||
//! own crate patches, so they cannot share one workspace; they share this crate by path.
|
||||
//!
|
||||
//! What every version commits: the shard statement is `ShardOutput::to_bytes` (328 bytes) and the block statement
|
||||
//! is `BlockOutput::to_bytes` (340 bytes, or 340 plus the finality extension when the fin-proof lane's aggregation
|
||||
//! carries one), byte for byte, whatever the proof system. A record's `statement` is keccak256 of those bytes, so a
|
||||
//! disagreement between two systems on one shard is a disagreement of two 32-byte values anyone can compare.
|
||||
|
||||
use crate::agg::BlockOutput;
|
||||
use crate::shard::ShardInput;
|
||||
use alloy_primitives::{keccak256, B256};
|
||||
use anyhow::Result;
|
||||
|
||||
/// The proof systems the node's active list may name (`ProofRecord::version` on the wire).
|
||||
pub const VERSION_STUB: u16 = 0;
|
||||
pub const VERSION_SP1: u16 = 1;
|
||||
pub const VERSION_RISC0: u16 = 2;
|
||||
|
||||
/// The system's name for logs and status lines.
|
||||
pub fn system_name(version: u16) -> &'static str {
|
||||
match version {
|
||||
VERSION_STUB => "stub",
|
||||
VERSION_SP1 => "sp1",
|
||||
VERSION_RISC0 => "risc0",
|
||||
_ => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
/// The prototype pgas table identity (design 4.2, the table itself lives in `crate::pgas`).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PgasTable {
|
||||
pub name: &'static str,
|
||||
pub version: u16,
|
||||
}
|
||||
|
||||
/// What a shard proof is about (design 5.1): the shard's input, witness and prover included.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ShardWitness {
|
||||
pub input: ShardInput,
|
||||
}
|
||||
|
||||
/// The claim a proof record makes (design 5.4): the segment, its pre- and post-roots and receipts commitment.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SegmentClaim {
|
||||
pub segment: B256,
|
||||
pub pre_root: B256,
|
||||
pub post_root: B256,
|
||||
pub receipts: B256,
|
||||
}
|
||||
|
||||
impl SegmentClaim {
|
||||
pub fn from_block(o: &BlockOutput) -> Self {
|
||||
Self { segment: o.block_hash, pre_root: o.pre_root, post_root: o.post_root, receipts: o.receipts }
|
||||
}
|
||||
pub fn digest(&self) -> B256 {
|
||||
let mut buf = Vec::with_capacity(128);
|
||||
buf.extend_from_slice(self.segment.as_slice());
|
||||
buf.extend_from_slice(self.pre_root.as_slice());
|
||||
buf.extend_from_slice(self.post_root.as_slice());
|
||||
buf.extend_from_slice(self.receipts.as_slice());
|
||||
keccak256(buf)
|
||||
}
|
||||
}
|
||||
|
||||
pub trait ProofSystem: Send + Sync {
|
||||
const VERSION: u16;
|
||||
type ShardProof;
|
||||
type SegmentProof;
|
||||
type WrappedProof;
|
||||
/// Hash of the executor guest (SP1: the shard program's verifying key hash; RISC Zero: the image id; stub: a
|
||||
/// fixed tag). What the node's active list pins per version.
|
||||
fn program_id(&self) -> B256;
|
||||
fn prove_shard(&self, w: &ShardWitness) -> Result<Self::ShardProof>;
|
||||
fn aggregate(&self, prev: Option<&Self::SegmentProof>, shards: &[Self::ShardProof]) -> Result<Self::SegmentProof>;
|
||||
fn wrap(&self, p: &Self::SegmentProof) -> Result<Self::WrappedProof>;
|
||||
fn verify_segment(&self, p: &Self::SegmentProof, claim: &SegmentClaim) -> bool;
|
||||
fn verify_wrapped(&self, p: &Self::WrappedProof, claim: &SegmentClaim) -> bool;
|
||||
fn pgas_table(&self) -> &PgasTable;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_versions_are_distinct_and_named() {
|
||||
assert_eq!((VERSION_STUB, VERSION_SP1, VERSION_RISC0), (0, 1, 2));
|
||||
assert_eq!(system_name(1), "sp1");
|
||||
assert_eq!(system_name(2), "risc0");
|
||||
assert_eq!(system_name(9), "unknown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_claim_digest_commits_to_every_field() {
|
||||
let c = SegmentClaim { segment: B256::repeat_byte(1), pre_root: B256::repeat_byte(2), post_root: B256::repeat_byte(3), receipts: B256::repeat_byte(4) };
|
||||
let d = c.digest();
|
||||
for f in 0..4 {
|
||||
let mut x = c.clone();
|
||||
match f {
|
||||
0 => x.segment = B256::repeat_byte(9),
|
||||
1 => x.pre_root = B256::repeat_byte(9),
|
||||
2 => x.post_root = B256::repeat_byte(9),
|
||||
_ => x.receipts = B256::repeat_byte(9),
|
||||
}
|
||||
assert_ne!(x.digest(), d);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -17,56 +17,9 @@ use sp1_sdk::blocking::{Prover, ProveRequest, ProverClient, SP1Stdin};
|
|||
use sp1_sdk::{Elf, HashableKey, ProvingKey, SP1Proof, SP1ProofWithPublicValues, SP1VerifyingKey};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// The prototype pgas table identity (design 4.2, the table itself lives in `igneum_prove_core::pgas`).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PgasTable {
|
||||
pub name: &'static str,
|
||||
pub version: u16,
|
||||
}
|
||||
|
||||
/// What a shard proof is about (design 5.1): the shard's input, witness and prover included.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ShardWitness {
|
||||
pub input: ShardInput,
|
||||
}
|
||||
|
||||
/// The claim a proof record makes (design 5.4): the segment, its pre- and post-roots and receipts commitment.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SegmentClaim {
|
||||
pub segment: B256,
|
||||
pub pre_root: B256,
|
||||
pub post_root: B256,
|
||||
pub receipts: B256,
|
||||
}
|
||||
|
||||
impl SegmentClaim {
|
||||
pub fn from_block(o: &BlockOutput) -> Self {
|
||||
Self { segment: o.block_hash, pre_root: o.pre_root, post_root: o.post_root, receipts: o.receipts }
|
||||
}
|
||||
pub fn digest(&self) -> B256 {
|
||||
let mut buf = Vec::with_capacity(128);
|
||||
buf.extend_from_slice(self.segment.as_slice());
|
||||
buf.extend_from_slice(self.pre_root.as_slice());
|
||||
buf.extend_from_slice(self.post_root.as_slice());
|
||||
buf.extend_from_slice(self.receipts.as_slice());
|
||||
keccak256(buf)
|
||||
}
|
||||
}
|
||||
|
||||
pub trait ProofSystem: Send + Sync {
|
||||
const VERSION: u16;
|
||||
type ShardProof;
|
||||
type SegmentProof;
|
||||
type WrappedProof;
|
||||
/// Hash of the executor guest (SP1: the shard program's verifying key hash; stub: a fixed tag).
|
||||
fn program_id(&self) -> B256;
|
||||
fn prove_shard(&self, w: &ShardWitness) -> Result<Self::ShardProof>;
|
||||
fn aggregate(&self, prev: Option<&Self::SegmentProof>, shards: &[Self::ShardProof]) -> Result<Self::SegmentProof>;
|
||||
fn wrap(&self, p: &Self::SegmentProof) -> Result<Self::WrappedProof>;
|
||||
fn verify_segment(&self, p: &Self::SegmentProof, claim: &SegmentClaim) -> bool;
|
||||
fn verify_wrapped(&self, p: &Self::WrappedProof, claim: &SegmentClaim) -> bool;
|
||||
fn pgas_table(&self) -> &PgasTable;
|
||||
}
|
||||
// The trait, the claim, the witness and the table live in igneum-prove-core (proof_system.rs, 7 October 2026:
|
||||
// one trait for every proof system, no SDK in the crate that defines it); re-exported here for the host.
|
||||
pub use igneum_prove_core::proof_system::{PgasTable, ProofSystem, SegmentClaim, ShardWitness};
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// Version 0: the devnet stub of design 5.7. `prove_shard` executes natively and signs the shard output with a
|
||||
|
|
|
|||
|
|
@ -26,12 +26,31 @@ for k in ("shard","aggregator"):
|
|||
print(m[k]["elf"], m[k]["elf_sha256"]); print(m[k]["vk"], m[k]["vk_sha256"])
|
||||
' "$E/manifest.json")
|
||||
fi
|
||||
ALLOW='^(proving/igneum-prove/pin-guests\.sh|proving/igneum-prove/host/build\.rs|proving/igneum-prove/host/src/bin/pin\.rs|tools/ci/pinned-guests-check\.sh)$'
|
||||
# Proof system 2 (RISC Zero, mission item 9, 7 October 2026): the same rule over proving/igneum-prove-r0: its guest is pinned
|
||||
# under elf/ (igneum-prove-r0-guest.bin and manifest-r0.json), only pin-guest.sh builds it (methods/build.rs under
|
||||
# IGNEUM_BUILD_GUESTS=1, host/src/bin/pin.rs writes the pin).
|
||||
R=proving/igneum-prove-r0/elf
|
||||
for f in manifest-r0.json igneum-prove-r0-guest.bin; do
|
||||
[ -f "$R/$f" ] || { echo "pinned-guests: $R/$f is missing"; fail=1; }
|
||||
done
|
||||
if [ -f "$R/manifest-r0.json" ] && [ -f "$R/igneum-prove-r0-guest.bin" ]; then
|
||||
while IFS= read -r line; do
|
||||
file="${line%% *}"; want="${line#* }"
|
||||
got="0x$(shasum -a 256 "$R/$file" | cut -c1-64)"
|
||||
if [ "$got" != "$want" ]; then echo "pinned-guests: $R/$file hashes to $got, the manifest says $want (run proving/igneum-prove-r0/pin-guest.sh)"; fail=1; fi
|
||||
done < <(python3 -c '
|
||||
import json,sys
|
||||
m=json.load(open(sys.argv[1]))
|
||||
assert m["format"]=="igneum-prove-r0-manifest-v1", m["format"]
|
||||
print(m["guest"]["elf"], m["guest"]["elf_sha256"])
|
||||
' "$R/manifest-r0.json")
|
||||
fi
|
||||
ALLOW='^(proving/igneum-prove/pin-guests\.sh|proving/igneum-prove/host/build\.rs|proving/igneum-prove/host/src/bin/pin\.rs|proving/igneum-prove-r0/pin-guest\.sh|proving/igneum-prove-r0/methods/build\.rs|proving/igneum-prove-r0/host/src/bin/pin\.rs|tools/ci/pinned-guests-check\.sh)$'
|
||||
while IFS= read -r f; do
|
||||
[[ "$f" =~ $ALLOW ]] && continue
|
||||
if grep -vE '^\s*(//|#)' "$f" | grep -qE 'IGNEUM_BUILD_GUESTS=1|cargo prove build|-p igneum-prove-(program|aggregator)\b'; then # comments do not build
|
||||
echo "pinned-guests: $f builds a guest outside pin-guests.sh (the host embeds the pinned elf/ files; never build a guest elsewhere)"; fail=1
|
||||
fi
|
||||
done < <(git ls-files 'packaging/**' 'proving/**' 'infra/**' 'tools/**' 'relay/playbooks/**' 'app/igneum-app/src/**' '.github/**' | grep -E '\.(sh|ps1|mjs|rs|yml|bat)$')
|
||||
[ "$fail" = 0 ] && echo "pinned-guests: elf/ matches its manifest and no script builds a guest outside pin-guests.sh"
|
||||
[ "$fail" = 0 ] && echo "pinned-guests: elf/ matches its manifest (both proof systems) and no script builds a guest outside the pin scripts"
|
||||
exit $fail
|
||||
|
|
|
|||
251
tools/shadow/shadow.py
Executable file
251
tools/shadow/shadow.py
Executable file
|
|
@ -0,0 +1,251 @@
|
|||
#!/usr/bin/env python3
|
||||
"""The shadow runner (mission item 9, docs/analysis/mission/mission.md 2.9; 7 October 2026): on one box, every
|
||||
segment a node executed is proven by BOTH proof systems behind the ProofSystem trait, SP1 (proof system 1, the pinned
|
||||
shard program) and RISC Zero (proof system 2, the pinned guest), and the two are compared. Agreement means: both hosts
|
||||
proved the shard, each proof verifies under its own pinned id, and the two statements (keccak256 of the committed
|
||||
shard public values) are one and the same. Anything else is a disagreement, which is the alert.
|
||||
|
||||
Read-only on the node: segments come from `igneum_exportSegments` over a tunnel (tools/shadow/tunnel.sh); nothing is
|
||||
submitted anywhere. The alert (a disagreement) is written to out/alerts.jsonl, printed as an ALERT line, posted to a
|
||||
node's `igneum_proofDisagreement` when --alert-rpc names one (that node's pool then stops carrying proof records until
|
||||
one third of weight signals which system to trust, igneum/exec/src/proving.rs), and handed to the Discord incident
|
||||
hook when --discord names the script (dry run unless --discord-live).
|
||||
|
||||
One pass: the node's tip, a batch of chain blocks below it (inside the node's 2,048-block account-dump ring), one
|
||||
export with the account dump at the block before the batch, one fixture per block (igneum-prove-export), and per shard
|
||||
of the fixture's plan both hosts in --mode compressed, then each proof re-verified by its own host in --mode verify (the
|
||||
node's verifier path), then the row. Rows: out/rows.jsonl (one per shard). Summary: out/summary.json after every row
|
||||
(agree, disagree, prove-time quantiles per system, proof bytes, verify time, cycles). Stops at --segments agreeing
|
||||
segments, at --max-hours, or when out/STOP exists; its pid is out/shadow.pid.
|
||||
|
||||
The injected bad proof (the gate's second line): --inject soundness --inject-at K replaces the SP1 statement of the
|
||||
K-th shard row with another value while keeping its verifier claim, which is what a soundness bug looks like from
|
||||
outside (a proof that verifies for a statement native execution did not produce); --inject bytes flips a byte of the
|
||||
SP1 proof file instead, which its own verifier refuses. Either way the row disagrees, pays nothing (a record carrying
|
||||
it is vetoed natively, and a stopped pool carries nothing) and raises the alert. The injected row is labelled and is
|
||||
never counted as agreement.
|
||||
|
||||
Usage (on the shadow box):
|
||||
python3 shadow.py --sp1-host <igneum-prove-host> --r0-host <igneum-prove-r0-host> --export <igneum-prove-export>
|
||||
--rpc http://127.0.0.1:26790 --out /root/sp-shadow/out --segments 1000 [--batch 40] [--lag 20] [--po2 20]
|
||||
[--sp1-env "HOME=/opt/igneum-floor/home SP1_PROVER=cuda SP1_GPU_ELEMENT_THRESHOLD=67108864"]
|
||||
[--r0-env "RISC0_PROVER=local"] [--parallel] [--alert-rpc http://127.0.0.1:29390] [--discord <hooks.mjs>]
|
||||
[--inject soundness|bytes --inject-at K] [--max-hours 11] [--keep 5] [--self-test]
|
||||
"""
|
||||
import argparse, json, os, shutil, subprocess, sys, time, urllib.request, datetime, statistics, signal
|
||||
|
||||
def now(): return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
def say(line, out=None):
|
||||
print(line, flush=True)
|
||||
if out:
|
||||
with open(os.path.join(out, "shadow.log"), "a") as f: f.write(line + "\n")
|
||||
|
||||
def rpc(url, method, params=None, timeout=120):
|
||||
body = json.dumps({"jsonrpc": "2.0", "id": 1, "method": method, "params": params or []}).encode()
|
||||
req = urllib.request.Request(url, data=body, headers={"content-type": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
d = json.loads(r.read())
|
||||
if "error" in d: raise RuntimeError(f"{method}: {d['error']}")
|
||||
return d["result"]
|
||||
|
||||
def run(cmd, env_extra, log_path, timeout):
|
||||
"""Runs a host; returns (rc, last RESULT line or last stderr line)."""
|
||||
env = dict(os.environ)
|
||||
for kv in (env_extra or "").split():
|
||||
k, _, v = kv.partition("="); env[k] = v
|
||||
with open(log_path, "w") as log:
|
||||
try:
|
||||
p = subprocess.run(cmd, env=env, stdout=log, stderr=subprocess.STDOUT, timeout=timeout)
|
||||
rc = p.returncode
|
||||
except subprocess.TimeoutExpired:
|
||||
rc = 124
|
||||
last = ""
|
||||
try:
|
||||
lines = open(log_path, errors="replace").read().splitlines()
|
||||
res = [l for l in lines if l.startswith("RESULT")]
|
||||
last = (res[-1] if res else (lines[-1] if lines else ""))[:400]
|
||||
if rc == 124: last = f"timed out after {timeout} s; " + last
|
||||
except OSError:
|
||||
pass
|
||||
return rc, last
|
||||
|
||||
def prove_one(label, host, env_extra, fixture, shard, out_json, log_path, extra, timeout):
|
||||
"""One host in --mode compressed, then its own --mode verify on the proof it wrote (the node's path)."""
|
||||
cmd = [host, fixture, "--mode", "compressed", "--shard", str(shard), "--prover", "0xCAfc6e74000000000000000000000000000000c2", "--out", out_json] + extra
|
||||
t0 = time.time(); rc, last = run(cmd, env_extra, log_path, timeout); wall = round(time.time() - t0, 2)
|
||||
r = {"system": label, "ok": False, "wall_s": wall, "last": last}
|
||||
if rc != 0 or not os.path.exists(out_json):
|
||||
r["error"] = f"prove exit {rc}: {last}"; return r
|
||||
res = json.load(open(out_json))
|
||||
r.update({"statement": res.get("statement"), "prove_s": res.get("compressed_prove_seconds"), "proof_bytes": res.get("compressed_proof_bytes"),
|
||||
"cycles": res.get("cycles"), "proof_file": res.get("proof_file"), "program_id": res.get("image_id") or res.get("shard_program_id"), "proof_sha256": res.get("proof_sha256")})
|
||||
t0 = time.time(); vrc, vlast = run([host, "--mode", "verify", "--proof", r["proof_file"], "--statement", r["statement"]], env_extra, log_path + ".verify", 300)
|
||||
r["verify_s"] = round(time.time() - t0, 3); r["verified"] = vrc == 0; r["verify_last"] = vlast
|
||||
r["ok"] = r["verified"] and bool(r["statement"])
|
||||
if not r["verified"]: r["error"] = f"its own verifier refuses the proof: {vlast}"
|
||||
return r
|
||||
|
||||
def quantiles(xs):
|
||||
xs = sorted(x for x in xs if isinstance(x, (int, float)))
|
||||
if not xs: return None
|
||||
q = lambda p: xs[min(len(xs) - 1, int(p * (len(xs) - 1)))]
|
||||
return {"n": len(xs), "min": xs[0], "median": statistics.median(xs), "p90": q(0.9), "max": xs[-1], "mean": round(sum(xs) / len(xs), 3)}
|
||||
|
||||
def summarize(rows, started, args, note=""):
|
||||
agree = [r for r in rows if r["agree"]]
|
||||
dis = [r for r in rows if not r["agree"]]
|
||||
segs_agree = sorted({r["number"] for r in agree if not r.get("injected")})
|
||||
# a segment agrees when every shard of it agreed and none disagreed
|
||||
bad_segs = {r["number"] for r in dis}
|
||||
segs_agree = [n for n in segs_agree if n not in bad_segs]
|
||||
s = {"at": now(), "started": started, "elapsed_s": round(time.time() - time.mktime(time.strptime(started, "%Y-%m-%dT%H:%M:%SZ")) + time.timezone, 1),
|
||||
"rows": len(rows), "shards_agree": len(agree), "shards_disagree": len(dis), "segments_agree": len(segs_agree), "segments_disagree": len(bad_segs),
|
||||
"injected": [r["number"] for r in rows if r.get("injected")], "target_segments": args.segments, "note": note,
|
||||
"sp1": {"prove_s": quantiles([r["sp1"].get("prove_s") for r in rows]), "wall_s": quantiles([r["sp1"].get("wall_s") for r in rows]), "proof_bytes": quantiles([r["sp1"].get("proof_bytes") for r in rows]), "verify_s": quantiles([r["sp1"].get("verify_s") for r in rows]), "cycles": quantiles([r["sp1"].get("cycles") for r in rows]), "program_id": next((r["sp1"].get("program_id") for r in rows if r["sp1"].get("program_id")), None)},
|
||||
"r0": {"prove_s": quantiles([r["r0"].get("prove_s") for r in rows]), "wall_s": quantiles([r["r0"].get("wall_s") for r in rows]), "proof_bytes": quantiles([r["r0"].get("proof_bytes") for r in rows]), "verify_s": quantiles([r["r0"].get("verify_s") for r in rows]), "cycles": quantiles([r["r0"].get("cycles") for r in rows]), "program_id": next((r["r0"].get("program_id") for r in rows if r["r0"].get("program_id")), None)},
|
||||
"txs": quantiles([r.get("txs") for r in rows]), "shards_per_segment": quantiles([r.get("shards") for r in rows]),
|
||||
"first_number": min((r["number"] for r in rows), default=None), "last_number": max((r["number"] for r in rows), default=None)}
|
||||
json.dump(s, open(os.path.join(args.out, "summary.json"), "w"), indent=1)
|
||||
return s
|
||||
|
||||
def alert(row, args):
|
||||
line = f"ALERT {now()} proof systems DISAGREE on chain block {row['number']} shard {row['shard']}: {row['reason']} (sp1 {row['sp1'].get('statement')} r0 {row['r0'].get('statement')}){' [INJECTED ' + row['injected'] + ']' if row.get('injected') else ''}"
|
||||
say(line, args.out)
|
||||
with open(os.path.join(args.out, "alerts.jsonl"), "a") as f: f.write(json.dumps({"at": now(), "line": line, "row": row}) + "\n")
|
||||
json.dump({"at": now(), "line": line, "row": row}, open(os.path.join(args.out, "alert.json"), "w"), indent=1)
|
||||
if args.alert_rpc:
|
||||
try:
|
||||
r = rpc(args.alert_rpc, "igneum_proofDisagreement", [{"number": row["number"], "blockHash": row.get("hash"), "shard": row["shard"], "systems": {"1": row["sp1"].get("statement"), "2": row["r0"].get("statement")}, "reason": row["reason"], "source": "tools/shadow/shadow.py", "injected": row.get("injected")}], timeout=30)
|
||||
say(f"RESULT alert_rpc {now()} {args.alert_rpc}: {json.dumps(r)[:300]}", args.out)
|
||||
except Exception as e:
|
||||
say(f"RESULT alert_rpc {now()} FAILED: {e}", args.out)
|
||||
if args.discord and os.path.exists(args.discord):
|
||||
cmd = ["node", args.discord, "incident", "open", "--what", f"Proof systems disagree on chain block {row['number']} shard {row['shard']}: {row['reason']}", "--affected", "the proving pool: no proof record is carried until one third of weight signals which proof system to trust; full nodes keep the native-execution veto", "--doing", "the shadow runner's row is in out/alerts.jsonl; the disagreeing proof is kept for the review", "--id", f"proof-disagreement-{row['number']}-{row['shard']}"] + (["--live"] if args.discord_live else [])
|
||||
try:
|
||||
p = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
||||
say(f"RESULT discord {now()} exit {p.returncode}: {(p.stdout or p.stderr).strip()[:300]}", args.out)
|
||||
except Exception as e:
|
||||
say(f"RESULT discord {now()} FAILED: {e}", args.out)
|
||||
|
||||
def self_test():
|
||||
"""The gate's two shapes on synthetic rows: an agreeing row counts, an injected disagreeing row raises and never counts."""
|
||||
class A: pass
|
||||
a = A(); a.out = "/tmp/sp-shadow-selftest"; a.segments = 2; a.alert_rpc = None; a.discord = None; a.discord_live = False
|
||||
shutil.rmtree(a.out, ignore_errors=True); os.makedirs(a.out)
|
||||
started = now()
|
||||
good = {"number": 1, "shard": 0, "sp1": {"statement": "0xaa", "verified": True, "ok": True, "prove_s": 1.0, "proof_bytes": 10, "verify_s": 0.1, "cycles": 5}, "r0": {"statement": "0xaa", "verified": True, "ok": True, "prove_s": 2.0, "proof_bytes": 5, "verify_s": 0.2, "cycles": 5}, "agree": True, "reason": "", "txs": 0, "shards": 1}
|
||||
bad = dict(good); bad = json.loads(json.dumps(good)); bad["number"] = 2; bad["sp1"]["statement"] = "0xab"; bad["agree"] = False; bad["reason"] = "statements differ"; bad["injected"] = "soundness"
|
||||
alert(bad, a)
|
||||
s = summarize([good, bad], started, a)
|
||||
assert s["segments_agree"] == 1 and s["segments_disagree"] == 1 and s["injected"] == [2], s
|
||||
assert os.path.exists(os.path.join(a.out, "alert.json"))
|
||||
assert json.loads(open(os.path.join(a.out, "alerts.jsonl")).readline())["row"]["injected"] == "soundness"
|
||||
print("self-test: an agreeing row counts, an injected disagreeing row raises the alert and never counts; summary", {k: s[k] for k in ("segments_agree", "segments_disagree", "injected")})
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--sp1-host"); ap.add_argument("--r0-host"); ap.add_argument("--export"); ap.add_argument("--rpc", default="http://127.0.0.1:26790")
|
||||
ap.add_argument("--out", default="/root/sp-shadow/out"); ap.add_argument("--segments", type=int, default=1000); ap.add_argument("--batch", type=int, default=40); ap.add_argument("--lag", type=int, default=20)
|
||||
ap.add_argument("--po2", type=int, default=20); ap.add_argument("--sp1-env", default="SP1_PROVER=cuda RUST_LOG=off"); ap.add_argument("--r0-env", default="RISC0_PROVER=local RUST_LOG=off")
|
||||
ap.add_argument("--parallel", action="store_true", help="run the two hosts at once (two GPU residents; fits a 24 GB card at the 2^26 SP1 threshold and po2 19)")
|
||||
ap.add_argument("--alert-rpc"); ap.add_argument("--discord"); ap.add_argument("--discord-live", action="store_true")
|
||||
ap.add_argument("--inject", choices=["soundness", "bytes"]); ap.add_argument("--inject-at", type=int, default=1)
|
||||
ap.add_argument("--max-hours", type=float, default=11.0); ap.add_argument("--keep", type=int, default=5); ap.add_argument("--timeout", type=int, default=900)
|
||||
ap.add_argument("--self-test", action="store_true")
|
||||
args = ap.parse_args()
|
||||
if args.self_test: return self_test()
|
||||
for k in ("sp1_host", "r0_host", "export"):
|
||||
if not getattr(args, k) or not os.access(getattr(args, k), os.X_OK): sys.exit(f"--{k.replace('_', '-')} must name an executable")
|
||||
os.makedirs(args.out, exist_ok=True)
|
||||
open(os.path.join(args.out, "shadow.pid"), "w").write(str(os.getpid()))
|
||||
started = now(); t_start = time.time()
|
||||
rows = []
|
||||
rows_path = os.path.join(args.out, "rows.jsonl")
|
||||
if os.path.exists(rows_path):
|
||||
rows = [json.loads(l) for l in open(rows_path) if l.strip()]
|
||||
done = {(r["number"], r["shard"]) for r in rows}
|
||||
ids = {}
|
||||
for label, host in (("sp1", args.sp1_host), ("r0", args.r0_host)):
|
||||
rc, last = run([host, "--mode", "id"], "", os.path.join(args.out, f"id-{label}.log"), 60)
|
||||
ids[label] = last; say(f"RESULT id {label}: {last}", args.out)
|
||||
say(f"RESULT start {started} rpc {args.rpc} target {args.segments} segments, batch {args.batch}, lag {args.lag}, po2 {args.po2}, parallel {args.parallel}, inject {args.inject} at {args.inject_at}, resumed rows {len(rows)}", args.out)
|
||||
shard_counter = 0
|
||||
stop_reason = ""
|
||||
while True:
|
||||
s = summarize(rows, started, args)
|
||||
if s["segments_agree"] >= args.segments: stop_reason = f"target reached: {s['segments_agree']} segments agree"; break
|
||||
if time.time() - t_start > args.max_hours * 3600: stop_reason = f"max hours {args.max_hours} reached"; break
|
||||
if os.path.exists(os.path.join(args.out, "STOP")): stop_reason = "STOP file"; break
|
||||
try:
|
||||
tip = int(rpc(args.rpc, "eth_blockNumber"), 16)
|
||||
except Exception as e:
|
||||
say(f"RESULT rpc {now()} tip unreachable: {e}; waiting 30 s", args.out); time.sleep(30); continue
|
||||
last = tip - args.lag; first = last - args.batch + 1
|
||||
# the batch below the last proven block when the chain has not moved past it (never a block twice)
|
||||
if rows and first <= max(r["number"] for r in rows): first = max(r["number"] for r in rows) + 1
|
||||
if first > last: say(f"RESULT wait {now()} tip {tip}: no new blocks under the lag; 20 s", args.out); time.sleep(20); continue
|
||||
d = os.path.join(args.out, f"batch-{first}-{last}"); os.makedirs(d, exist_ok=True)
|
||||
t0 = time.time()
|
||||
try:
|
||||
export = rpc(args.rpc, "igneum_exportSegments", [hex(first - 1), hex(last)], timeout=600)
|
||||
except Exception as e:
|
||||
say(f"RESULT export {now()} {first - 1}..{last} FAILED: {e}; 30 s", args.out); time.sleep(30); continue
|
||||
json.dump(export, open(os.path.join(d, "export.json"), "w"))
|
||||
say(f"RESULT export {now()} {first - 1}..{last} ({len(export.get('segments', []))} segments, {len(export.get('preState') or [])} accounts in the dump, {round(time.time() - t0, 1)} s)", args.out)
|
||||
for n in range(first, last + 1):
|
||||
if (n, 0) in done: continue
|
||||
fx = os.path.join(d, f"block-{n}.json")
|
||||
rc, lastl = run([args.export, os.path.join(d, "export.json"), str(n), fx, "--source", "shadow runner, Devnet 2"], "", os.path.join(d, f"export-{n}.log"), 600)
|
||||
if rc != 0 or not os.path.exists(fx):
|
||||
say(f"RESULT fixture {now()} block {n} FAILED (exit {rc}): {lastl}", args.out); continue
|
||||
fixture = json.load(open(fx))
|
||||
shards = len(fixture["plan"]["shards"]); txs = sum(len(b["txs"]) for b in fixture["block"]["blocks"]); bhash = fixture["block"]["env"]["hash"]
|
||||
for i in range(shards):
|
||||
if (n, i) in done: continue
|
||||
shard_counter += 1
|
||||
sp1_json = os.path.join(d, f"sp1-{n}-{i}.json"); r0_json = os.path.join(d, f"r0-{n}-{i}.json")
|
||||
if args.parallel:
|
||||
import concurrent.futures
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as ex:
|
||||
fa = ex.submit(prove_one, "sp1", args.sp1_host, args.sp1_env, fx, i, sp1_json, os.path.join(d, f"sp1-{n}-{i}.log"), [], args.timeout)
|
||||
fb = ex.submit(prove_one, "r0", args.r0_host, args.r0_env, fx, i, r0_json, os.path.join(d, f"r0-{n}-{i}.log"), ["--po2", str(args.po2)], args.timeout)
|
||||
a, b = fa.result(), fb.result()
|
||||
else:
|
||||
a = prove_one("sp1", args.sp1_host, args.sp1_env, fx, i, sp1_json, os.path.join(d, f"sp1-{n}-{i}.log"), [], args.timeout)
|
||||
b = prove_one("r0", args.r0_host, args.r0_env, fx, i, r0_json, os.path.join(d, f"r0-{n}-{i}.log"), ["--po2", str(args.po2)], args.timeout)
|
||||
injected = None
|
||||
if args.inject and shard_counter == args.inject_at:
|
||||
injected = args.inject
|
||||
if args.inject == "soundness" and a.get("statement"):
|
||||
# a proof that verifies for a statement native execution did not produce: the last nibble changed
|
||||
st = a["statement"]; a["statement_original"] = st; a["statement"] = st[:-1] + ("0" if st[-1] != "0" else "1"); a["injected"] = "soundness-bug simulation: the statement replaced, the verifier's claim kept"
|
||||
elif args.inject == "bytes" and a.get("proof_file"):
|
||||
pf = a["proof_file"]; data = bytearray(open(pf, "rb").read()); mid = len(data) // 2; data[mid] ^= 0x01; open(pf, "wb").write(data)
|
||||
vrc, vlast = run([args.sp1_host, "--mode", "verify", "--proof", pf, "--statement", a["statement"]], args.sp1_env, os.path.join(d, f"sp1-{n}-{i}.log.verify-injected"), 300)
|
||||
a["verified"] = vrc == 0; a["ok"] = a["verified"]; a["verify_last"] = vlast; a["injected"] = "a byte of the proof flipped; its own verifier's answer recorded"
|
||||
agree = bool(a.get("ok") and b.get("ok") and a.get("statement") == b.get("statement"))
|
||||
reason = "" if agree else ("sp1: " + a.get("error", "") if not a.get("ok") else "") + (" r0: " + b.get("error", "") if not b.get("ok") else "") + (" statements differ" if a.get("ok") and b.get("ok") and a.get("statement") != b.get("statement") else "")
|
||||
row = {"at": now(), "number": n, "hash": bhash, "shard": i, "shards": shards, "txs": txs, "sp1": a, "r0": b, "agree": agree, "reason": reason.strip(), "injected": injected}
|
||||
rows.append(row); done.add((n, i))
|
||||
with open(rows_path, "a") as f: f.write(json.dumps(row) + "\n")
|
||||
say(f"RESULT row {row['at']} block {n} shard {i}/{shards} txs {txs}: sp1 {a.get('prove_s')} s {a.get('proof_bytes')} B verify {a.get('verify_s')} s {'ok' if a.get('ok') else 'FAIL'}; r0 {b.get('prove_s')} s {b.get('proof_bytes')} B verify {b.get('verify_s')} s {'ok' if b.get('ok') else 'FAIL'}; {'AGREE' if agree else 'DISAGREE ' + reason}{' INJECTED' if injected else ''}", args.out)
|
||||
if not agree: alert(row, args)
|
||||
# disk: proofs and fixtures of the first --keep rows and of every disagreement are kept, the rest go
|
||||
keep = len(rows) <= args.keep or not agree
|
||||
if not keep:
|
||||
for p in (a.get("proof_file"), b.get("proof_file")):
|
||||
if p and os.path.exists(p): os.remove(p)
|
||||
s = summarize(rows, started, args)
|
||||
if s["segments_agree"] >= args.segments or os.path.exists(os.path.join(args.out, "STOP")): break
|
||||
if not (len(rows) <= args.keep) and os.path.exists(fx) and all(r["agree"] for r in rows if r["number"] == n): os.remove(fx)
|
||||
s = summarize(rows, started, args)
|
||||
if s["segments_agree"] >= args.segments or os.path.exists(os.path.join(args.out, "STOP")) or time.time() - t_start > args.max_hours * 3600: break
|
||||
if os.path.exists(os.path.join(d, "export.json")) and len(rows) > args.keep: os.remove(os.path.join(d, "export.json"))
|
||||
s = summarize(rows, started, args, stop_reason)
|
||||
say(f"RESULT end {now()} {stop_reason}: rows {s['rows']}, segments agree {s['segments_agree']}, disagree {s['segments_disagree']}, injected {s['injected']}; sp1 prove median {s['sp1']['prove_s'] and s['sp1']['prove_s']['median']} s p90 {s['sp1']['prove_s'] and s['sp1']['prove_s']['p90']} s; r0 median {s['r0']['prove_s'] and s['r0']['prove_s']['median']} s p90 {s['r0']['prove_s'] and s['r0']['prove_s']['p90']} s", args.out)
|
||||
try: os.remove(os.path.join(args.out, "shadow.pid"))
|
||||
except OSError: pass
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
20
tools/shadow/tunnel.sh
Executable file
20
tools/shadow/tunnel.sh
Executable file
|
|
@ -0,0 +1,20 @@
|
|||
#!/usr/bin/env bash
|
||||
# The shadow runner's read-only line to a node: an ssh tunnel from the shadow box to the node's loopback RPC (the EVM
|
||||
# JSON-RPC on 26790, the gRPC on 26610), kept up by a loop, killed by its pid file (never by a name). Mission item 9,
|
||||
# 7 October 2026. Usage: tools/shadow/tunnel.sh start <key> <port> <user@host> [pidfile] | stop [pidfile] | status [pidfile]
|
||||
set -uo pipefail
|
||||
PID="${5:-/root/sp-shadow/tunnel.pid}"
|
||||
case "${1:-}" in
|
||||
start)
|
||||
KEY="$2"; PORT="$3"; TARGET="$4"
|
||||
if [ -f "$PID" ] && kill -0 "$(cat "$PID")" 2>/dev/null; then echo "tunnel already up (pid $(cat "$PID"))"; exit 0; fi
|
||||
nohup bash -c "while true; do ssh -i '$KEY' -o StrictHostKeyChecking=accept-new -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -o ExitOnForwardFailure=yes -N -L 26790:127.0.0.1:26790 -L 26610:127.0.0.1:26610 -p '$PORT' '$TARGET'; sleep 5; done" > "${PID%.pid}.log" 2>&1 &
|
||||
echo $! > "$PID"
|
||||
sleep 3
|
||||
if curl -s --max-time 5 -H 'content-type: application/json' 127.0.0.1:26790 -d '{"jsonrpc":"2.0","id":1,"method":"eth_blockNumber","params":[]}' | grep -q result; then echo "tunnel up (pid $(cat "$PID"))"; else echo "tunnel started (pid $(cat "$PID")) but 26790 does not answer yet"; fi ;;
|
||||
stop)
|
||||
if [ -f "$PID" ]; then p="$(cat "$PID")"; pkill -P "$p" 2>/dev/null; kill "$p" 2>/dev/null; rm -f "$PID"; echo "tunnel stopped"; else echo "no pid file"; fi ;;
|
||||
status)
|
||||
if [ -f "$PID" ] && kill -0 "$(cat "$PID")" 2>/dev/null; then printf 'loop pid %s; ' "$(cat "$PID")"; curl -s --max-time 5 -H 'content-type: application/json' 127.0.0.1:26790 -d '{"jsonrpc":"2.0","id":1,"method":"eth_blockNumber","params":[]}'; echo; else echo "down"; fi ;;
|
||||
*) echo "usage: tunnel.sh start <key> <port> <user@host> [pidfile] | stop [pidfile] | status [pidfile]"; exit 2 ;;
|
||||
esac
|
||||
Loading…
Reference in a new issue