Skip to content

mining: getblocktemplate proposal mode validates like submitblock (#663) - #710

Open
bkeroack wants to merge 2 commits into
fix/661-670-dust-and-template-floorsfrom
fix/663-gbt-proposal-real-validation
Open

mining: getblocktemplate proposal mode validates like submitblock (#663)#710
bkeroack wants to merge 2 commits into
fix/661-670-dust-and-template-floorsfrom
fix/663-gbt-proposal-real-validation

Conversation

@bkeroack

@bkeroack bkeroack commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Closes #663.

Second in a four-PR stack, based on #709. Merge order: #709 → this → #662-tips → #660-bounds.

Proposal mode answered a different question from submitblock

BIP 22's proposal mode exists so a miner can ask "would you accept this block?" before spending hash power on it. Core answers it with TestBlockValidity, which ends in ConnectBlock(fJustCheck=true) against a throwaway coins view — the same code path a real submission takes, with the write discarded.

satd answered it with a second, hand-written loop. It said so itself:

Script verification is skipped — Core's TestBlockValidity runs full script verification, but proposal mode only needs to detect structural and contextual invalidity.

Along with script verification it had no BIP 68 sequence-lock check, no block sigop accounting and no BIP 30 test. A miner proposing a block with any of those was told it was fine and then had it rejected on submission.

The loop is gone. connect_block is pure — it takes &dyn Store, reads, and hands its caller a StoreBatch to write — so proposal mode calls it and drops the batch. One implementation of the rules, no way for the two answers to drift. It holds accept_lock for the duration, as Core holds cs_main: the tip must not move between the "builds on the tip" test and the connect.

Three smaller divergences went with it:

  • Duplicates. A block the node already knows is duplicate, duplicate-invalid or duplicate-inconclusive depending on what it decided last time (rpc/mining.cpp). satd reported inconclusive-not-best-prevblk for all of them, because a block already on the chain does not build on the tip.
  • mode and data. A present-but-non-string mode was read as "not proposal", so a caller asking for something satd did not understand was quietly handed a template. It is -8 Invalid mode now, and proposal mode with no string data is -3 (Core's RPC_TYPE_ERROR), not -8.
  • Internal errors are -25, Core's RPC_VERIFY_ERROR.

check_block ordering, and a missing sigop gate

Core's CheckBlock runs CheckMerkleRoot first, ahead of the size limits, because "all potential-corruption validation must be done before we do any transaction validation": a peer that sent the wrong transactions for a header must not cause that header to be marked invalid. satd tested size first, so a block both oversized and merkle-broken answered bad-blk-length where Core answers bad-txnmrklroot.

satd also had no equivalent of Core's legacy-sigop ceiling in CheckBlock — a cheap, context-free gate that fires before any prevout is resolved. It counted sigops only in connect_block, so a block over the ceiling that also spent nothing resolvable reported bad-txns-inputs-missingorspent.

Both are in feature_block_consensus.rs and in the live differential against bitcoind.

-blockversion

Core stamps it on the template header under MineBlocksOnDemand() — regtest only — which is how mining_basic.py sets up forking scenarios. satd listed the key among its known configuration options and never read it. Applied here on regtest alone; mining_basic.py still needs the testdummy versionbits entry before its row can flip.

Verification

Three cargo gates green. Perturbation-proved: the duplicate lookup, the accept_lock, writing the batch instead of dropping it, the merkle ordering, and the legacy-sigop gate — each by a named failing test.

New tests: proposal mode enforces BIP 68 and accept_block agrees; a proposal for a block on the chain is duplicate while an unknown off-tip block stays inconclusive-not-best-prevblk; a proposal moves neither the tip, nor the index, nor the UTXO set, and accepting the same block does; a proposal blocks on accept_lock; proposal mode and submitblock return the same verdict over RPC; mode and data codes; -blockversion.

🤖 Generated with Claude Code

https://claude.ai/code/session_011H5HUWaezLTYJCZDpgaioL

@bkeroack

bkeroack commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

Force-pushed a fix for the Lint failure.

The new test a_proposal_enforces_the_rules_the_hand_written_loop_skipped was inserted between prune_blocks_mutation_waits_for_the_accept_lock's doc comment and its #[test] attribute. The new test ended up with two #[test] attributes (clippy duplicate-macro-attributes) and prune_blocks_mutation_waits_for_the_accept_lock lost its own — so that test stopped being compiled as a test and has not run since this commit landed. Both are now correctly attributed, and both pass.

The rest of the stack was rebased onto the amended commit; every branch above this one has a byte-identical tree to before the rebase.

bkeroack and others added 2 commits September 9, 2026 18:32
Proposal mode ran a hand-written loop that reimplemented a subset of
`connect_block`. It skipped script verification by its own admission, and
with it BIP 68 sequence locks, the block sigop cost and BIP 30 — so a
miner asking whether a block would be accepted was told "yes" for blocks
`submitblock` then rejected.

`connect_block` is pure: it takes `&dyn Store`, reads, and returns a
`StoreBatch` its caller writes. Proposal mode calls it and drops the
batch, which is exactly Core's `ConnectBlock(fJustCheck=true)` against a
throwaway coins view. There is now one implementation of the rules, and
`accept_lock` is held for the duration as Core holds `cs_main`.

Also: a block already in the index answers `duplicate` /
`duplicate-invalid` / `duplicate-inconclusive` rather than
`inconclusive-not-best-prevblk`; a non-string `mode` is `-8 Invalid mode`
instead of being read as "not proposal" and answered with a template;
proposal mode with no string `data` is `-3`; an internal error is `-25`.

`check_block` gains Core's ordering — the merkle root is checked before
the size limits, so a block wrong in both ways answers `bad-txnmrklroot`
— and Core's legacy-sigop ceiling, which fires before any prevout is
resolved. Both are in the block-consensus matrix and the live
differential.

`-blockversion` is read: Core stamps it on the template header under
`MineBlocksOnDemand()`, and satd listed the key and ignored it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011H5HUWaezLTYJCZDpgaioL
…nclusive`

Core answers a proposal for a known block from `IsValid(BLOCK_VALID_SCRIPTS)`,
a validity level pruning does not lower: a pruned block was connected and
judged, only its data is gone. satd folded `Pruned` into the "not decided"
arm and told a miner the node had never reached a verdict. The match is now
exhaustive so a new status cannot fall into either arm unnoticed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011H5HUWaezLTYJCZDpgaioL
@bkeroack
bkeroack force-pushed the fix/663-gbt-proposal-real-validation branch from 0154907 to c74b91e Compare September 10, 2026 00:42
@bkeroack

Copy link
Copy Markdown
Contributor Author

Independent review pass — one fix landed on this branch.

  • A proposal for a block the node has already validated and since pruned answered duplicate-inconclusive. Core's IsValid(BLOCK_VALID_SCRIPTS) is unaffected by pruning, so the answer is duplicate. BlockStatus::Pruned now maps with Valid; the existing proposal test gained a pruned case.

Verified against Core v31.1 rpc/mining.cpp.

🤖 Generated with Claude Code

https://claude.ai/code/session_011H5HUWaezLTYJCZDpgaioL

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant