Skip to content

Narya Ed25519 integration + FD/Solana Conformance Suite refresh - #258

Merged
smcio merged 23 commits into
alpenglow-devfrom
7layer/narya-integration
Jul 31, 2026
Merged

Narya Ed25519 integration + FD/Solana Conformance Suite refresh#258
smcio merged 23 commits into
alpenglow-devfrom
7layer/narya-integration

Conversation

@7layermagik

Copy link
Copy Markdown

Route all Ed25519 verification through narya, under the strict predicate

Mithril verified transaction signatures with Go's crypto/ed25519, one signature
at a time, at four independent call sites. That was wrong on two axes at once.

It accepted signatures mainnet rejects. Solana verifies with
ed25519-dalek's verify_strict, which is stdlib Verify plus rejection of
small-order A and small-order R. solana.PublicKey.Verify bottoms out at
plain stdlib, so Mithril admitted a class of signature the network refuses — a
standing violation of the byte-identical-state invariant, and one an adversary
can construct cheaply.

It used the slowest possible shape. The library verifies eight signatures per
AVX-512 group, so per-signature cost is a strong function of how many arrive at
once: on Zen 5 a lone signature costs ~22.9 µs and a group of eight costs ~6.1 µs
each. Verifying one at a time gave up a ~3.7× factor before any kernel work.

This PR fixes the predicate everywhere, batches the two hot paths, and takes the
conformance harness from "never executed a fixture" to three suites at 100%.


Consensus-relevant behaviour changes

Reviewers should look hardest at these. Everything else is mechanical.

change before after
Transaction sigverify stdlib (non-strict) strict — small-order A/R rejected
Gossip / shreds / repair stdlib (non-strict) strict
Ed25519 precompile curve25519-voi, misconfigured narya, strict when the feature gate is active
--sigverify-backend stdlib changed which signatures were accepted swaps arithmetic only
Versioned transactions over TPU rejected outright accepted

Expect a real accept/reject delta on replay. Any transaction that only passed
under the non-strict predicate is now rejected. If a bankhash diverges after this
lands, that is the first hypothesis, not the last.

The backend flag was a consensus switch

backend=stdlib bypassed the library and called crypto/ed25519 directly, which
is non-strict. An operator flag therefore decided which signatures the node
accepted — two nodes on different settings would disagree on block validity. It
is the flag you reach for under exactly the pressure that makes a silent fork
worst.

The package comment already said the predicate "is not optional and not
configurable", and Configure set DalekStrict before selecting anything. The
bypass contradicted both. It now selects narya's own crypto/ed25519-backed
arithmetic instead: the rejection pre-pass runs before backend dispatch, so the
diagnostic value survives — it still swaps out the r51 assembly, the comb tables
and the batch kernels — while acceptance is fixed.

The precompile ran a predicate the reference never used

pkg/sealevel built its options as a struct literal setting three fields. Go
zeroes what a literal omits, so AllowNonCanonicalA silently became false and
the precompile rejected public keys the reference accepts — and voi's own
VerifyOptionsStdLib sets that field true, so the omission inverted the
library default rather than merely leaving it.

The non-strict branch was worse: voi's defaults are cofactored and reject
non-canonical A, neither of which the reference does. That branch now calls
crypto/ed25519, which is exactly what pre-activation dalek did.

Signatures are still verified one at a time here, deliberately. The reference
walks entries in order and returns the first error, so batching would let a
later entry's offset error preempt an earlier entry's signature error — and that
error code reaches the ledger.


What changed, by area

pkg/sigverify (new). Owns backend selection and the batch entry points.
Configure is validated-then-published and refused on a second call. Batch is
reusable worker-local scratch that allocates nothing after the first use. Drain
takes a fair share of the queue rather than the whole thing, so one worker cannot
strand the others.

Replay and TPU. Both worker loops moved from one-signature-at-a-time to
drain-and-batch. Failure attribution is preserved exactly: narya fills a per-item
verdict slice, so the arity-mismatch and invalid-signature panics still name the
precise signer via diagContext(), and each job still completes its own
WaitGroup.

Gossip, shreds, repair. Now on narya.VerifyStrict.

TPU versioned transactions. txverify.MessageBytes applies a version-byte
fixup that solana-go's MarshalV0 gets wrong (it writes 0x7f; the wire
encoding is 0x80). Both TPU verifiers called raw MarshalBinary and skipped
it, so valid versioned transactions failed TPU sigverify. Consolidated onto
one helper.

Observability. The resolved backend, InternalFaultFallbacks, and a
batch-width histogram are recorded to <run-dir>/sigverify.log each run.
Terminal output is unchanged. Width is a histogram rather than a counter because
a total cannot distinguish a million signatures arriving one at a time from the
same million arriving in eights, and those cost ~3.7x differently. Recorded
inside Batch.Verify so a new caller cannot forget to instrument itself.

conformance/. See below.

go.mod. narya-ed25519 1625c183da0d045 on main (108 commits,
fast-forward). The reason to move now is 5e7ead9, which fixes a nil per-key
table dereference in a warm group that is reachable from network input. Also
picks up a fail-closed nil Cache receiver and an r51 availability gate tied to
compiled kernels rather than CPU features alone.


The conformance harness was silently broken

This was not part of the plan and turned out to be the largest finding.

TestConformance_Precompile_Ed25519_Program read
test-vectors/precompile/fixtures/ed25519, a layout that no longer exists
upstream. ReadDir failed, the assertion failed, and no fixture was ever
executed
. Firedancer now publishes every precompile fixture in one flat
directory keyed by the program id inside the fixture, so filtering has to parse
rather than glob.

Underneath that sat something quieter. The generated bindings described a schema
several versions old:

  • InstrFixture gained metadata at field 1, pushing input to 2 and output to 3
  • InstrContext dropped epoch_context and slot_context; the FeatureSet now
    sits directly at field 10
  • AcctState dropped rent_epoch

Protobuf does not complain about any of that. It read the metadata submessage as
an InstrContext and produced a context whose program id was the ASCII of
"sol_compat_instr_execute_v1" and whose account list was empty. Every test
built on those bindings compared nothing while reporting success.

The field-10 miss is what the remaining failures were about: reading the old
number yielded nil, parseAndConfigureFeatures saw no active features, and every
feature-gated branch evaluated as though nothing had ever activated. For the
Ed25519 precompile that selected the pre-activation non-strict path.

Bindings regenerated from protosol v5.4.0 with protoc v3.21.12 and protoc-gen-go
v1.34.2, matching the versions recorded in the previous files so the diff shows
schema movement rather than codegen churn.

suite before after
ed25519 never ran 3479 / 3479
secp256k1 never ran 2628 / 2628
secp256r1 no test existed 13185 / 13185

secp256r1 is the largest fixture set in the corpus and had no test at all.

The corpus revision is pinned at a87fc430 rather than tracking main — both
the schema and the per-suite counts move with it, so following main makes a
passing run unreproducible and a regression indistinguishable from an upstream
edit. make conformance-vectors fetches it; suites skip when it is absent.

Address lookup table, config and stake were undispatched

resolveNativeProgramById had no case for AddressLookupTableAddr, so
AddressLookupTableExecute was unreachable and all 31 address-lookup unit tests
failed. The implementation was complete and exported the whole time, which is why
nothing caught it at build time.

Config and Stake were missing the same way. Both are BPF-migrated on mainnet, so
their accounts are loader-owned and route through the loader; this resolver is
only consulted when the owner is NativeLoader, the pre-migration shape. Wiring
them changes nothing today and restores historical replay.

A resolver test now pins every native program to its case, because this bug class
is silent by construction: an implementation with no case still compiles, still
exports, and simply never runs.


Testing

Added tests avoid two failure modes that would make them decorative.

Nothing passes vacuously. Backend selection is one-shot per process by
design, so an in-process table over backends would let the first Configure win
and turn every later case into a silent pass. Each case runs in a child process
instead. TestConfigureChildPlumbingActuallyRuns guards the harness itself — if
the child marker or -test.run pattern stopped matching, every subprocess test
would spawn a child that ran nothing and reported PASS.

Assertions discriminate. TestEd25519PrecompileFeatureGateSelectsThePredicate
drives one input through both feature states and requires opposite verdicts. A
test asserting "both reject" would pass under either branch and prove nothing.
The probe is a small-order construction that genuinely satisfies the stdlib
equation — A is the identity, so [s]B − [k]A collapses to [s]B, exactly the
R it carries — so it is rejected purely on the small-order gate, making it a
clean signal for which predicate ran.

Coverage: predicate delta vs stdlib; batched-equals-per-item across mixed
validity and batch widths straddling 4 and 8; panic attribution inside a full
batch; every Configure path including unsupported CPU and repeat calls;
end-to-end precompile through ProcessInstruction; three conformance suites.
pkg/sigverify passes under -race.

One case deliberately absent

verify_strict accepts a non-canonical A and hashes its original bytes, and
pinning that looks like the obvious fourth precompile case. It cannot be
built.
A non-canonical encoding requires y < 19, and every curve point with
such a y whose discrete log is computable is already small-order, so it is
rejected before canonicality is consulted. All 152 non-canonical-A fixtures in
the Firedancer corpus expect an error for that reason — which also means they do
not discriminate. A test for it would read as coverage and prove nothing, so the
reasoning is recorded in a comment instead.

Consequence worth stating plainly: the AllowNonCanonicalA fix is correct by
reading the reference, not by test.
That is not fixable.


Not in this PR

  • Warm per-key comb cache. Break-even is 1.6 verifies at 19,200 B/key, but a
    warm group must be eight homogeneous warm lanes with no partial credit. Its
    value rests on the fee-payer recurrence distribution, which nobody has
    measured, and Alpenglow moving votes to BLS certificates plausibly removed the
    dominant recurrence source. Cold-path batching is recurrence-independent and is
    what this PR ships.
  • Batching the precompile, for the error-ordering reason above.
  • Legacy native-program conformance suites. Upstream removed the native ALT,
    config and stake fixtures; native coverage lives in unit tests instead.

Known-red, and what this branch does to it

go test ./... is not green here, and was not green before. The failing package
set is identical on origin/alpenglow-dev and on this branch: conformance,
pkg/genesis, pkg/lightbringer, pkg/sealevel. This branch adds no new one.

At test granularity it is better than that, and the detail is worth stating
because a naive diff of failing tests looks alarming:

  • Fixed: 31 address-lookup-table tests, by adding the missing dispatch case.
  • Newly visible: 19 BPF-loader tests.

Both sets are internal unit tests in pkg/sealevel
(address_lookup_table_test.go, sealevel_bpf_loader_test.go). Neither touches
the Firedancer corpus, so neither is conformance breakage — worth saying because
"19 BPF-loader failures" reads that way otherwise.

The 19 are not regressions. On the base, the address-lookup test did not merely
fail — it panicked, which aborts the whole test binary, so every later test
in pkg/sealevel never ran. Verified by running
go test ./pkg/sealevel/ -run TestExecute_Tx_BpfLoader at
origin/alpenglow-dev, where all 19 fail identically in isolation. Unblocking
the panic simply let the rest of the package execute for the first time.

That is worth someone's attention on its own: a panicking test had been masking
19 real failures for as long as the dispatch case was missing. It is out of
scope here and wants its own change.

The conformance suite moves the other way. Three precompile suites went from
never executing a fixture to 100%, and vm-programs is unchanged at 144/3398,
verified either side of the protobuf regeneration. conformance stays red only
because of that pre-existing vm-programs count.

Rollback

--sigverify-backend generic selects the portable pure-Go path; stdlib selects
crypto/ed25519-backed arithmetic. Neither changes the predicate. There is
deliberately no flag that restores the old non-strict behaviour, because that
behaviour was the bug.

7layermagik and others added 23 commits July 26, 2026 00:41
Agave v4.2.0-beta.1 verifies every P2P/consensus signature — transactions,
gossip (CRDS, ping/pong, prune), shreds, and repair — through
solana-signature -> ed25519-dalek 2.2.0 verify_strict (confirmed uniform by
reading each path). verify_strict rejects small-order public keys A and
small-order signature points R; Go's crypto/ed25519.Verify does not (it
never decodes R). Mithril used the standard library at every one of these
sites, so it accepted an adversarial-only class of signatures mainnet
rejects — a crafted block, gossip packet, or repair message could be
admitted by Mithril and discarded by mainnet, splitting the node from
consensus.

Route all eight sites through narya.VerifyStrict, which enforces
DalekStrict regardless of any global profile: gossip contact-info and
CRDS value (contact_info.go), ping/pong (message.go), turbine shred
reference and cache paths (shred.go, sigcache.go), and repair ping and
signed request (protocol.go). The shred cache only inserts after a
successful verify and never caches failures, so the single miss-path
check is sufficient — a strict rejection can never be memoized as valid.

Tests: repair carries the rigorous proof — real ed25519vectors that
crypto/ed25519 accepts and verify_strict rejects (small-order A and R)
are now rejected by VerifySignedRequest; gossip and turbine cover the
honest happy path, small-order-key rejection, agreement with
narya.VerifyStrict on both paths, and that the shred cache never caches
a strict rejection.

The transaction replay path is fixed separately in the narya sigverify
integration. The ed25519 precompile (pkg/sealevel) is a separate,
feature-gated path analyzed under its own task.

narya is wired via a local replace directive (../narya) for now; it must
become a published version pin before this branch is shared.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New package owning backend selection and the batch entry point. Mithril
verifies with stdlib today, which is non-strict, so it accepts small-order A
and R that ed25519-dalek verify_strict -- and therefore mainnet -- rejects.
Every path here applies the strict predicate.

Batch is reusable worker-local scratch and Drain is the fill policy: block for
the first item, non-blocking peek for the rest, no timer. Batch width is what
sets cost per signature (a lone signature is roughly 3.7x the per-signature
cost of one inside a group of eight), but a batching timer would trade tip
latency away to buy that, so batches are whatever happened to be queued.

The consensus claim is tested rather than asserted: the suite constructs a
signature with a small-order public key that stdlib accepts and this package
rejects, and pins that backend=stdlib faithfully reintroduces it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The pool worker blocked for one job and verified it alone. One transaction
carries one or two signatures, which is the worst shape for a backend that
verifies eight per AVX-512 group: a lone signature pays for a whole group and
uses one lane of it.

Workers now drain what is already queued and verify the group in one call.
Draining costs nothing on an empty queue -- the worker still takes exactly the
job it blocked for -- and a backlog here means catch-up, which is when
throughput matters and per-block latency does not.

Attribution is preserved exactly. The backend reports a verdict per signature,
so the failing signer is identified without re-verifying anything, and the
panic still names it. Tests place the bad signature at every boundary of a full
group, and cover release of jobs belonging to different blocks.

Count on the Sigverify timing now counts groups rather than transactions;
SumNanoseconds keeps its documented meaning as total async worker time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two TPU verifiers each marshalled the signed message themselves and so skipped
the version-byte fixup. solana-go's MarshalV0 writes 0x7f, which is not the
Solana wire encoding -- the signed prefix is 0x80 -- so a correctly signed
versioned transaction was checked against bytes no client ever signs and was
dropped at ingest. Both now go through txverify, which owns that fixup, and
pick up the strict predicate at the same time.

The pipeline worker verified one packet at a time. A transaction carries one
or two signatures against a backend that verifies eight per group, so it paid
for a group and used one lane. Workers now drain what is already queued;
an empty queue still yields exactly the packet the worker blocked for, so
quiet-ingress latency is unchanged.

An unparseable packet contributes no signature lane, so the batch keeps a nil
placeholder to stop verdicts sliding onto their neighbours -- covered by a
test that interleaves garbage, corrupted, and honest packets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Draining unconditionally was a latency regression waiting to happen. Eight
workers facing eight queued items would let the first worker take all eight
and verify them as one group while the other seven idled -- and one group of
eight costs more wall-clock than eight workers each verifying one. Batching
buys throughput per core; it must not buy it with parallelism that was already
there. A turbine cancellation regression test caught exactly this: one worker
swallowed both jobs meant to occupy two workers.

FairShare gives a worker one item plus an equal cut of what remains, so a
shallow queue spreads and a deep queue -- catch-up, where every worker is
saturated anyway -- gives everyone full groups. The floor is 1: the item
already in hand is never given back.

Turbine additionally admits workers*BatchTarget transactions per wave rather
than one per worker, since a producer that meters work out one-per-worker makes
groups unreachable no matter what the consumer does. The fairness bound it was
protecting is unchanged in spirit: tens of jobs parked, not tens of thousands.

Nothing waits for a target width anywhere, so a partly-filled group cannot
strand work. That property is now pinned in all four pipelines with counts that
divide badly into groups (1, 3, 7, 9, 33, 65, 129); a stranded item hangs the
join and fails the test rather than passing quietly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A package-wide gofmt -w while iterating reformatted files this change does not
touch. Whitespace-only, but it does not belong in a consensus-critical diff.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The comment said the test 'hangs rather than failing quietly', which reads
backwards. The point is that asserting on the join completing -- rather than
just on no error being returned -- means a stranded item surfaces as a timeout
instead of slipping through as a pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
VerifySignedRequest authenticates repair requests from peers. Mithril is
requester-side only today so nothing outside tests reaches it, but serving
repair is normal validator work, and when it is wired up this becomes a
packet-rate consumer on a socket loop -- the shape that wants Drain/FairShare
rather than a one-at-a-time verify.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nothing selected a backend, so every verification ran on the portable pure-Go
path: the strict predicate was live but none of the acceleration was, and the
stdlib rollback switch was unreachable. This is the link that turns it on.

--sigverify-backend / tuning.sigverify_backend resolves through the house
flag-over-TOML precedence and installs the backend during config parsing, so a
machine that cannot run the requested one fails at startup rather than at the
first block. The resolved name is printed at startup because under 'auto' it is
the only way an operator learns whether they got the accelerated path -- and
because backend=stdlib deserves to say out loud that it accepts signatures
mainnet rejects.

Replay sigverify had no Prometheus series at all. It gets two: group duration
and group width. Width is the one worth watching -- it is the difference
between paying for a vector group and using it, and no backend setting can
compensate for work arriving too thinly to fill one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The branch carried 'replace => ../narya' while the library was private, which
made it unbuildable for anyone without that checkout sitting beside the repo --
CI included. narya-ed25519 is public now, so this pins an ordinary
pseudo-version, matching how Overclock-Validator/crypto is consumed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The strict-path options were built as a struct literal setting only
AllowSmallOrderA, AllowSmallOrderR and CofactorlessVerify. Go zeroes every
field a literal omits, so AllowNonCanonicalA silently became false and the
precompile rejected public keys the reference predicate accepts. voi's own
VerifyOptionsStdLib sets that field true, so the omission also inverted the
library's default rather than merely leaving it.

No constructible input separates the two behaviours: a non-canonical encoding
requires y < 19, and every curve point with such a y whose discrete log is
computable is small order, which AllowSmallOrderA already rejects. So this
changes no verdict reachable today. It is worth fixing anyway, because the
gap between "what we meant" and "what the literal said" is not visible at the
call site, and the next field to go missing may not be unreachable.

Moving the options into a named package-level value is the part that makes
the invariant testable. The tests pin each field individually so a regression
names the field it broke, and record why AllowNonCanonicalR stays unset: voi
panics on AllowNonCanonicalR together with CofactorlessVerify, so setting it
would crash on the first precompile instruction rather than loosen a check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The precompile was the last verification site still on curve25519-voi, and the
only one that did not honour the backend selection or the stdlib rollback
switch that pkg/sigverify owns. Route the strict path through
sigverify.VerifyOne so it uses the same narya DalekStrict predicate, the same
backend, and the same kill switch as replay, TPU, gossip, repair and turbine.

The non-strict branch, which runs only when replaying history from before
Ed25519PrecompileVerifyStrict activated, now calls crypto/ed25519 directly.
The reference used plain non-strict verification there -- cofactorless, no
small-order rejection, non-canonical A accepted, R compared as bytes -- which
is exactly what the standard library does. voi's default options were none of
those things: they are cofactored and reject a non-canonical A, so that branch
had been running a predicate the reference never used. narya exposes no
StdlibCompat entry point, so the stdlib is both correct and the smallest
dependency for that path.

Signatures are still verified one at a time. The reference walks entries in
order and returns the first error, so batching would let a later entry's
offset error preempt an earlier entry's signature error, and that error code
reaches the ledger. The ordering is consensus-visible and not worth trading
for the throughput of a batch that is usually one or two signatures deep.

This supersedes the AllowNonCanonicalA fix in the previous commit; the option
struct it repaired no longer exists. The tests now guard the wiring rather
than the struct literal, since the predicate itself is covered by narya's CCTV,
Wycheproof and edge corpora.

Verified against Agave v4.2.0-rc.0: precompiles is still pinned to
ed25519-dalek "=1.0.1" and the workspace to 2.2, unchanged from beta.1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The precompile conformance tests read test-vectors/precompile/fixtures/<program>,
a layout that no longer exists upstream. ReadDir failed, the assertion failed,
and no fixture was ever executed. Firedancer now publishes every precompile
fixture in one flat instr/fixtures/precompile directory and distinguishes them
by the program id inside the fixture, so filtering has to parse rather than glob.

A second and quieter problem sat underneath. The fixture schema gained a
metadata message at field 1 and pushed input and output to fields 2 and 3, but
the generated bindings here still say input is 1 and output is 2. Protobuf does
not error on that: it reads the metadata submessage as an InstrContext and
yields a context with no program id and no accounts. Every test built on those
bindings would have compared nothing while reporting success. unmarshalInstrFixture
splits the wrapper by hand and decodes the two submessages with the existing
inner types, which have not drifted, and refuses a fixture with no metadata
field rather than guessing at an older shape.

parseAndConfigureFeatures dereferenced through Input.EpochContext
unconditionally and panicked on the precompile corpus, which mostly carries no
epoch context. It now treats a missing one as "defaults only". Its per-feature
logging moved behind MITHRIL_CONFORMANCE_VERBOSE, which the vm-programs test
already used; unguarded it emits a line per feature per fixture across thousands
of fixtures.

Results against firedancer-io/test-vectors:

    ed25519     3316 / 3479
    secp256k1   2523 / 2628
    secp256r1  13185 / 13185

secp256r1 is the largest fixture set in the corpus and had no test at all.

The remaining ed25519 and secp256k1 failures share one cause, and it is not the
predicate. InstrContext.epoch_context also moved, from field 9 to field 10, so
the bindings read nil, parseAndConfigureFeatures sees no active features, and
every feature-gated branch evaluates as though nothing had ever activated. For
the ed25519 precompile that selects the pre-activation non-strict path, which
accepts signatures strict rejects -- exactly the observed "we accepted, fixture
expects an error". Field 10 is not universally an epoch context across the
corpus, so recovering it by hand is a guess rather than a fix; regenerating the
bindings from firedancer-io/protosol v5.3.0 is the correct repair and is left
as follow-up work.

Tests skip rather than fail when the corpus is absent. It is a ~7 GB external
checkout that is deliberately gitignored, so 'make conformance-vectors' fetches
or updates it and 'make test-conformance-precompiles' runs all three suites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The generated bindings described a schema several versions old. Three changes
mattered:

  InstrFixture   gained metadata at field 1, pushing input to 2 and output to 3
  InstrContext   dropped epoch_context and slot_context; the FeatureSet now sits
                 directly at field 10
  AcctState      dropped rent_epoch

Protobuf does not complain about any of that. It read the metadata submessage
as an InstrContext and produced a context whose program id was the ASCII of
"sol_compat_instr_execute_v1" and whose account list was empty, so the
precompile suites compared nothing. The previous commit worked around the outer
drift with a hand-rolled wrapper splitter; that shim is deleted here, since
correct descriptors make it unnecessary.

The field-10 FeatureSet is what the remaining failures were about. Reading the
old field number yielded nil, parseAndConfigureFeatures saw no active features,
and every feature-gated branch evaluated as though nothing had ever activated.
For the Ed25519 precompile that selected the pre-activation non-strict path,
which accepts signatures strict rejects.

  ed25519     3316 / 3479  ->  3479 / 3479
  secp256k1   2523 / 2628  ->  2628 / 2628
  secp256r1  13185 / 13185 -> 13185 / 13185

So none of those 268 disagreements were predicate differences.

vm-programs is unchanged at 144/3398, verified by running the suite either side
of this commit. It already carried its own compatibility path for the feature
set, which is why it alone kept working; that path now goes through the
regenerated types like everything else.

Generated with protoc v3.21.12 and protoc-gen-go v1.34.2, matching the versions
recorded in the previous files so the diff shows schema movement rather than
codegen churn. elf.pb.go is untouched: protosol no longer ships elf.proto.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
backend=stdlib bypassed the library and called crypto/ed25519 directly. That
is non-strict: it accepts small-order A and small-order R, which the strict
predicate rejects and mainnet rejects. So an operator flag decided which
signatures the node accepts. Two nodes on different settings would disagree on
block validity, and it is the flag reached for under exactly the pressure that
makes a silent fork worst.

The package comment already said the predicate "is not optional and not
configurable", and Configure sets DalekStrict before selecting anything. The
bypass contradicted both.

The library has its own crypto/ed25519-backed backend, and its rejection
pre-pass runs before dispatch regardless of which backend is active. Selecting
that instead keeps the diagnostic value -- it swaps out the r51 assembly, the
comb tables and the batch kernels, which is where an implementation bug would
realistically live -- while leaving acceptance untouched. The bypass flag is
gone entirely; every verification now goes through the library.

The test that pinned the divergence as deliberate is inverted to pin its
absence. It has to re-execute itself in a child process: backend selection is
one-shot per process by design, so the library can never hold key tables in two
formats, which means any earlier Configure would make this test skip. A test
that skips during "go test ./..." guards nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Drives ProcessInstruction rather than the verifier underneath it, so offsets
parsing, program-id routing and the predicate are covered together. Three cases
that discriminate: a valid signature accepted, small-order A and small-order R
rejected, and a tampered-but-well-formed signature failing on the equation
rather than on a byte-level gate.

The Firedancer corpus covers this path far more broadly at 3479 fixtures, but
it needs a ~7 GB external checkout and skips without it. These run in an
ordinary "go test ./..." and cost microseconds.

Deliberately absent: a non-canonical-A case. verify_strict accepts a
non-canonical A and hashes its original bytes, so pinning that looks like the
obvious fourth case, but it cannot be built. A non-canonical encoding requires
y < 19, and every curve point with such a y whose discrete log is computable is
already small-order, so the small-order gate rejects it before canonicality is
consulted. All 152 non-canonical-A fixtures in the Firedancer corpus expect an
error for that reason, which also means they do not discriminate: they pass
whether or not an implementation handles non-canonical A correctly. A test for
that bullet would read as coverage and prove nothing, so the reasoning is
recorded in a comment instead.

Test files never enter the binary, so none of this costs the node anything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The non-strict branch stays, so historical replay remains possible: blocks from
before Ed25519PrecompileVerifyStrict activated were validated without it, and
re-verifying them strictly would reject transactions the network accepted and
produce a different bank hash. Keeping an untested branch is what made it
dangerous, not the branch itself.

One input, both feature states, opposite verdicts. The signature is a
small-order construction that genuinely satisfies the stdlib equation -- A is
the identity, so [s]B - [k]A collapses to [s]B, which is the R it carries. It
is rejected only because strict verification refuses a small-order public key,
which makes it the cleanest probe for which predicate ran.

This also guards the failure mode that actually occurred. When the conformance
fixtures parsed with an empty feature set, the gate read inactive, the
non-strict branch ran, and 163 signatures were accepted that should have been
rejected. A feature-plumbing bug silently became an acceptance change. With
both directions pinned, that surfaces as a test failure rather than a quiet
divergence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Moves from 1625c183 to da0d045, about a hundred commits. The pin was an
ancestor of main, so this is a fast-forward rather than a branch change.

The reason to do it now is one commit in that range: 5e7ead9, "r51x5: stop
dereferencing a skipped lane's nil table". A malformed lane in a warm group
dereferenced a nil per-key table, which is reachable from network input. The
pin predates that fix. Also picked up: 53868d9, which makes a nil Cache
receiver fail closed uniformly instead of depending on the input, and 41720a2,
which gates r51 availability on the kernels actually compiled in rather than on
CPU features alone.

The rest is the sigprep extraction, the fixed-base and x8 Niels work, and
documentation. 118 files, none of which change the acceptance predicate.

Verified after the bump: pkg/sigverify and the ed25519 precompile tests pass,
and the Firedancer conformance suites are unchanged at ed25519 3479/3479,
secp256k1 2628/2628, secp256r1 13185/13185.

One pre-existing failure is untouched by this and should not be read as fallout
from it: TestExecute_AddrLookupTable_Program_Test_Create_Lookup_Table_Not_Idempotent
fails identically on the old pin, verified by reverting go.mod and rerunning it.
It is in the address lookup table program and has no path to ed25519.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
resolveNativeProgramById had no case for AddressLookupTableAddr, so
AddressLookupTableExecute was unreachable: every instruction for that program
returned InstrErrUnsupportedProgramId and all 31 address-lookup unit tests
failed. The implementation was complete and exported the whole time, which is
why nothing caught it at build time. Adding the case makes those 31 pass.

Config and Stake were missing the same way. Both have been migrated to BPF on
mainnet, so their accounts are loader-owned and route through the loader; this
resolver is only consulted when the program account's owner is NativeLoader,
which is the pre-migration shape. Wiring them therefore changes nothing about
current execution and restores historical replay.

A resolver test now pins every native program to its case. This class of bug is
silent by construction -- an implementation with no case still compiles, still
exports, and simply never runs -- so the mapping needs an explicit assertion
rather than relying on each program's own tests to notice.

Two fixes from the same review:

Conformance accounts get RentEpoch math.MaxUint64. protosol v5.4.0 removed
rent_epoch from AcctState, and I had dropped the field with it, but a converted
account's implied value is the maximum rather than zero. Leaving the Go zero
modelled every account as rent-paying.

The corpus revision is pinned at a87fc430 instead of tracking main. Both the
schema and the per-suite counts move with the corpus, so following main makes a
passing run unreproducible and makes a regression indistinguishable from an
upstream edit. That revision is the one every count in these commits was
measured against.

Precompile suites unchanged: ed25519 3479/3479, secp256k1 2628/2628,
secp256r1 13185/13185.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…o be

The doc comment said "Calling it twice returns an error rather than silently
ignoring the second call". Nothing enforced that. A second call re-ran the
whole path, and for BackendAuto it would return successfully while the library
had already pinned a different backend, so the caller was told it got something
it did not get.

Three changes:

Repeat calls are refused, with the already-resolved backend named in the error.
Refusing a repeat of the *same* backend too is deliberate: treating it as a
harmless no-op would make "Configure ran twice" invisible, and the second
caller still has no way to learn its configuration was discarded.

Validation now happens before anything is published. Cfg was assigned from the
argument before the backend name was checked, so a rejected name still became
visible to Backend() and to the startup log.

Backend installation moved into a helper, leaving Configure as validate,
install, publish. The r51 case grew a comment recording that the absence of a
fallback is the contract, not an oversight.

The tests run each case in a child process. Backend selection is one-shot by
design, so an in-process table would let the first Configure win and turn every
later case into a vacuous pass. That risk is not hypothetical: an earlier
version of the stdlib test skipped whenever another test had configured first,
which guarded nothing during "go test ./...".

TestConfigureChildPlumbingActuallyRuns guards the harness itself. If the child
marker or the -test.run pattern stopped matching, every subprocess test would
spawn a child that ran nothing, exit zero, and report a pass.

The r51 assertion holds on both kinds of machine: it accepts a resolution to
r51 or a clear error, and rejects only the outcome that would be a bug, which
is success while a different backend is actually active.

Passes under -race with -count=3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three things were unobservable at runtime: which backend actually resolved,
whether the accelerated one ever fell back on an internal fault, and how wide
the batches reaching it were.

The third is the one that matters and the one a counter cannot answer. Cost per
signature is a strong function of batch width -- eight signatures per AVX-512
group, so a stream of width-1 batches pays roughly 3.7x per signature what the
same work costs at width 8. A million signatures arriving one at a time and the
same million arriving in eights produce identical totals. Only the distribution
separates them, so this is a histogram, with per-width buckets up to 8 where
the group boundary sits and coarse buckets above it.

Recorded in Batch.Verify rather than at each drain site, so a new caller cannot
forget to instrument itself. It is an atomic add into a fixed array: no
allocation, no lock, nothing that can fail on the verification path.

pkg/sigverify keeps zero Mithril dependencies. It exposes Stats(); the reporter
lives in cmd/mithril/node and owns the logging.

Output goes to <run-dir>/sigverify.log via mlog.NamedFilef and nowhere else.
Terminal output is unchanged -- the only edit to node.go is the two-line call
that starts the reporter. Operator stderr already carries replay progress, and
batch width is diagnostic rather than something to watch live.

Lines are startup, per-interval and shutdown. Intervals report deltas, because
counters are monotonic for the process lifetime and a cumulative-only view lets
a long-healthy run hide a recent collapse in batch width. The shutdown line
means a short run still leaves a record, which matters most when the run ended
because of a verification problem.

One exception to the file-only rule: a rise in InternalFaultFallbacks also
warns to the operator log. It means the accelerated backend produced a result
it could not trust and recomputed on the portable path. That should never
happen, and it is a backend bug rather than an input condition, so silence is
the wrong default.

diffWidths matches buckets by upper bound rather than position, because Stats
omits empty buckets and two snapshots need not share a shape. Getting that
wrong would be quiet: the reporter would keep emitting plausible lines while
misattributing counts across boundaries.

Passes under -race.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@smcio smcio left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just made a few wording clarifications around what the stdlib backend does in Narya (still uses strictness checks).

@smcio
smcio merged commit 77750ef into alpenglow-dev Jul 31, 2026
1 check passed
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.

2 participants