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
- What checking a signature costs
- The map
- The cost model
- How it was designed
- What is proven
- Limits and open problems
- Glossary
§What checking a signature costs
A signature passes four independent tests (details in Verdicts below):
| Test | Work | Hardware |
|---|---|---|
| format | compare 16 header bytes, check the key index | loader |
| chains | 1 BLAKE3 digest, then 3 − digit chain steps per chain (105 on average, 101.4 measured), then 21 BLAKE3 for the commitment | chain lanes, BLAKE3 lane |
| Merkle | 7 BLAKE3, one per level | BLAKE3 lane |
| certificate | 1 strict Ed25519 verification per batch of 128 | Ed25519 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.
| Module | Role |
|---|---|
verify | record format, verdict codes, the software oracle expected_verdict |
datapath | one verify core: the loader, retirement, the wiring between blocks |
chains | three recirculating chain lanes, node and end banks, the row assembler |
hasher | the BLAKE3 lane and its oldest-first scheduler |
certs | the certificate cache in front of the Ed25519 core |
unit | HBM record reader, verdict writer, one core |
engine | the 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):
| Block | CLB LUT | Block RAM (tiles) | Notes |
|---|---|---|---|
| chain block | 48,237 | 246 | three 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 |
| hasher | 21,476 | 27 | 16,688 of them the BLAKE3 lane |
| core control | 7,296 | 0 | loader byte maps, per-context flags, retirement |
| certificate cache | 3,019 | 0 | two 512-bit compares; the entry rows became LUT RAM |
| loader registers | 185 | 0 |
In the routed kernel (build v1, Vivado, in context) the numbers are larger than the out-of-context estimate:
| Part | CLB LUT | Notes |
|---|---|---|
| one unit | 93,377–93,601 | core 89,495–89,716 (80,211 alone), reader ~1,966, writer ~1,917 |
Ed25519 verify core (1,1,1,1) | 79,786 | 698 DSP, 176 UltraRAM, 41 RAMB36 + 4 RAMB18 |
| cluster links and registers | 6,604 | relay pipes, delay lines, toggle links, AXI-Lite |
| engine, three units | 366,916 | of the 420,000 cap |
Build v3 (the card build, with the step 8 fixes and fix14) in context:
| Part | CLB LUT | Notes |
|---|---|---|
| three units | 281,708 | 93,903 each on average |
Ed25519 verify core (1,1,1,0, fix14) | 79,532 | 698 DSP, 176 UltraRAM; 84,435 routed alone (Ed25519 project, 007zzr) |
| cluster control and links | 7,472 | |
| engine | 368,713 | of 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
- 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 − digitsteps, 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. Chaincalways uses lanec mod 3, so no two lanes share a RAM. Result: lanes do only useful steps. →chains - 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 blockjfrom 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 - 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 - 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 - 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 - 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 inverify_core_hides_ed25519_latency. →datapath,certs - 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
kofnin SLR(3k + n − 1) / n. →engine,fpga/u280/verify/floorplan.tcl - 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-v2on 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 (&& !resettinggates, 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 - When the tool is wrong (build v2 → v3).
Problem: on the card, build v2 rejected the trusted key (
FAULTbit 3) after 8,846 cycles, so all 65,229 verdicts of the first burst were0x88; 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 constantdread 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
| Claim | Kind | Evidence |
|---|---|---|
expected_verdict agrees with the reference verifier on valid and tampered records | tested | verify::tests, 80 tampered records |
| The chain block reproduces the reference commitment message for 12 records, with context reuse | tested | tests/verify_chains.rs |
| One core matches the oracle byte for byte: valid records, every field class tampered, a forged certificate | tested | tests/verify_datapath.rs, 72 records |
| The cluster matches through its AXI-Lite and HBM ports; status registers agree | tested | tests/verify_engine.rs, 4 units × 40 records, software Ed25519 model |
| The whole kernel lowers to Verilog and closure-checks | tested | dsig_verify_export, 3 units: 1,295 instances |
| Core cost and timing | measured | Vivado OOC route, 80,211 LUT, WNS +0.258 ns at 4.878 ns |
| 35.12 cycles per signature, 729-cycle hash latency | measured | native simulation (cycle-accurate RHDL, not the card) |
| Kernel resources, 3 units: 366,916 LUT; timing missed by 1.278 ns | measured | build v1 routed (Vivado 2023.2), in-context hierarchy report |
| Kernel meets 205 MHz: 368,713 LUT, WNS +0.016 ns, WHS +0.006 ns | measured | build v3 routed (4d9c7fe), acceptance/route-accepted.txt |
| 1,572,864 real signatures verified valid, ≥ 16,959,195/s | measured | card, 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 right | measured | card, build v3, vrun-tamper-3: 0 mismatches, 68,958,083 cycles |
| Sustained (continuous) verification rate | not measured | only 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 latency | measured | Ed25519 project: OOC route 007zzi, native simulation |
§Limits and open problems
- 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.
- Four units. In context a unit is ~93.5k LUT, so four (~460k) do not fit; each would have to lose ~10k.
- HBM. 74 % of a port per unit is untested on the card.
- 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.
- Only the built scheme. W-OTS+
w = 4,K = 128, Haraka chains; other chain hashes compile but are not simulated in the verifier.
§Glossary
| Term | Meaning |
|---|---|
| Record | one signature plus its message, 1,664 bytes, 26 HBM beats |
| Verdict | one byte per record; 0x80 means valid |
| Context | a record’s slot in a unit’s RAMs, its number mod 64 |
| Ring | a chain lane whose output can feed its input again |
| End queue | chains whose revealed node is already their end |
| Strand | a sequence of dependent BLAKE3 compressions of one record |
| Row | one 64-byte block of the endpoint-commitment message |
| Unit | one reader, one verify core, one writer |
| Cluster | the units, their links, the registers and the certificate arbiter |
| OOC | out-of-context: one module placed and routed alone in Vivado |
§Record layout
One record = RECORD_BYTES = 1,664 bytes = 26 beats of 64 bytes.
| Bytes | Field | Beat |
|---|---|---|
| 0..64 | batch commitment (crate::certificate) | 0 |
| 64..128 | Ed25519 signature R ‖ S | 1 |
| 128..130 | key index, little endian | 2 |
| 130..162 | pk_hash | 2 |
| 162..178 | pk_nonce | 2 |
| 178..194 | message nonce | 2–3 |
| 194..418 | 7 Merkle siblings × 32 bytes, bottom up | 3–6 |
| 418..1642 | 68 chain nodes × 18 bytes | 6–25 |
| 1642..1658 | the 16-byte message | 25 |
| 1658..1664 | zero | 25 |
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.
| Bit | Name | Set when |
|---|---|---|
| 0 | VERDICT_FORMAT | commitment header bytes 0..16 are not DSIGFP01, profile 1, batch 128, or the key index is 128 or more |
| 1 | VERDICT_MERKLE | the Merkle climb from pk_hash (with the index’s low 7 bits) misses the commitment’s root |
| 2 | VERDICT_CHAINS | the commitment of the 68 chain ends is not pk_hash |
| 3 | VERDICT_CERT | strict Ed25519 verification of the certificate fails |
| 7 | VERDICT_WRITTEN | always |
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
crate::referenceis the scheme;expected_verdictis built on it.crate::chain_laneandcrate::blake3_coreare the hash lanes the unit reuses.- The planner’s
DsigVerifycomponent (featureplanner) prices this block for the chip planner and TUI.
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_UNITSverify 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; seePROFILE_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_verdictmeans the same thing.