export the reject leg the corpus generator already calls

go build ./... fails on this branch and has since 8968c19c:

    conformance/corpus.go:248:15: undefined: chain.CanonicalRejectMessage

The generator needs both decisions because the accept byte is bound into
the signed message, and proving that binding is most of what the corpus
is for — a port that folded the decision out would pass every
accept-only vector. But only the accept leg was exported;
canonicalVoteMessageFor is package-private, and package conformance is
not that package.

So export the reject leg beside it. Two named builders rather than one
taking a bool: the decision is the thing being distinguished, and Go
reads better when it is in the name.

Correct, not merely compiling: the committed corpus.json already holds
the two reject vectors (zero_reject, spec_reject, message tails 00
against the accept vectors' 01), and TestCorpusMatchesGolden compares
the regenerated corpus to it byte for byte. It passes. Invert the bool
and it fails twice over — 'the corpus moved: a finality rule changed'
and 'decision byte is reject=0x1 accept=0x1, want 0x00 and 0x01'.

The wire-vector test pinned the unexported builder, so it could not see
a wrapper handed the wrong bool, which is the only way a three-line
wrapper goes wrong. It pins both exported names now.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-08-29 17:52:36 -07:00
co-authored by Hanzo Dev
parent c03aaa1b9c
commit fb181d586b
2 changed files with 31 additions and 0 deletions
+11
View File
@@ -208,6 +208,17 @@ func CanonicalVoteMessage(pos VotePosition) []byte {
return canonicalVoteMessageFor(pos, true)
}
// CanonicalRejectMessage is the exact byte string a validator signs to vote
// REJECT on a position — the accept=0 leg of the same layout. It is not a
// certificate message: a finality cert proves ACCEPT and carries only accept
// signatures. It exists because the accept byte is bound, so the two decisions
// are distinct messages, and proving that is what the conformance corpus does:
// a reject signature presented as an accept must fail, and a port that folded
// the decision out of the message would pass every accept-only vector.
func CanonicalRejectMessage(pos VotePosition) []byte {
return canonicalVoteMessageFor(pos, false)
}
// canonicalVoteMessageFor builds the domain-separated vote message for a
// position AND a decision. The accept byte is bound, so an ACCEPT signature
// (accept=1, what a cert carries) and a REJECT signature (accept=0) over the
+20
View File
@@ -149,6 +149,26 @@ func TestVoteMessage_AcceptAndRejectAreDistinct(t *testing.T) {
}
}
// TestVoteMessage_ExportedAccessorsCarryTheirOwnDecision pins the two exported
// message builders to the decision each one names. The test above proves the
// accept and reject legs differ, but it calls the unexported builder directly,
// so it would still pass if an exported wrapper handed it the wrong bool —
// which is the only way a three-line wrapper can be wrong. The corpus generator
// reaches these two names and nothing else, so a swapped bool here would ship
// reject bytes to every port as the accept vector.
func TestVoteMessage_ExportedAccessorsCarryTheirOwnDecision(t *testing.T) {
pos := vectorPosition()
if !bytes.Equal(CanonicalVoteMessage(pos), canonicalVoteMessageFor(pos, true)) {
t.Fatal("CanonicalVoteMessage is not the accept leg")
}
if !bytes.Equal(CanonicalRejectMessage(pos), canonicalVoteMessageFor(pos, false)) {
t.Fatal("CanonicalRejectMessage is not the reject leg")
}
if bytes.Equal(CanonicalVoteMessage(pos), CanonicalRejectMessage(pos)) {
t.Fatal("the exported accept and reject builders return the same bytes")
}
}
// TestVoteMessage_DegradesUnsetCanonicalToTheOuterID pins the one place the
// non-wrapped degrade lives. A bare chain must sign its outer id under the
// canonical slot, and it must produce the SAME bytes a wrapped position with