23 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:
- 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 areIgneumProofMissing, 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. - The payment rule (
igneum/exec/src/proving.rs,carried_payoutsandcarried_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 withrejected = "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, branch proving-payment of the fork at 421bb852 (on release-2.0.0-node's c04674fe), igneum-exec 67 passed on build-2 at 17:10 UK | team-tested for what the test holds (the derivation authenticated by every node's own execution: a statement over other rewards or payouts pays nothing); PENDING for the proof side, the register row "Negative test six, proof side: derivation inside the aggregator guest (P22 stage 3)", clock 18:00 UK on 9 October 2026, when its served label moves from PENDING to team-tested on its own test (section 7, stages 1 to 3) |
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
- The live Devnet 3 object does not move: the switch is false, the floor never, the digest unchanged, and the tests say so.
- 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.
- The class v6 object (
docs/design/class-v6-rotating-family.md, no consensus code yet) carriesverifier_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 throughproving_consensus_verify_daain 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. - Every node must run a binary whose embedded keys are the object's pinned ids before the floor; the daemon refuses to start otherwise.
- 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 |
| 17:22 | build-2, tools/fast-time-remote.sh (normal class), three local nodes, one CPU mining thread each |
infra/fast-time/proving-enforcement.mjs --floor 240 --before 90 --after 150 --real-proof <the testnet shard proof> (the fifth attempt; the first four were the harness's own faults: a bare boolean in the override file, the block tag's hex form, the forged block inside the exclusive window, a dead ssh leaving three nodes on the box) |
RESULT PASS. Below the floor (A at DAA 118, block 101): A's trusting pool took shapes a, b, c, d and g and its templates carried them; A's own unmodified pool refused e (bad signature) and f (the native-execution veto), the same checks every honest node runs; H1 paid the first carried record, shape c (the real proof of another chain under A's statement), 0x8cc611991c3c400 wei to A's payout: ledger P21's finding, observed; the three records after it read "shard already paid" (shape g's duplicate among them). At the floor (A at DAA 247, block 229): the same five shapes carried by A; H1 paid nothing, carried nothing of A's (its blocks never entered H1's DAG), and H1 and H2 each logged four new REFUSED verdicts (3 to 7), one per carried record, in 0.000 to 0.003 s each (the cached verdict from the relay-time verify, the measured cold path above being the first verify). Result file on build-2: /home/build/enforced-fixtures/floor-240-expect-refuse.json |
| 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 (ran, PASS at 17:22 UK, section 6): 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.
Key succession mode (docs/design/key-succession.md, 8 October 2026, evening): --succession <H> --window <W> --next-ids <shard,aggregator> --next-proof <a real proof under the next pair> schedules the next pair at H on the harness's object; the honest nodes must embed both pairs. No prover runs on the harness chain, so the signal is the honest nodes' refusal reason: below H a next-pair proof is refused on its pair ("the carrier's epoch does not accept"), in the window on its statement (it proves another chain), after H+W a prior-pair proof is refused on its pair; the seven shapes pay nothing on any side. Three forges (below H, in the window, after H+W); PASS = those reasons in that order and nothing paid.