Chapter 11 — Running the web3 Signing Device on Real Hardware

The previous chapters took a toy design (a blinky) all the way to silicon. This chapter does the same for a real security product: a secp256k1 Ethereum signer whose private key never leaves the die. You'll generate its Verilog from Sparkle Lean source, clock it with an on-chip PLL, flash it to a Tang Nano 20K, sign an Ethereum transaction hash from your PC over UART, and finally broadcast a real transfer to a local Ethereum node (§11.8) — watching the balance move on-chain and ecrecover land on the chip's own address.

This is the on-chip-key signer (IP/Crypto/EcdsaSignMsgDemo.lean, signZDemo): the key d is baked into the bitstream, the ECDSA nonce k is derived on-chip via RFC-6979, and the only thing that crosses the wire is the 32-byte transaction hash z. A compromised host learns neither d nor k; and because k never leaves the die, it cannot back d out of a signature (d = (s·k − z)·r⁻¹). Its policy-enforcing sibling — PolicySignDemo, which adds an on-chip allowlist + cap — is in `docs/ip-catalog/PolicySignDemo.md` (§11.10).

Everything below was run on real hardware: a Tang Nano 20K (Gowin GW2A-18) signing two live transfers on a local anvil node.

11.1 What the device does

signZDemo is a hardware security module in miniature:

d = 12345, address 0xeb4665750b1382df4aebf49e04b429aaac4d9929).

state machine (IP/Crypto/Rfc6979HW.lean) driving one on-chip SHA-256.

modular ALU with a 16-bit word-serial multiplier (shifts + a 17-bit adder, no DSP) sequenced by microcode over a BRAM register file (IP/Crypto/EcdsaSignSmall.lean).

The private key never travels the wire, and the nonce never leaves the chip — the two properties a "blind" software signer can't give you.

              Tang Nano 20K · GW2A-18 · rPLL 27→13.5 MHz (global clock)
 host (PC)   ┌────────────────────────────────────────────────────────────────┐
    │ z(32B) │   ┌─────────┐   z     ┌─────────────────────┐                    │
    ├───────▶│──▶│ UART RX │───────▶ │ RFC-6979  (on-chip) │   d = 12345         │
 ttyUSB1     │   └─────────┘         │ HMAC-SHA-256  K/V   │◀── baked, never     │
    │        │                       └──────────┬──────────┘    on the wire      │
    │        │                          k (never leaves the die)                 │
    │        │                                  ▼                                │
    │        │           ┌──────────────────────────────────────────┐           │
    │        │           │ secp256k1 signer                          │           │
    │        │           │  · 16-bit word-serial multiplier (no DSP) │           │
    │        │           │  · bit-serial modular add/sub ALU         │           │
    │        │           │  · BRAM register file + microcode FSM     │           │
    │[A5 5A  │   ┌─────────┐  r‖s    └─────────────────────┬──────────┘           │
    ◀─01|r|s]│◀──│ UART TX │◀──────────────────────────────┘                     │
             │   └─────────┘                                                      │
             └────────────────────────────────────────────────────────────────┘
import Sparkle
import Sparkle.Compiler.Elab
import IP.Crypto.EcdsaSignMsgDemo

open Sparkle.Core.Domain
open Sparkle.Core.Signal
open Sparkle.IP.Crypto.EcdsaSignMsgDemo

namespace Notebooks.Ch11

11.2 Generate the Verilog

The Sparkle top is signZDemo (uartRx) (bitDiv) : DemoOut, exposing uartTx and signDone. As in the blinky chapter, project the output you route to a pin and hand it to #synthesizeVerilog:

-- The signer is a deep FSM stack (RFC-6979/SHA-256 + secp256k1 signer);
-- give the elaborator room.
set_option maxRecDepth 100000
set_option maxHeartbeats 80000000

def signZTx
    (uartRx : Signal defaultDomain Bool)
    (bitDiv : Signal defaultDomain (BitVec 16)) :
    Signal defaultDomain Bool :=
  (signZDemo uartRx bitDiv).uartTx

#synthesizeVerilog signZTx

lake build on this chapter runs that elaboration and prints -- Verilog successfully generated!. For the FPGA flow we use the hierarchical emitter (smaller; keeps the sub-modules as separate Verilog modules), driven by a small script:

lake env lean fpga/tangNano20k/build/GenSignZDemoSynth.lean
# → writes fpga/tangNano20k/build/sign_z_demo.v (UART RX/TX, SHA-256, HMAC,
#   RFC-6979, the regfile + 16-bit word-serial multiplier, the ladder /
#   opctrl / signctrl engines, and the signing core signZSmallDemo).

The core we instantiate on the board is Sparkle_IP_Crypto_EcdsaSignMsgSmall_signZSmallDemo, whose parallel interface is (_gen_start, _gen_z[255:0], clk, rst) → (rOut[255:0], sOut[255:0], done) — the board wrapper feeds it z from its own UART RX and streams r‖s back from its own UART TX (§11.4).

11.3 Clock it with an rPLL — not fabric logic

On the small Gowin parts you must generate the core clock with the PLL primitive. Two natural-looking alternatives both fail on the GW2A-18:

wide-mux mapping (~69 % LUT4, unroutable on this dense design).

is dead — an LED probe on that clock never toggles, so the core never runs and the UART only spits noise.

A Gowin `rPLL` (27 → 13.5 MHz) fixes both: its output is a genuine global clock, and the netlist packs ~49 % LUT4 (0 hold violations, Fmax ≫ 13.5 MHz). The board wrapper (fpga/tangNano20k/sign_z_uart_top.v) instantiates it:

wire clk_div, pll_lock;
rPLL #(.FCLKIN("27.0"), .IDIV_SEL(1), .FBDIV_SEL(0), .ODIV_SEL(64),  // 27*1/2 = 13.5 MHz
       .CLKFB_SEL("internal"), /* … */)
  pll (.CLKOUT(clk_div), .LOCK(pll_lock), .CLKIN(clk), .CLKFB(1'b0), /* … */);
// bit_div = 13.5 MHz / 115200 − 1 = 116

(The larger GW5A parts close timing straight off the crystal, so the 50K/PolicySignDemo wrapper skips the PLL — but on the 20K, use it.)

11.4 The framed UART protocol

Give the device a framed request → response with a status byte from the start — don't stream raw bytes and make the host guess boundaries (§11.9 explains why). The board wrapper's UART front-end speaks:

request :  z            (32 bytes, big-endian)
response:  A5 5A         (2-byte sync marker)
           01            (status: 01 = signature ready; reserve EE = error)
           r  (32 bytes) s (32 bytes)

The device signs on receiving 32 bytes and then repeats the 67-byte response frame; the host syncs on A5 5A, checks the status, and reads r‖s. fpga/tangNano20k/sign_z_uart_top.v emits the frame; host/sign_z/sign_z.py parses it.

One UART byte, at the bit level (115200 8N1; 1 bit-time = 13.5 MHz / 115200 = 117 clk_div cycles):

              │◀── 117 clk_div cyc ──▶│
       ‾‾‾‾‾‾‾╲                       ╱‾‾‾‾ … 8 data, LSB-first … ‾‾╲       ╱‾‾‾‾‾   idle
  line        ╲_______________________╱                             ╲_____╱   (mark=1)
              start bit (0)            D0  D1  D2 … D7               stop(1)

And one whole transaction — request, on-chip sign, framed response — on a cycle time-line (clk_div = 13.5 MHz):

              32 z-bytes in           on-chip sign                 framed frames out
  uart_rx  ─┤▤▤▤ 32 bytes ▤▤▤├───────────────────────────────────────────────── idle
  bytecnt   0 1 2 … 31 →0                                                          0
  start_p   ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁█▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁  (1 cyc @ byte 32)
                           │◀─ ~1.3M clk_div cyc  (RFC-6979 + ECDSA ≈ 0.1 s) ─▶│
  have      ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁███████████████
  uart_tx   idle ──────────────────────────────────────────── ┤A5 5A 01 r s├┤A5…├

11.5 Pin constraints

fpga/tangNano20k/sign_z_uart.cst maps the 27 MHz clk (pin 4), the rst button (pin 88), uart_rx_line/uart_tx (pins 70/69, wired to the on-board BL616 bridge), and six user LEDs (pins 15–20; the wrapper blinks a heartbeat on three and lights the other three when a signature is ready — a handy "is the clock alive / did it sign" probe).

11.6 Synthesize, place-and-route, flash

Same open-source Gowin flow as the other Tang Nano demos. The signer core is dense, so use the flat ABC9 packing (as sign_z_uart does):

cd fpga/tangNano20k
yosys -p "read_verilog -sv sign_z_uart_top.v; read_verilog -sv build/sign_z_demo.v; \
          synth_gowin -top sign_z_uart_top -run :map_luts; \
          read_verilog -icells -lib -specify +/abc9_model.v; abc9 -maxlut 8; \
          synth_gowin -top sign_z_uart_top -run map_cells:; \
          write_json build/sign_z_uart.json"
nextpnr-himbaechel --device GW2AR-LV18QN88C8/I7 --vopt family=GW2A-18C \
    --vopt cst=sign_z_uart.cst --json build/sign_z_uart.json --write build/sign_z_uart_pnr.json
gowin_pack -d GW2A-18C -o build/sign_z_uart.fs build/sign_z_uart_pnr.json

# Flash to SPI flash (-f) so the design is PERSISTENT — see §11.9.
openFPGALoader -f -b tangnano20k build/sign_z_uart.fs

(Routing is slow on this arithmetic-dense design — the himbaechel router grinds down the last congestion over many minutes — but it closes at ~49 % LUT4 with 0 hold violations.)

11.7 Sign a hash from the host

host/sign_z/sign_z.py is dependency-free (pure-Python secp256k1 + raw termios), so --selftest checks the crypto with no board:

python3 host/sign_z/sign_z.py --selftest         # pubkey + a sample signature
python3 host/sign_z/sign_z.py --port /dev/ttyUSB1 --z 123456789
# → sends z, reads the framed r‖s, prints "signature verifies against Q = 12345·G : YES ✓"

The FPGA UART is the interface-1 node — /dev/ttyUSB1, not ttyUSB0 (that's JTAG; opening it as a serial port wedges JTAG). The driver handles the two on-board-bridge quirks for you (§11.9): it pulses DTR/RTS to enable host→FPGA, and reads on a fresh fd after the write.

11.8 Broadcast a real transfer to a local node (anvil)

host/sign_z/eth_sign_tx.py builds a canonical EIP-1559 transfer from the signer's address, gets (r,s) from the FPGA over UART, normalizes to low-s (EIP-2), recovers the y-parity, assembles the signed raw transaction, and broadcasts it over JSON-RPC — zero dependencies beyond stdlib (urllib; no web3).

  host (eth_sign_tx.py)          FPGA signer               anvil (local node)
  ─────────────────────          ───────────               ──────────────────
  build EIP-1559 tx
  z = keccak256(0x02‖rlp([…]))
        │  z (32 bytes)  ──────▶  RX assembles z
        │                         RFC-6979 k + ECDSA sign   (~0.1 s on-chip)
        │  ◀── [A5 5A|01|r|s] ──  TX streams the framed r‖s
  low-s normalize (EIP-2)
  recover y-parity
  assemble signed raw tx
        │  eth_sendRawTransaction ──────────────────────▶   mine block
        │  ◀───────────────── receipt {status: 0x1} ────────
  assert ecrecover(z,r,s) == 0xeb46…4d9929   (the baked key's address) ✓
# start a local chain (chainId 31337)
anvil                 # (nix: `nix-shell -p foundry --run anvil`)

# fund the FPGA signer's address from a pre-funded anvil account
cast send --private-key 0xac09…bacb478… 0xeb4665750b1382df4aebf49e04b429aaac4d9929 --value 100ether

# the FPGA signs a real EIP-1559 tx hash and broadcasts the transfer
python3 host/sign_z/eth_sign_tx.py --port /dev/ttyUSB1 \
    --to 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 --value 1000000000000000000

Output — a real transaction, signed on the chip, mined, funds moved:

from      0xeb4665750b1382df4aebf49e04b429aaac4d9929   ← the FPGA (d=12345)
z         0x9b6d55a5…256c8731        keccak256(0x02‖rlp([…]))
signature: from the FPGA (over UART)
r         0x8bce3d8d…4a5ac30f
s         0x67d73d9f…527ab1f9
y_parity  1   (ecrecover → 0xeb46…4d9929 ✓)
broadcast tx 0xc7d826d8…802c5edf
receipt status 0x1
recipient 10001.0000 → 10002.0000 ETH  (+1.0)

ecrecover returning the baked key's address is the whole point: the signature that moved on-chain funds was produced by the physical chip, from a key that never left it. To run the pipeline without a board, swap --port … for --ref — it signs with the same RFC-6979 in pure Python, byte-for-byte identical to the chip (verified against the chip's signature for z = 123456789).

11.9 Field notes: bringing the 20K up on real silicon

Simulation never shows these; each cost real time on the bench.

clock is dead on the GW2A-18; crystal-direct won't route.

interface 0 = JTAG and interface 1 = the FPGA UART (ttyUSB0/ttyUSB1 on the 20K). Use the UART node only. The bridge forwards host→FPGA only after DTR/RTS are pulsed through a few transitions — a static level doesn't enable it.

withholds read bytes until the write drains — write the request on one fd, close it, then read on a fresh fd.

openFPGALoader run churns USB; use -f (SPI) so the design survives, and after heavy JTAG use a physical re-plug is the only reliable recovery. (host/sign_z/sign_z.py bakes the DTR/RTS pulse, the two-fd write/read, and retries in — full-entropy z may need a couple of tries.)

11.10 Where to go next

— the on-chip-key signer's protocol and the RFC-6979 / signer internals.

— the policy-enforcing sibling: an on-chip allowlist + cap sliced from the very bytes it hashes, so a compromised host can't make the chip sign an attacker-chosen transfer ("clear signing"). Runs on the larger Tang Nano 50K (GW5A-60), which closes timing straight off the crystal.

same device-top pattern applied to a FIDO2 security key (P-256, CTAPHID) for Google / GitHub login.

computes over (IP/Crypto/Proof/P256FieldTheorems.lean).

end Notebooks.Ch11