Skip to main content

Module verify

Module verify 

Source
Expand description

The DSig verifier: checks signatures at the rate the signer makes them.

The verifier reads DSig signatures from HBM and writes one verdict byte per signature, at the rate the signer makes them, entirely in RHDL.

In numbers (October 2026). On the card, three verify units checked 1,572,864 real signatures from the signer’s card run, every one valid, in 19,012,289 cycles: at least 16,959,195 verifications per second (finite burst, build v3, clock measured at 205.00 MHz). A second burst of 1,572,864 records with every 97th tampered got every verdict right (16,215 rejected, 0 mismatches). The kernel routes at 368,713 CLB LUT of the 420,000 cap with +0.016 ns slack at 205 MHz. One unit checks a signature every 36.3 cycles on the card (35.12 in native simulation), and a signature whose certificate is already checked takes 729 cycles (3.5 µs, simulation) from its first beat to its verdict. Reproduce the simulation figures with:

cargo test --profile sim --test verify_datapath period -- --ignored --nocapture

§The story in five minutes

The job. The signer makes 12 million signatures a second; something has to check them as fast. Checking a DSig signature means redoing about half of the signer’s hash work, plus one Ed25519 check per batch of 128.

The first idea was to reuse the signer’s chain engine. It runs every chain through all three steps; a verifier needs only 3 − digit of them, so half the work would be wasted. Closing each lane into a ring, where a chain goes round until it is done, fixed that.

Then, one idea per step: build the commitment message from a byte map just before BLAKE3 needs it; let 64 records share one BLAKE3 lane, oldest first; give chains that are already finished their own queue (41.72 → 35.12 cycles); check each certificate once and remember it.

What broke first was an assumption: the Ed25519 check takes about 52,000 cycles, not 2,000, so verdicts now wait in a queue while records move on. Then the wires: the first card build missed the clock by 1.278 ns, and every failing path was traced to one of four causes. Then the tools: build v2 met timing but rejected a valid trusted key on the card, and the cause was Vivado, not the design — it mis-wired a preloaded constant table in the Ed25519 core (step 9).

Where it stands. Build v3 runs on the card at 16.96 million verifications per second for a 1.57-million-signature burst, faster than the signer’s 12.04 million.

Where you come in. A fourth unit needs each one ~10k LUT smaller, and the verifier has only ever run with Haraka chains. See the good first contributions.

§Contents

  1. What checking a signature costs
  2. The map
  3. The cost model
  4. How it was designed
  5. What is proven
  6. Limits and open problems
  7. Glossary

§What checking a signature costs

A signature passes four independent tests (details in Verdicts below):

TestWorkHardware
formatcompare 16 header bytes, check the key indexloader
chains1 BLAKE3 digest, then 3 − digit chain steps per chain (105 on average, 101.4 measured), then 21 BLAKE3 for the commitmentchain lanes, BLAKE3 lane
Merkle7 BLAKE3, one per levelBLAKE3 lane
certificate1 strict Ed25519 verification per batch of 128Ed25519 verify core, behind a cache

So a unit’s two busy resources are its three chain lanes (101.4 steps per signature, about 34 per lane) and its one BLAKE3 lane (29 compressions). Its period is set by the lanes: 105 / 3 = 35 cycles, against 29 for BLAKE3. In plain terms: three chain lanes keep one BLAKE3 lane about 83 % busy, and the pair finishes a signature every 35 clocks.

§The map

Read datapath first: it shows how the blocks fit together.

ModuleRole
verifyrecord format, verdict codes, the software oracle expected_verdict
datapathone verify core: the loader, retirement, the wiring between blocks
chainsthree recirculating chain lanes, node and end banks, the row assembler
hasherthe BLAKE3 lane and its oldest-first scheduler
certsthe certificate cache in front of the Ed25519 core
unitHBM record reader, verdict writer, one core
enginethe card kernel: registers, units, registered links, the Ed25519 core

Host side: dsig_verify_host builds records from a signer card run and checks every verdict; dsig_verify_export writes the Verilog bundle; fpga/u280/verify/ holds the runner, packaging and floorplan.

§The cost model

Where one core’s 80,211 LUT go (Vivado OOC, routed):

BlockCLB LUTBlock RAM (tiles)Notes
chain block48,237246three rings of 16.0k each: ~12k of Haraka lane (T-tables in rounds 3..6) plus ~4k of ring logic; 148 for the row assembler’s pk_nonce copy
hasher21,4762716,688 of them the BLAKE3 lane
core control7,2960loader byte maps, per-context flags, retirement
certificate cache3,0190two 512-bit compares; the entry rows became LUT RAM
loader registers1850

In the routed kernel (build v1, Vivado, in context) the numbers are larger than the out-of-context estimate:

PartCLB LUTNotes
one unit93,377–93,601core 89,495–89,716 (80,211 alone), reader ~1,966, writer ~1,917
Ed25519 verify core (1,1,1,1)79,786698 DSP, 176 UltraRAM, 41 RAMB36 + 4 RAMB18
cluster links and registers6,604relay pipes, delay lines, toggle links, AXI-Lite
engine, three units366,916of the 420,000 cap

Build v3 (the card build, with the step 8 fixes and fix14) in context:

PartCLB LUTNotes
three units281,70893,903 each on average
Ed25519 verify core (1,1,1,0, fix14)79,532698 DSP, 176 UltraRAM; 84,435 routed alone (Ed25519 project, 007zzr)
cluster control and links7,472
engine368,713of the 420,000 cap; WNS +0.016 ns, WHS +0.006 ns

So three units fit with ~51k LUT to spare and four do not (~462k).

Memory per record in flight: 26 beats in, then 64 contexts’ worth of node, end, header, check and chaining-value RAM in block RAM. HBM read bandwidth is 26 beats per 35 cycles per unit, 74 % of one 64-byte port at 205 MHz.

§How it was designed

  1. Recirculate instead of a fixed timetable. Problem: the signer’s chain engine runs every chain through all three steps in a fixed pipeline. A verifier needs only 3 − digit steps, 105 of 204 on average, so that engine would waste half of every lane (68 cycles per signature instead of 35, model). Idea: close each lane into a ring; a chain goes round again until it is an end, and a new chain takes any slot that comes back empty. Chain c always uses lane c mod 3, so no two lanes share a RAM. Result: lanes do only useful steps. → chains
  2. Assemble commitment blocks on demand. Problem: the 1,240-byte commitment message interleaves 18-byte ends with 64-byte blocks, and ends finish out of order. Idea: store ends in fifteen banks (c mod 15), so any five consecutive chains can be read at once, and build block j from a fixed byte map just before BLAKE3 uses it. Rejected: a packer pass that rewrites the ends into rows (one more pass and 1 KiB per context). → ROW_SOURCE
  3. Interleave records on one BLAKE3 lane. Problem: a record’s 29 compressions form three strands of dependent steps, each 34 cycles after the last. Idea: 64 contexts; every clock the scheduler issues the next compression of the oldest record that has one ready. Result: 29 compressions per 35 cycles. A cycle model (Python, before any RTL) showed 32 contexts already reach 35.15 cycles; 64 hide up to 2,000 cycles of certificate latency. → hasher
  4. Digit-3 chains get their own queue (41.72 → 35.12 cycles). Problem: the first core ran at 41.72 cycles per record (native simulation, 256 records) with lanes 81 % and BLAKE3 70 % busy: neither was the limit. A chain whose digit is 3 needs no step, but it waited at the head of the lane’s feed queue for the end port, and the lane slot went unused, about six times per record and lane. Idea: a separate end queue. Result: 35.12 cycles (measured, native simulation); the lanes are now the limit, as designed. Also tried: issuing digests first in the BLAKE3 scheduler; it changed nothing measurable (41.71) but costs little and is kept. → chains
  5. Check each certificate once, compare it whole. Problem: Ed25519 verification costs about a thousand times the hash work. Idea: an 8-entry cache keyed by batch id; a record reuses an answer only if its whole 128-byte certificate matches, exactly like the reference VerifiedBatch. An entry a waiting record points at is never replaced. Result: one Ed25519 check per batch of 128 (simulation: 10 checks for 4 units × 40 records with 3 forged certificates). → certs
  6. Verdicts wait for certificates, contexts do not. Problem: the Ed25519 verify core (1,1,1,1) takes 52,000–83,000 cycles per check (Ed25519 project, native simulation: isolated 52,670, saturated median 63,026), not the 2,000 the first design assumed. With retirement in record order, every unit would stand still at each batch start for ~50k cycles out of every ~4.5k cycles of work, about ten times slower. Idea: split retirement. A record leaves its context when its hash work is done; its verdict waits in a 4,096-entry queue (one block RAM) until its batch’s certificate settles, and the cache grows from 8 to 64 entries so ~20 batches can wait at once. Result: see the simulation with a 60,000-cycle certificate model in verify_core_hides_ed25519_latency. → datapath, certs
  7. Register every unit boundary, pin units to dies. Problem: the signer’s builds v6–v10 showed that unregistered paths across SLRs cannot meet 205 MHz, and that only hard pblocks hold blocks in place. Idea: relay pipes, delay lines and toggle links between each unit and the cluster; unit k of n in SLR (3k + n − 1) / n. → engine, fpga/u280/verify/floorplan.tcl
  8. Shallow logic where wires are long (build v1 → v2). Problem: the first kernel link missed 205 MHz by 1.278 ns with 38,287 failing endpoints (measured, routed), although the core alone met it with +0.258 ns. Grouping every failing endpoint by hierarchy (/extra/dsig-verify/analysis-v2 on Wolverine) gave four causes: retirement (retired mod 64 → the head’s flags → two 32-bit compares → a clear of that context in all three rings and the hasher, 9–13 levels, most endpoints); the reset inside logic cones (&& !resetting gates, from a reset register the placer put in SLR1); the arbiter and key registers feeding the Ed25519 core’s input logic (11 levels); and HBM ready signals crossing into unit 0’s write relays, which the placer put in SLR1 because SLR0 was 82 % full. Ideas: small occupancy and backlog counters instead of 32-bit compares, and the context clear one cycle late (nothing touches a freed context for 26 cycles); no reset gates where every fed register resets itself; the Ed25519 request held in registers; the read request leaves the unit from registers; the HBM-facing stage of every link pinned to SLR0; stronger placement and routing directives. Result: build v2 met 205 MHz (WNS +0.016 ns, 367,915 LUT). → datapath, tools/wolverine-verify.sh
  9. When the tool is wrong (build v2 → v3). Problem: on the card, build v2 rejected the trusted key (FAULT bit 3) after 8,846 cycles, so all 65,229 verdicts of the first burst were 0x88; yet the key is a valid point, and RHDL simulation, an Icarus replay of the generated Verilog and a whole-kernel simulation all loaded it. Idea: simulate what Vivado built. Exporting the routed Ed25519 core as a netlist (write_verilog -mode funcsim) and replaying the RHDL vectors in xsim reproduced the rejection; per-cycle state dumps first differed at cycle 65 in the aux machine’s constant bank, a 32 × 255 table preloaded with four constants and never written. Vivado 2023.2 had trimmed its 16 always-zero output bits and wired the output registers as if the bits were packed, while the RAM’s INIT strings kept the original layout, so the constant d read back with 16 wrong bits. Fix: the Ed25519 project replaced every constant bank with an RHDL literal and a registered mux (fix14, ConstRom); a netlist replay of fix14 had 0 mismatches. Result: build v3 loads both trusted keys used so far and runs at 16.96M/s on the card. Lesson: when a card disagrees with simulation, replay the routed netlist. → crates/ed25519/src/machine.rs, measure/gate/

§What is proven

ClaimKindEvidence
expected_verdict agrees with the reference verifier on valid and tampered recordstestedverify::tests, 80 tampered records
The chain block reproduces the reference commitment message for 12 records, with context reusetestedtests/verify_chains.rs
One core matches the oracle byte for byte: valid records, every field class tampered, a forged certificatetestedtests/verify_datapath.rs, 72 records
The cluster matches through its AXI-Lite and HBM ports; status registers agreetestedtests/verify_engine.rs, 4 units × 40 records, software Ed25519 model
The whole kernel lowers to Verilog and closure-checkstesteddsig_verify_export, 3 units: 1,295 instances
Core cost and timingmeasuredVivado OOC route, 80,211 LUT, WNS +0.258 ns at 4.878 ns
35.12 cycles per signature, 729-cycle hash latencymeasurednative simulation (cycle-accurate RHDL, not the card)
Kernel resources, 3 units: 366,916 LUT; timing missed by 1.278 nsmeasuredbuild v1 routed (Vivado 2023.2), in-context hierarchy report
Kernel meets 205 MHz: 368,713 LUT, WNS +0.016 ns, WHS +0.006 nsmeasuredbuild v3 routed (4d9c7fe), acceptance/route-accepted.txt
1,572,864 real signatures verified valid, ≥ 16,959,195/smeasuredcard, build v3, vrun-clean-1: 19,012,289 cycles, clock 204,997,459–205,004,645 Hz, finite burst
1,572,864 records with 16,215 tampered: every verdict rightmeasuredcard, build v3, vrun-tamper-3: 0 mismatches, 68,958,083 cycles
Sustained (continuous) verification ratenot measuredonly finite bursts started from reset
Ed25519 verify core: 82,184 LUT, 698 DSP, WNS +0.094 ns; 735 cycles per check, 52,670-cycle isolated latencymeasuredEd25519 project: OOC route 007zzi, native simulation

§Limits and open problems

  1. Continuous rate. The card numbers are finite bursts from reset; back-to-back runs are not measured. A tampered burst runs 3.6× slower (68.96M cycles), because a tampered certificate cannot reuse a cached Ed25519 answer; how slow a hostile stream can make the verifier is not characterized.
  2. Four units. In context a unit is ~93.5k LUT, so four (~460k) do not fit; each would have to lose ~10k.
  3. HBM. 74 % of a port per unit is untested on the card.
  4. The first verdict waits for Ed25519. A run’s first verdict comes ~52,000 cycles after its first record (one certificate check); after that the queue hides the check. A run shorter than ~1,500 records per unit is dominated by that wait.
  5. Only the built scheme. W-OTS+ w = 4, K = 128, Haraka chains; other chain hashes compile but are not simulated in the verifier.

§Glossary

TermMeaning
Recordone signature plus its message, 1,664 bytes, 26 HBM beats
Verdictone byte per record; 0x80 means valid
Contexta record’s slot in a unit’s RAMs, its number mod 64
Ringa chain lane whose output can feed its input again
End queuechains whose revealed node is already their end
Stranda sequence of dependent BLAKE3 compressions of one record
Rowone 64-byte block of the endpoint-commitment message
Unitone reader, one verify core, one writer
Clusterthe units, their links, the registers and the certificate arbiter
OOCout-of-context: one module placed and routed alone in Vivado

§Record layout

One record = RECORD_BYTES = 1,664 bytes = 26 beats of 64 bytes.

BytesFieldBeat
0..64batch commitment (crate::certificate)0
64..128Ed25519 signature R ‖ S1
128..130key index, little endian2
130..162pk_hash2
162..178pk_nonce2
178..194message nonce2–3
194..4187 Merkle siblings × 32 bytes, bottom up3–6
418..164268 chain nodes × 18 bytes6–25
1642..1658the 16-byte message25
1658..1664zero25

The first 1,642 bytes are exactly DsigSignature::to_bytes.

§Verdicts

The verifier writes one byte per record, in record order. Bit 7 (VERDICT_WRITTEN) is always set, so a zero byte means “never written”. A record is valid exactly when its byte is VERDICT_VALID.

BitNameSet when
0VERDICT_FORMATcommitment header bytes 0..16 are not DSIGFP01, profile 1, batch 128, or the key index is 128 or more
1VERDICT_MERKLEthe Merkle climb from pk_hash (with the index’s low 7 bits) misses the commitment’s root
2VERDICT_CHAINSthe commitment of the 68 chain ends is not pk_hash
3VERDICT_CERTstrict Ed25519 verification of the certificate fails
7VERDICT_WRITTENalways

expected_verdict computes the same byte in software. The card run checks every byte against it, and expected_verdict itself is tested against DsigSignature::verify: valid ⇔ the reference accepts.

§Neighbours

Modules§

certs
The verifier’s certificate cache, in front of the Ed25519 verify core.
chains
The verifier’s chain block: three recirculating chain lanes.
datapath
One verify core: records in, verdict bytes out, in order.
engine
The verifier card kernel: VERIFY_UNITS verify units, HBM, registers.
hasher
The verifier’s hasher: one BLAKE3 lane for every record in flight.
unit
One verify unit: its HBM record reader, its verify core, its verdict writer.

Constants§

MESSAGE_BYTES
Bytes of the signed message (DSIG_MESSAGE_BYTES; see PROFILE_ID).
MESSAGE_OFFSET
Offset of the message inside a record (right after the signature).
RECORD_BEATS
HBM beats per record (64 bytes each).
RECORD_BYTES
Bytes of one verifier input record: the wire signature, the message and zero bytes up to a whole beat (profile 1: 16-byte message, 1,664 bytes, 26 beats; profile 2: 64-byte message, 1,728 bytes, 27 beats).
VERDICT_CERT
Verdict bit 3: the Ed25519 certificate does not verify.
VERDICT_CHAINS
Verdict bit 2: the chain ends do not commit to pk_hash.
VERDICT_FORMAT
Verdict bit 0: malformed commitment header or key index ≥ 128.
VERDICT_MERKLE
Verdict bit 1: the Merkle path does not lead to the commitment’s root.
VERDICT_VALID
The byte of a valid signature.
VERDICT_WRITTEN
Verdict bit 7: set on every written verdict.

Functions§

commitment_header
The 16 header bytes every well-formed commitment starts with: DSIGFP01, profile 1 and batch size 128, little endian.
expected_verdict
The verdict byte the hardware must write for record, given the trusted Ed25519 key.
record
Builds one input record from a wire signature and its message.
reference_accepts
Whether the reference verifier accepts the record’s signature for its message. Used to check that expected_verdict means the same thing.