Prover Guide

Shell-Chain supports asynchronous STARK proof generation. Blocks are produced and broadcast immediately (native Dilithium3 signature verification), and a ProofAmendment is attached later by a prover node. This lets validators stay responsive without waiting for expensive proof generation.


Table of Contents


Node Roles

Shell-Chain defines three operational roles:

Role CLI value Block production Generates proofs Description
Validator validator (default) ✅ ❌ Standard authority node. Signs and proposes blocks. Does not run the prover.
ValidatorProver validator-prover ✅ ✅ (idle slots) Authority node that also runs the prover service during idle (non-proposing) slots. Useful for small networks where each validator can contribute proof work.
Prover prover ❌ ✅ (full time) Standalone prover — syncs the chain, generates proofs for every incoming block, broadcasts ProofAmendment via P2P. No block-signing key required.

Set the role via TOML or CLI:

# config.toml
[node]
node_role = "prover"
shell-node run --node-role prover --config config.toml

How Proving Works

Block N sealed by validator
        │
        ▼
Broadcast to network (contains Dilithium3 sigs in WitnessBundle)
        │
        ├──► Peers: verify natively with TxWitness signatures (instant)
        │
        └──► Prover node receives block
                  │
                  ▼
            prove_sig_batch(entries) → SigBatchProof   ← Winterfell STARK
                  │
                  ▼
            Wrap in ProofAmendment { block_hash, block_number, proof, prover, prover_sig }
                  │
                  ▼
            P2P broadcast → peers store at pa/<block_hash>
                  │
                  ▼
            (if proof_replacement_grace == 0)  delete w/<block_hash>
                      ← WitnessBundle freed (~4 KB/tx saved)

ProverRegistry

The native ProverRegistry stores governance-approved proof signing identities in canonical chain state, separately from validator membership. Registration grants no block-production or consensus-voting rights.

Version and activation: this interface is an unreleased source feature. It requires a node build containing shell_proposeRegisterProver and an explicitly configured prover_registry_height. The default is disabled. Existing databases accept only a future activation height, and a saved schedule cannot be changed. All peers must use the same schedule; snapshots must match trusted configuration. Do not set height zero on an existing network. Before activation, historical proof admission and settlement rules remain unchanged. At and after activation, proof admission and settlement require a registered prover, including proofs from validator-prover nodes.

Registering a prover

First generate the proof signing key as shown below. Submit only its public key and algorithm ID to a funded validator's authenticated governance RPC. Each validator votes through its own node; registration completes when votes represent strictly more than half of the current Registry weight. Repeating a vote does not add weight. No prior transaction from the prover account is required.

For the Dilithium3 key generated in this guide (algorithm = 0), with jq and curl installed:

PROVER_PUBLIC_KEY=$(jq -r '.public_key' /data/prover-keystore.json)
PROVER_ADDRESS=$(jq -r '.address' /data/prover-keystore.json)
VALIDATOR_RPC=http://127.0.0.1:8545
REQUEST=$(jq -nc --arg key "$PROVER_PUBLIC_KEY" \
  '{jsonrpc:"2.0",id:1,method:"shell_proposeRegisterProver",params:[$key,0]}')
curl -sS -H 'Content-Type: application/json' --data "$REQUEST" "$VALIDATOR_RPC"

The result is a transaction hash. Query eth_getTransactionReceipt with that hash and require status: "0x1". A successful vote receipt alone does not prove quorum: query the record and wait for its registered_at block to be finalized (eth_getBlockByNumber with ["finalized", false]) before starting the prover.

REQUEST=$(jq -nc --arg address "$PROVER_ADDRESS" \
  '{jsonrpc:"2.0",id:2,method:"shell_getRegisteredProver",params:[$address]}')
curl -sS -H 'Content-Type: application/json' --data "$REQUEST" "$VALIDATOR_RPC"

The query returns null until registration is committed. Match pubkey and algorithm to your key and check that shell_getValidators is unchanged. ML-DSA-65 uses algorithm ID 1; SPHINCS+-SHA2-256f uses 2. Addresses are derived as blake3(algo_id || pubkey), rendered as 0x plus 64 lowercase hex characters. Malformed keys, unsupported algorithms, non-validator callers, and duplicate registrations are rejected. shell_proposeAddValidator changes consensus membership and is not the prover-registration operation.

When the validator RPC listener is non-loopback, the node must be configured with --rpc-api-key; add Authorization: Bearer <key> to the requests above. Keep the prover's encrypted key and password on the prover machine.

ProverRecord fields

Field Description
address Prover's full 32-byte address
pubkey Public key bound to this proof signing identity
algorithm Signature algorithm ID: 0, 1, or 2
registered_at Block height where governance reached quorum
proofs_submitted Successfully executed proof settlements since registration
last_proof_block Highest source block covered by those settlements

Receiving or storing a proof does not increment the count. Settlement updates share the block's state root, roll back with rejected blocks or failed execution, and persist through restart and snapshot recovery. Import and replay use the registry state of the block being executed.


Running a Prover Node

A prover node does not need a validator identity. It uses a separate prover keystore to sign proof amendments and does not propose or vote on blocks. It needs:

  1. A PQ key for signing ProofAmendment messages
  2. Registration in ProverRegistry
  3. Access to a synced peer (or full P2P participation)

Step 1: Generate a prover key

shell-node key generate --algorithm dilithium3 --output /data/prover-keystore.json
# Enter passphrase when prompted

Step 2: Config

# prover.toml
[node]
datadir = "/data/prover"
chain_id = 31337
node_role = "prover"
keystore = "/data/prover-keystore.json"  # proof signing only; not a validator key

[rpc]
listen_addr = "127.0.0.1:8545"  # optional — prover nodes rarely need RPC

[p2p]
enabled = true
listen_addr = "0.0.0.0:30303"
bootnodes = ["/ip4/VALIDATOR_IP/tcp/30303/p2p/QmVALIDATOR..."]

[consensus]
enable_stark_aggregation = true

[prover]
max_concurrent_proofs = 2         # default 1; increase for multi-core machines
proving_priority = "sequential"   # "sequential" or "latest-first"

[metrics]
enabled = true
listen_addr = "0.0.0.0:9090"

Step 3: Start

shell-node run --config prover.toml

Enter the passphrase for the prover keystore generated in Step 1. The [node].keystore setting selects the proof-signing identity; generating a key alone does not configure it. If omitted, the node creates or loads its development identity instead, which has a different address from the generated prover key. Keep node_role = "prover" so loading the key does not enable block production.


Running a ValidatorProver Node

A validator-prover node runs both roles. The prover service starts automatically during slots where this node is not the block proposer.

[node]
node_role = "validator-prover"
keystore = "/data/keystore.json"

[consensus]
enable_stark_aggregation = true

[prover]
max_concurrent_proofs = 1       # conservative on validator hardware
proving_priority = "sequential"

Tip: On dedicated proving hardware, set max_concurrent_proofs to the number of physical CPU cores. The Winterfell STARK prover is CPU-bound and benefits from parallelism.


Configuration Reference

[node]
node_role = "validator"       # "validator" | "validator-prover" | "prover"

[consensus]
enable_stark_aggregation = false  # set true to enable ProofAmendment flow

[prover]
max_concurrent_proofs = 1         # parallel proof jobs
proving_priority = "sequential"   # "sequential" (oldest first) | "latest-first"

[pruning]
proof_replacement_grace = 0       # blocks to keep WitnessBundle after proof arrival
                                  # 0 = delete immediately (default, recommended)

max_concurrent_proofs

Set a positive integer (default 1). Zero, negative and non-integer values are rejected at configuration load. On builds containing the concurrent prover implementation, this bounds active CPU proof jobs and completed jobs awaiting ordered delivery. A later range may finish first, but persistence and event-loop handoff remain in canonical source order. Source reservations stay active until handoff is acknowledged. Graceful shutdown stops admission and drains active proofs to storage for recovery without waiting for a full handoff channel.

This is an unreleased implementation change; verify the node build before assuming that an older binary honors this setting.

proving_priority

Value Behaviour
sequential Prove blocks in ascending order. Preferred for archival integrity.
latest-first Compute newer eligible ranges first within a bounded contiguous window. Results are persisted and submitted in source order.

The latest-first scheduler reserves up to twice max_concurrent_proofs eligible proof ranges from the canonical frontier. At most max_concurrent_proofs CPU workers take ranges from the newest end of that window. The entire window drains before more work is admitted, so new arrivals cannot indefinitely postpone older reserved ranges. Gaps and below-threshold ranges remain queued under the same strict settlement rules. Even with one worker, newer ranges in the window are computed first; proof publication still waits for their predecessors.

Priority scheduling is an unreleased implementation change. Older binaries may parse the setting without changing the actual proving order.


ProofAmendment Lifecycle

prove_sig_batch()
    │
    └─► ProofAmendment {
          version: 1,
          block_hash,
          block_number,
          proof: SigBatchProof {
            batch_root_bytes: [u8; 16],  // final accumulator (STARK public output)
            n_sigs: usize,               // number of signatures covered
            proof_bytes: Vec<u8>,        // raw Winterfell STARK proof
          },
          prover: Address,
          prover_signature: Bytes,       // PQ sig over sha3(b"proof-amendment" ‖ block_hash ‖ block_number ‖ batch_root)
        }

On receipt, the network:

  1. Checks prover is in ProverRegistry
  2. Verifies prover_signature with the PQ public key bound to the registered address
  3. Verifies the SigBatchProof (Winterfell STARK verification)
  4. Stores at pa/<block_hash>
  5. Deletes w/<block_hash> (after grace window)

Monitoring

Scrape the configured metrics listener (port 9090 by default):

curl --fail --silent http://127.0.0.1:9090/metrics

Use these exact Prometheus series names:

# Locally generated L1 proofs delivered to the node event loop (process lifetime)
shell_stark_proofs_generated_total
# CPU proof-attempt time, excluding queueing and ordered publication waits
shell_stark_proof_duration_seconds_count
shell_stark_proof_duration_seconds_sum
shell_stark_proof_duration_seconds_bucket{le="1"}
# Failed proof jobs; they do not increment the successful-proof counter
shell_stark_proof_failures_total
# Queued proof tasks, including below-threshold work; excludes reserved jobs
shell_stark_backlog_depth
# Generated proofs awaiting canonical settlement
shell_stark_pending_settlements
# Authenticated amendments rejected by admission limits
shell_stark_amendments_rate_limited_total

# Cached storage byte estimates, refreshed every 300 seconds
shell_storage_cf_size_bytes{cf="witness"}
shell_storage_cf_size_bytes{cf="proof"}

# Local production accepts settlements; imported settlements do not increment this counter
shell_stark_settlements_accepted_total
# Amendments rejected during validation
shell_stark_settlements_rejected_total
# Canonical source blocks not yet settled at L1
shell_stark_frontier_lag

Counters and histogram samples reset when the process restarts; durable prover registration counts are queried separately with shell_getRegisteredProver. A generated proof is not necessarily accepted into the chain: check its settlement transaction receipt and the canonical prover record as well. The duration histogram records successful and failed CPU attempts. Waiting for enough entries or a missing source does not produce a duration sample or a proof failure.

Storage estimates include keys and values under the witness and proof prefixes. Proof size varies with the source range and encoding; it is not a fixed number of bytes per block. Witness retention and the settled frontier govern pruning, so a nonzero witness estimate alone does not indicate a failure.

The generation-duration, failure-count and live-backlog updates are an unreleased implementation change. Older binaries expose these series but may leave them at zero. Previous guide examples using shell_prover_* or unprefixed settlement names did not match the exported series.

Proof generation remains paused while the node is synchronizing or otherwise fails its readiness gate. A prover keeps at most two generated amendments in flight until canonical settlement releases capacity. Sustained growth in shell_stark_amendments_rate_limited_total, especially together with a stalled chain head, indicates peer pressure or a settlement-path failure and should be investigated before increasing any limits.

Durable Settlement Index (v0.22+)

Shell-Chain v0.22.0 adds a persistent SettledSourceIndex that stores accepted (layer, source_hash) pairs in the KV store (prefix ss/). This replaces the previous in-memory rebuild that required a full chain scan on every restart:

  • Fast-path startup: index is loaded directly — typically instant.
  • Slow-path / repair: if the index is empty (first run after upgrade or corruption), the node scans the chain once and backfills the index automatically.
  • Durable rejection: duplicate STARK settlements are correctly rejected even after a restart or crash.

No operator action is required — the index is built transparently on first startup after upgrading to v0.22.0.


Proof Challenge Mechanism

If a node receives a ProofAmendment it cannot verify, it may broadcast a ProofChallenge to the network:

The following Rust example constructs the message using the source API. In a shell-chain source checkout, save it as crates/consensus/examples/challenge-message.rs (create examples if needed), then run cargo run -p shell-consensus --example challenge-message. It prints the serialized message; constructing it does not broadcast a challenge or prove that a node accepts it.

use shell_consensus::{ChallengeReason, ProofChallenge};
use shell_primitives::{Address, ShellHash};

fn main() {
    let challenge = ProofChallenge::new(
        ShellHash::from([0x11; 32]), // replace with the challenged block hash
        42,                       // that block's number
        ChallengeReason::VerificationFailed,
        Address::from([0x22; 32]), // replace with the challenger's address
        1,                        // sequence supplied by the sender
    );
    println!("{}", serde_json::to_string(&challenge).unwrap());
}

The message includes block_hash, block_number, reason, challenger and sequence. Supported reasons are VerificationFailed, BlockNotFound, MalformedPayload and StateRootMismatch. Batch-root, signature and registration checks are validation conditions, not additional ChallengeReason variants.

Any node holding the raw proof can respond with a ChallengeResponse carrying the proof bytes, allowing the challenger to retry verification. Challenges are rate-limited per-peer to prevent DoS. A prover that accumulates failed challenges may be removed from ProverRegistry.

Incoming challenges use a separate token bucket per network peer: ten initial requests, refilling five tokens every 60 seconds up to the initial capacity. Changing the claimed challenger address does not create another allowance. Excess requests are dropped before tracking the challenge or reading and sending proof data; they do not consume the prover's settlement-submission allowance. Idle peer buckets expire after 600 seconds. These limits reset on process restart. This receive-side enforcement is an unreleased implementation change; older binaries do not apply this challenge limit.

On builds containing the challenge recovery fix, restarting with the same node database preserves open challenges, their original opening heights and resolved or timed-out states. The timeout remains 7200 blocks from the original opening height. Timeout status and its consensus penalty are saved together; failed writes leave both unchanged. Recovery restores the saved penalty without applying another percentage reduction, including after terminal records are collected. Later equivocation penalties update the same snapshot. Head rewinds do not reopen resolved challenges or reset their deadlines. A malformed snapshot stops network startup instead of silently discarding challenge history.

This recovery behavior is unreleased. Verify that the node build contains the fix before relying on it; upgrading cannot recover records already lost by an older process. It is separate from the per-peer admission buckets above, which still reset on restart. Process-exit/reopen testing covers database WAL recovery, not machine power-loss durability.

Challenge responses carry a serialized ProofAmendment, including the full STARK proof and the prover's authenticated signature envelope. The receiver checks the signature and its binding to the prover address before validating the source range or writing proof artifacts. When prover_registry_height is active at the next block height, that prover must also be registered. A relay may forward another prover's signed amendment; the claimed responder address is not a substitute for proof authentication. Rejected responses leave the challenge open and do not write any proof-range artifacts. A valid response for an unsettled range stores the proof and resolves the challenge; this alone does not settle a reward or increment registration counts. This response-admission correction is unreleased; verify the node build before relying on this behavior in an older binary.

For a challenge to an earlier block in a stored proof range, a prover resolves the local proof pointer and returns the complete signed amendment for that range. The response retains the challenged block hash; the receiver checks that the amendment covers that hash before authentication and source verification. Missing or inconsistent pointer targets do not produce a response. This range-response correction is also unreleased; the stored, unsettled range flow is covered by cargo test -p shell-node event_loop_authenticates_challenge_responses_before_storage.

For an already settled range, a response still requires prover admission, canonical range checks, and proof verification. When every covered source is already settled at that layer, a valid response resolves the challenge without rewriting proof artifacts, settlement links, or rewards. New or partially settled ranges retain the normal settlement-order checks. This historical-response correction is unreleased. If L1 witnesses have been pruned, the response's settlement transaction link identifies the retained canonical StarkReward payload. The node checks the containing block is still canonical and matches the source range, original size, entry count, and batch root, then independently verifies the received proof. Missing or noncanonical settlement links cannot replace missing witnesses. This path does not rewrite settlement data or award another reward. The regression above covers unpruned and pruned L1 ranges with registry activation disabled; its shortened retention window is a test condition. L2 responses and registry activation changes require separate verification.