igneum/docs/spec/proving-enforcement.md

20 KiB

Enforced proving: a valid proof as a condition of payment in consensus

8 October 2026, the enforced-proving lane, branch enforced-proving (main repository) and enforced-proving-node (the fork, on the 0.3.25 node line at acaf08b0, which carries the hotfix pair f8da7515). Ordered after an external review the founder accepted (16:1x UK): ledger P21 says consensus validates a proof record's statement against native execution but does not require the SP1 proof itself to verify, so a modified producer can include an unverified record and cause a proving payout without the proof work. This document is the rule, the switch, the tests, the cost, the activation plan and the P22 staging statement. Every figure carries its label: measured (a box run with its RESULT line), stated (code read), or owed (with the clock time it is due).

1. The rule

A carried proof record pays only when the paying node has verified the record's SP1 proof itself. Two places hold it, the same floor for both:

  1. The body rule (consensus/src/pipeline/body_processor/body_validation_in_context.rs, check_carried_proofs, the 0.3.16 rule): from the floor, every shard record (IGNP) and segment record (IGNS) a block's coinbase carries must come with proof bytes the node holds that verify for the record's statement under the pinned program id. A proof that does not verify, or that names another guest, makes the block invalid (IgneumInvalidProofRecord); proof bytes not held are IgneumProofMissing, retried once after a 20-second fetch from the sending peer and then dropped unmarked (protocol/flows/src/v10/blockrelay/flow.rs), so the block never enters the DAG.
  2. The payment rule (igneum/exec/src/proving.rs, carried_payouts and carried_segment_payouts): from the floor, a record passes every check of spec 07 7.7 item 3 as before (chain block, window, plan, signature under the network name, assignee, the native-execution veto, first per shard) and then one more: this node's verifier holds a VERIFIED verdict for its proof (ProofPool::verified_for_payment: the cached verdict of the body rule or of the relay-time verifier, else a verify now when the bytes are held and the keys exist). Without it the record is carried with rejected = "proof not verified by this node (enforced proving): no payment" and pays nothing, and nothing is marked paid, so the honest proof of the same shard can still pay.

Why both: the body rule stops a block carrying a bad proof from entering any honest node's DAG; the payment rule makes the money condition explicit on the node that computes the state, so a node whose body rule is off (the harness attacker, IGNEUM_TEST_SKIP_PROOF_RULE=1) still never pays an unverified record. The native-execution veto stays underneath: no record moves state or pays for a wrong claim whatever its proof.

Stated, not new: the verifier (igneum/exec/src/nativeverify.rs) is SP1's light verifier in-process on Unix (the shard and aggregator verifying keys embedded from proving/igneum-prove/elf), through the installed igneum-prove-host on Windows. Its verdicts are cached by proof hash, so a proof verified at relay time costs the body rule nothing, and the payment rule nothing again.

2. The switch

Params::verifier_in_consensus: bool (consensus/core/src/config/params.rs), with OverrideParams::verifier_in_consensus: Option<bool> for the override file (infra/fast-time/override-60x.json lists it, false). The one accessor every reader uses is Params::proof_rule_active_from():

Object verifier_in_consensus proving_consensus_verify_daa proof_rule_active_from() Meaning
Devnet 3 (the live object, compiled, no file) false never never the finding's shape: v0, every producer verifies off the consensus path; unchanged by this rollout
igneum-testnet-1 (the 0.3.25 object) false 0 0 the rule has held from the testnet's block zero under the two pinned program ids (stated, TESTNET_PARAMS); the switch adds nothing there
mainnet, simnet, devnet defaults false never never off
the class v6 object (section 5) true never 0 on from block zero under the ids the object pins, or the binary's embedded ids when it pins none
the tests true (or a floor) as the test says 0 or the floor on

What the switch changes, exactly: the body processor reads proof_rule_active_from() instead of the DAA field; the daemon installs the proof oracle with that floor and refuses to start with the rule set when the binary's embedded keys are not the object's pinned ids (an object that sets the switch and pins no ids pins the ids its release embeds, and the start line says so); the executor's ProvingConfig::verified_payout_from takes the same floor for the payment rule. In the digest once set (one field, the 0.3.15 rule for new fields), so no live digest moves on the rollout and a node with the switch on never peers with one that lets an unverified record pay. Unit test: kaspa_consensus_core::config::params::tests::the_verifier_switch_is_off_on_every_compiled_object_and_on_from_genesis_when_set.

3. The tests, known-failed first

The harness producer is a modified producer: it signs the CORRECT native statement for the shard and payout every time (the shape the veto cannot catch) and tries the seven refusals. The validator is an ordinary unmodified node. Each refusal is a named test with its assertion.

Consensus side (consensus/src/pipeline/body_processor/proving_enforcement_tests.rs, a TestConsensus on the devnet object with the switch on, a test oracle standing in for the executor's verifier, one verdict per proof hash):

Refusal Test Assertion
(a) no proof enforced_a_record_with_no_proof_held_is_missing_and_the_block_is_not_inserted Err(IgneumProofMissing): the block is never inserted; the relay drops it after the 20-s fetch
(b) a wrong proof enforced_a_record_whose_proof_fails_to_verify_invalidates_the_block Err(IgneumInvalidProofRecord) carrying "does not verify"
(c) a proof for another program id enforced_a_proof_for_another_program_id_invalidates_the_block Err(IgneumInvalidProofRecord) carrying "pinned id"
honest enforced_a_verified_record_is_accepted_by_the_body_rule Ok; the payment (once) is the executor's test below
the finding enforced_without_the_switch_and_with_the_floor_at_never_the_fake_record_is_accepted_the_p21_finding the live object's shape: the wrong proof passes the body rule. Kept as the named known-failed shape
the boundary enforced_the_floor_is_a_boundary_below_it_the_fake_record_passes_at_it_the_block_is_invalid a floor of 1,000 lets the genesis child (DAA 1) pass with a wrong proof; a floor of 0 refuses it

Execution side (igneum/exec/src/proving.rs, tests::enforced_*, the 30-block chain of the existing proving tests, block 21 one shard, the carrier at DAA 105 past the activation at 100, verified_payout_from 0):

Refusal Test Assertion
(a) no proof, (b) a wrong proof, at payment enforced_an_unverified_proof_pays_nothing_and_the_verified_honest_record_pays_once with the verifier answering false: no payout, rejected says "proof not verified", nothing marked paid; with the verifier answering true for the honest proof hash and statement: the shard's 1,000,000 wei paid once, the same record carried again is "shard already paid"
(d) a replayed record enforced_a_replayed_record_pays_nothing_the_second_time paid once at carrier 25; carried again at carrier 26: no payout, "shard already paid", one entry in the paid map
(e) a wrong network id enforced_a_record_signed_for_another_network_pays_nothing signed under igneum-devnet-951: "bad signature", no payout
(f) an altered payout address enforced_an_altered_payout_address_pays_nothing the address changed after signing: "bad signature"; re-signed for another address: the native-execution veto (the statement binds the payout); no payout either way
(g) a duplicate of a paid record enforced_a_duplicate_of_a_paid_record_by_another_key_pays_nothing prover-b's own valid record for the shard prover-a was paid for: "shard already paid", one payout
the finding, the floor enforced_below_the_floor_an_unverified_record_still_pays_the_v0_shape floor never: paid without a verified proof (the finding); one below the floor: paid; at the floor: no payment
the verdict source enforced_the_pool_answers_verified_only_for_a_cached_ok_verdict no verdict: false; a refused verdict: false; a VERIFIED verdict: true

Known-failed first: the two tests that encode the new condition (the "pays nothing without a verified proof" test and the floor test) fail on the tree before the verified_payout_from condition, and the consensus tests under verifier_in_consensus = true do not compile before the switch exists. The refusals (d), (e), (f) and (g) were already held by the signature (domain-separated with the network name and binding the payout address), the native veto and the paid map; their tests pass on the old tree too, which is stated here so nobody reads them as new protection. What is new is (a), (b) and (c) at payment on every object, and the named switch.

Results: section 6.

3a. The plan's six negative tests, mapped

The Igneum 2.0 plan (p. 16) names six negative tests every validator must pass. The map, each with its test name and the tree it is green on (the 0.3.25 node line at 019a12b1, on release-2.0.0-node as dd84ed6a and f6cd2f00; the proving-payment branch eaddbf83 for the last row):

Plan test Covered by Where State
(1) a correct statement with an invalid proof: rejected, no reward enforced_a_record_whose_proof_fails_to_verify_invalidates_the_block (the block refused); enforced_an_unverified_proof_pays_nothing_and_the_verified_honest_record_pays_once (no payment, the honest proof pays once) consensus; executor green 16:15 UK
(2) a wrong program or verifier identity under the pinned release configuration enforced_a_proof_for_another_program_id_invalidates_the_block (the verdict); nativeverify::tests::a_real_proof_verifies_and_a_wrong_statement_is_refused (a real proof under the other pinned key: "pinned id"); the_embedded_keys_match_the_manifest (the release's keys are the manifest's); the daemon's start refusal when the embedded keys are not the object's pinned ids (exit 3, a process check, not a unit test) consensus; executor; the daemon green 16:15 UK; the start refusal stated
(3) a wrong chain, epoch or statement binding, replayed or misbound work enforced_a_record_signed_for_another_network_pays_nothing (the chain); enforced_a_replayed_record_pays_nothing_the_second_time (replay); assignment_follows_the_window_and_records_check_against_native_execution (a wrong block, a wrong shard, a stale record outside the window, a wrong statement); the epoch binds through the sortition's epoch seed and the chain block in the statement executor green 16:15 UK
(4) a changed payout identity enforced_an_altered_payout_address_pays_nothing (altered after signing: the signature; re-signed: the statement binds the payout) executor green 16:15 UK
(5) a duplicate proof reward: exactly the permitted payment outcome enforced_a_duplicate_of_a_paid_record_by_another_key_pays_nothing; the honest record paid once in (1)'s test executor green 16:15 UK
(6) incorrect rewards or consensus inputs: derivation authenticated, not only execution over supplied inputs enforced_a_statement_over_altered_rewards_or_payouts_is_vetoed_native_derivation_is_the_check (a statement whose post-root came from execution over other rewards or payouts is not the native statement and pays nothing: the native veto, every node's own derivation) executor (proving-payment branch, on build-2 at 17:06 UK) PENDING as the plan means it: the derivation is authenticated by every node's own execution, not inside the proof; the proof-side closure is P22's stages 1 to 3 (section 7), phase 2

The boundary sentence of the plan, carried in every served text that names the rule: a proof of execution is not a proof of authenticated consensus inputs, canonical history or data availability. What the rule proves today is that the carried record's statement is the native statement of this node's own execution and that an SP1 proof of that statement verifies; what consensus inputs the execution used, which history is canonical and whether the data is available are each the node's own reading, not the proof's.

4. The cost

The verifier's time per record on the node, measured on build-2 under the lease tool (section 6 carries the RESULT lines). The budget per block: at most 8 shard records and 2 segment records per block (MAX_RECORDS_PER_BLOCK, MAX_SEGMENT_RECORDS_PER_BLOCK), verified in parallel on the body processor's thread pool, each verdict cached by proof hash, so a proof verified at relay time costs the block nothing. Measured (section 6, build-2, one EPYC 9454P core at nice 19 under the lease tool, the box loaded): 0.668 to 0.710 s per record and about 30 MB per concurrent verify. What the switch costs a block, from those figures: nothing for a block that carries no records; nothing for a proof the relay verified before its block (the cached verdict); the worst case is a block whose ten proofs all arrive cold with it, about 0.7 s wall on ten cores of the body processor's pool (7 s of CPU) and about 300 MB peak. At the hold's rate of one block a second, a producer that fills every block with cold proofs costs a validator 0.7 s of wall per block on ten cores, which the pool absorbs; a validator with fewer cores serialises the ten verifies (7 s a block) and falls behind, which is why the relay-time verify is the design's budget and the block-time verify its backstop. Per tier: every node class holds the 300 MB (8 GB rig, 12 and 16 GB card nodes, pool nodes); a Windows node verifies through igneum-prove-host and the daemon refuses to start with the rule set and no host. The 10 ms gate of the overview is the hash's per-warp CPU-verify gate, not this check's: an SP1 compressed proof verify is 70x it, a different class of check, and the cache above is what keeps it off the block's critical path.

5. Activation

  1. The live Devnet 3 object does not move: the switch is false, the floor never, the digest unchanged, and the tests say so.
  2. igneum-testnet-1 already runs the body rule from block zero under its two pinned ids; this rollout adds the payment rule on the same floor (0), which changes nothing a testnet node pays (every carried record on the testnet has passed the body rule), and is the first live network under the full condition.
  3. The class v6 object (docs/design/class-v6-rotating-family.md, no consensus code yet) carries verifier_in_consensus: true: on from its block zero. Until that object exists, a network that wants the rule before class v6 sets its own floor through proving_consensus_verify_daa in the override file, the way the 0.3.16 rule was designed to land, one weight window above the rollout so every node runs the binary first.
  4. Every node must run a binary whose embedded keys are the object's pinned ids before the floor; the daemon refuses to start otherwise.
  5. Shipping: consensus code on the release line the shipper names (release-0.3.26 is the hotfix pair; the next node line opens as release-0.3.27); the main-repository branch lands on the box mirror master through the gate on the coordinator's word.

6. Results and measurements

Filled from the box runs as they land; each line names the box, the command class and the time.

Time (UK) Box What Result
16:08 build-2, suite class, 12 threads, nice 10 (the bounded pool held 13 free cores; the 24-thread ask waited) cargo test --release -p igneum-exec --lib enforced_ 7 passed, 0 failed (the seven executor tests of section 3), 229 s wall with the compile
16:11 build-2, same class cargo test --release -p kaspa-consensus-core --lib the_verifier_switch 1 passed (the switch is off on every compiled object; Devnet 3 at never; the testnet floor 0; the digest moves once set; a file that omits it changes nothing)
16:15 build-2, suite class, 12 threads cargo test --release -p kaspa-consensus-core -p igneum-exec -p kaspa-consensus --lib (the full lib suites on the branch; the first consensus build stopped on a missing ConsensusApi import at 16:11, fixed at 16:12) igneum-exec 64 passed 0 failed; kaspa-consensus 138 passed 0 failed, 3 ignored (the six proving_enforcement_tests::enforced_* among them); kaspa-consensus-core 171 passed 0 failed, 4 ignored; 172 s wall with the compile
16:34 build-2 (AMD EPYC 9454P, 96 threads, 125 GB), lease pool 1 --nice 19, one core, the box at load 85 to 95 the verify cost per record: nativeverify::tests::a_real_proof_verifies_and_a_wrong_statement_is_refused on a real compressed shard proof of the testnet join pass (build-1 /srv/builds/tn-join-pass/prover/block-763-shard-0-compressed.bin, 1,272,897 bytes), seven runs; /usr/bin/time -v for the memory measured: 0.710, 0.683, 0.701, 0.671, 0.700, 0.687, 0.668 s one core (0.668 to 0.710 s, a loaded-box figure); peak resident 34.6 MB with the verify against 4.6 MB for the same binary without it, so about 30 MB per concurrent verify

7. The staging statement for P22

P22: the rewards and the prover payouts are inputs to the shard proof, not outputs. The shard guest takes the segment's rewards and payouts as data and commits the post-root after them; a host can feed any list and the proof still verifies. Today the node's own derivation is the check: the statement must equal the node's native statement for that shard and payout, which used the rewards and payouts consensus derived, so a proof over another list matches no node's statement and pays nothing.

The closure is staged. Each stage is a claim about one input and the check that holds it; no stage claims a self-contained proof of the whole state.

Stage What becomes an output of a proof What checks it until then Status
0 (today) nothing: rewards and payouts are data in the shard statement (BlockFixture.rewards, payouts) every node's native execution derives both and vetoes a statement that differs; enforced proving (this document) adds that the record's proof must verify for that statement implemented
1 the shard proof's rewards list is checked against a commitment the aggregator carries in its public values (the mergeset's blue blocks and their subsidies, hashed) the aggregator's commitment is itself data; every node recomputes it from the mergeset it holds and vetoes a segment statement whose commitment differs (spec 7.8 item 5, the native block statement) next: the consensus-proof work of design 7, phase 2
2 the payouts list is derived inside the aggregator guest from the carried records it verifies (the first valid record per shard, the pool credit split) the node's carried_payouts is the native check of the same derivation; a segment statement whose payouts differ is vetoed phase 2, after stage 1
3 the rewards are derived inside the aggregator guest from consensus data it verifies (headers, blue sets) the node's own derivation vetoes; this is the consensus proof proper, and only here do rewards stop being an input anywhere phase 2, the last step

Until stage 3 lands, every stage's output is checked natively by every node, and the statement "the rewards and payouts are proven" is not made in any served text. The ledger entry P22 carries this table.

8. The fast-time harness case

infra/fast-time/proving-enforcement.mjs (section 6 says whether it ran): three nodes on one fast-time network, two honest under the floor set a few epochs ahead (proving_consensus_verify_daa in the override, the boundary), one attacker with the body rule off and the verifier in trust mode. The attacker reads each shard's native statement for its own payout address from its node (igneum_getShardPlan(block, payout)), signs it (igneum-miner sign-record) and submits it with proof bytes of the seven shapes through igneum_submitProofRecord; its templates carry the records. Below the boundary the honest nodes accept the attacker's blocks and the v0 rule pays (the finding, observed); from the boundary every honest node refuses the carrying block (IgneumInvalidProofRecord or the 20-s drop) and igneum_getProofRecords shows no paid entry for any of the seven. The honest-pays-once case needs a real shard proof of the harness's own chain, which a CPU prover makes in minutes; the unit tests hold it meanwhile and the testnet holds it live.