Skip to content

M3.13g: the version-seven ABCI adapter - #223

Merged
kaikisegfault merged 3 commits into
mainfrom
feat/222-version-seven-adapter
Sep 1, 2026
Merged

M3.13g: the version-seven ABCI adapter#223
kaikisegfault merged 3 commits into
mainfrom
feat/222-version-seven-adapter

Conversation

@kaikisegfault

Copy link
Copy Markdown
Owner

Closes #222.

adapter/cometbft spoke only version one, so no consensus engine could drive a
version-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-version on 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 ClientV7 embeds Client and declares one method. The
connection, 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 New and NewV7 differ by the codespace
and by whether a finalized block arrives with an identifier.

The replay handshake

ADR 0058 owed this slice an answer. 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.

The answer turned out to be a fact about the pinned engine rather than a design.
CometBFT v0.39.4's consensus/replay.go, in the one branch where the
application is ahead of the engine's state, loads its own saved
FinalizeBlock response and replays that height against a mock application
built from it; the source says in as many words that it does not want to call
Commit twice for the same block on the real app. Every other branch replays
from appBlockHeight + 1, which is current + 1 at each step because each
replayed 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 commit and 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 Info and Commit, so
it 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 -l over every changed and added file — clean.
  • go vet ./... and go test ./localapp/ over the whole internal/localapp
    package 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 nodeconfig packages import CometBFT and are verified on the hosted
    matrix, as the repository's resource rules require.

Hosted tools/verify.sh across gcc-debug, gcc-sanitizers, clang-debug,
and clang-sanitizers is the required gate for this change, since it touches Go
sources. Results are recorded below when the run reaches a terminal state.

Evidence in the tests

  • All thirty-six results a version-seven block can report — three admission
    failures and thirty-three execution results — decoded, with each field checked
    against the receipt that produced it, and eight refusals exercised.
  • Both directions of the version mismatch: version seven refuses version
    one's finalized block and version one refuses version seven's, which is what
    makes -protocol-version fail closed rather than misread a block.
  • The version-seven client's request read off a real socket pair and compared
    byte for byte against the shared encoder's output.
  • The identity event's type, key, index flag, and value; version one must emit
    no event at all.
  • The guard driven from both sides of a commit, with the fake counting its own
    calls to prove a committed height never reaches the local application, plus a
    case proving the guard counts nothing itself.
  • A home initialized for version seven carrying the version-seven application
    state, and refusing to be re-initialized for version one.

Owed, and recorded in the ADR rather than implied

  • The end-to-end run. Nothing here has spoken to a real CometBFT engine.
    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.block4 is at
    height 1,152,000.
  • The four-validator devnet stays on version one explicitly. Its genesis and
    the -protocol-version its supervisor passes each bridge must be one choice.
  • The uptime schedule is still nullptr, so a chain driven through this
    adapter executes correctly and pays nobody.

`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.
@kaikisegfault

Copy link
Copy Markdown
Owner Author

Hosted verification

Run 33500977481 on head 142f750passed.

Job Result
Classify and verify change scope success (full)
Linux x86_64 / gcc-debug success
Linux x86_64 / gcc-sanitizers success
Linux x86_64 / clang-debug success
Linux x86_64 / clang-sanitizers success
Verification required success

That is tools/verify.sh in full on each preset: go mod verify, go test ./...
and go vet ./... over every package, all four commands built with cgo disabled,
the C++ suite, and both the single-node and four-validator CometBFT integrations.
The integrations are what prove the version-one path is unchanged by the
localApplication interface change and the nodeconfig.Ensure signature — they
drive real CometBFT, a real bridge, and a real application through commits and a
restart, on the default -protocol-version 1.

No CMake target and no ctest entry changes: the slice is Go and Markdown, so the
suite stays at 152 entries in the debug presets and 160 under clang-sanitizers.

@kaikisegfault
kaikisegfault merged commit ecd3fcf into main Sep 1, 2026
6 checks passed
@kaikisegfault
kaikisegfault deleted the feat/222-version-seven-adapter branch September 1, 2026 11:20
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.

M3.13g: the version-seven ABCI adapter

1 participant