M3.13g: the version-seven ABCI adapter - #223
Merged
Merged
Conversation
`adapter/cometbft` could speak only to a version-one application. A version-seven one answers `finalize_block` in a shape version one's decoder refuses, so nothing on the Go side could read a block the version-seven kernel had executed. Three fields differ and nothing else does. The response carries a **block identifier** after the state root; an executed transaction's receipt is fifty-six octets with a version field of 7 rather than forty-seven with a 1, and its result byte sits at offset 39 rather than 38; and the result codes that byte may carry are version seven's thirty-three rather than version one's eight. The twenty-octet header, the seven kinds, the seven wire errors, the five request payloads, the status-and-reserved prefix, and the info, prepared-proposal, proposal-vote and committed-head shapes are unchanged. So this adds no wire. `ClientV7` embeds the version-one client and declares one method: the connection, the request-identifier discipline, the terminal latch, the frame codec, and every request encoder are shared, because a second copy of them would be a second place for a framing rule to be wrong, kept in step by discipline alone. It is the same decision ADR 0059 made on the C++ side, in the same shape. The decoder validates rather than trusts. A declared code and its receipt's own result byte must be the same fact, an admission failure must carry no receipt, a receipt must be version seven's length under version seven's prefix, and a result byte at or above the code count means the two sides disagree about the contract. All thirty-six results a block can report -- three admission failures and thirty-three execution results -- are decoded and each field checked against what produced it. Eight refusals are exercised, and two of them are the pair that makes a client dialled at the wrong version fail closed: version seven refuses version one's finalized block, and version one refuses version seven's. A protocol failure in the version-seven response is terminal for the whole connection, because the latch is the embedded client's and there is only one of it.
The version-seven client could read a finalized block; nothing turned one into an ABCI response, so no consensus engine could drive a version-seven node and requirement 13's four-node scenarios still had nothing to run. **One `Application`, parameterized, rather than two.** Six of the seven ABCI conversions -- the height range, the chain-identity decoding, the app state bound, the proposal prefix, the block bounds, the committed head -- name no ledger version, so `New` and `NewV7` differ by two things: the codespace that names the result codes an executed transaction can carry, and whether a finalized block arrives with an identifier. The bridge's own `FinalizedBlock` carries that identifier as a **pointer**, so absent is unmistakable where a zero hash could be read as naming something. **The block identifier becomes a block event.** ABCI has no field for a second identifier, and a value that crosses a process boundary and is then discarded is a value the next simplification deletes. A block event is not hashed into anything CometBFT agrees on -- only a transaction result's code and data reach `LastResultsHash` -- so it is observable rather than consensus-visible. **The replay handshake ADR 0058 owed this slice is a guard, not a reconciliation.** A local application refuses a `finalize_block` at any height that is not its current plus one, including one it has already committed, and that refusal is terminal. CometBFT v0.39.4 never asks: when its state is one behind a block the application has committed, it replays that height against a mock built from its own saved response, with the comment that it does not want to call Commit twice on the real app; and an application ahead of the block store is refused at the handshake without a request being sent. The guard is here because the consequence if any engine ever did ask is a node bricked on a contradiction it did not commit, while a legible error the engine stops on costs one comparison per block and leaves a restart clean. **It counts nothing itself**: the height comes from the application's own answers to Info and Commit, so it can never exceed the height the application would accept. `protocol-cometbft-init` and `protocol-cometbft-bridge` each take `-protocol-version`, defaulting to one. The genesis application state is version-specific because that is what refuses a node started against a version-one genesis and a version-seven engine -- it fails at InitChain rather than at the first block -- and the parser compares as the wider type, so 257 is not admitted as version one. The devnet stays on version one explicitly: a version-seven local network needs its genesis and the `-protocol-version` its supervisor passes each bridge to be one choice, and that is the four-node slice. The version-seven bridge is driven through Info, CheckTx, Query, and a finalized block carrying all three result shapes, and the identity event's type, key, index flag, and value are each checked; version one must emit no event at all. The guard is exercised from both sides of a commit: a height below the one Info reported and the reported height itself are refused without reaching the local application, the next height succeeds, the same height repeated after its own commit is refused, and the height after that succeeds. A home initialized for version seven carries the version-seven application state, and re-initializing it for version one is refused rather than adopted.
ADR 0061 records what the adapter decided, and the interesting half is the question ADR 0058 left open rather than the encoding. **The replay handshake turned out to be a fact about the pinned engine.** `ApplicationV7` refuses a `finalize_block` at any height that is not its current plus one, including one it has already committed, and that refusal is terminal — so the worry was the crash window that leaves the application one block ahead of CometBFT's own state. CometBFT v0.39.4 closes that window itself: it replays exactly that height against a mock application built from its own saved response, with the source saying in as many words that it will not call `Commit` twice for one block on the real app, and every other branch sends only `current + 1` because each replayed block is committed before the next is sent. So the adapter reconciles nothing, and the ADR records why inventing a reconciliation would have been worse than useless: answering a repeat honestly means reproducing per-transaction receipts the stage no longer holds after `commit` and the store never recorded, so any answer it synthesised would be the fabricated agreement this layer exists to prevent. What it adds instead is a guard that refuses to forward such a request, using a height taken only from the application's own answers. The rest is recorded with its reasoning: why the Go client is version one's client and one different answer, why one parameterized `Application` rather than two, why the block identifier is a pointer and becomes an indexed block event rather than being decoded and discarded, and why the genesis application state is the thing that refuses a mismatched pair. Three items are owed rather than implied: the end-to-end run against a real engine, which needs the recorded blocks' raw inputs that no accepted vector file carries; the four-validator devnet, whose genesis and bridges must choose one version together; and the uptime schedule, which is still `nullptr`, so a chain driven through this adapter executes correctly and pays nobody.
Owner
Author
Hosted verificationRun 33500977481 on head
That is No CMake target and no ctest entry changes: the slice is Go and Markdown, so the |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #222.
adapter/cometbftspoke only version one, so no consensus engine could drive aversion-seven node. This adds the Go half: a version-seven local application
client, an ABCI bridge over it, the genesis application state that pairs a home
with a ledger version, and
-protocol-versionon the two binaries that need it.What differs, and what deliberately does not
Three fields differ on the wire and all three are in the finalized block: the
block identifier after the state root, a receipt of fifty-six octets under a
version field of 7 with its result byte at offset 39, and a result range of
thirty-three rather than eight.
Nothing else does, so
ClientV7embedsClientand declares one method. Theconnection, the request-identifier discipline, the terminal latch, the frame
codec, and every request encoder are shared — the same decision ADR 0059 made on
the C++ side, for the same reason. In the bridge, six of the seven ABCI
conversions name no ledger version, so
NewandNewV7differ by the codespaceand by whether a finalized block arrives with an identifier.
The replay handshake
ADR 0058 owed this slice an answer.
ApplicationV7refuses afinalize_blockat any height that is not its current plus one — including one it has already
committed — and that refusal is terminal.
The answer turned out to be a fact about the pinned engine rather than a design.
CometBFT
v0.39.4'sconsensus/replay.go, in the one branch where theapplication is ahead of the engine's state, loads its own saved
FinalizeBlockresponse and replays that height against a mock applicationbuilt from it; the source says in as many words that it does not want to call
Committwice for the same block on the real app. Every other branch replaysfrom
appBlockHeight + 1, which iscurrent + 1at each step because eachreplayed block is committed before the next is sent, or refuses at the handshake
without sending a request at all.
So the adapter reconciles nothing. Inventing a reconciliation would have been
worse than useless: answering a repeat honestly means reproducing per-transaction
receipts the stage no longer holds after
commitand the store never recorded.What it adds is a guard that refuses to forward such a request, using a
height taken only from the application's own answers to
InfoandCommit, soit can never exceed the height the application would accept and can never refuse
a legitimate block.
Verification
Focused local checks, run before pushing:
git diff --check main HEAD— clean.gofmt -lover every changed and added file — clean.go vet ./...andgo test ./localapp/over the wholeinternal/localapppackage in a scratch stdlib-only module — passed. The package imports no
third-party code, so it type-checks and runs without the module cache; the
bridge and
nodeconfigpackages import CometBFT and are verified on the hostedmatrix, as the repository's resource rules require.
Hosted
tools/verify.shacrossgcc-debug,gcc-sanitizers,clang-debug,and
clang-sanitizersis the required gate for this change, since it touches Gosources. Results are recorded below when the run reaches a terminal state.
Evidence in the tests
failures and thirty-three execution results — decoded, with each field checked
against the receipt that produced it, and eight refusals exercised.
one's finalized block and version one refuses version seven's, which is what
makes
-protocol-versionfail closed rather than misread a block.byte for byte against the shared encoder's output.
no event at all.
calls to prove a committed height never reaches the local application, plus a
case proving the guard counts nothing itself.
state, and refusing to be re-initialized for version one.
Owed, and recorded in the ADR rather than implied
Driving version seven through the single-node integration needs the recorded
blocks' raw inputs, which no accepted vector file carries; only four of the
five recorded blocks are reachable in any case, since
carried.block4is atheight 1,152,000.
the
-protocol-versionits supervisor passes each bridge must be one choice.nullptr, so a chain driven through thisadapter executes correctly and pays nobody.