Skip to main content
A voucher is a fixed-layout, little-endian, 202-byte packet with no optional fields. Bytes 0..105 are the message the secure element signs.

The layout

Signed message: 105 bytes. Total packet: 202 bytes.

Why 202 is the budget

202 bytes fits an NDEF record for NFC and a QR code at version 10, error-correction level M — 213 bytes of capacity in byte mode, on a 57×57 grid that scans cleanly off a phone screen.
That means the offline path is not hard-blocked on NFC, which matters because the low-cost Android handsets this product targets do not all have usable host card emulation. QR is the transport that cannot fail on hardware you do not control.
Any field added later comes out of that budget. Growing past 213 bytes costs you QR version 10, and the fallback is a denser code that scans worse in a dim shop.

The layout is a contract

The TypeScript in @nelo/voucher and the Rust in programs/nelo_vault must produce byte-identical signed messages, or every signature fails verification. That is not left to careful reading. packages/voucher/vectors/voucher-v1.json is a frozen set of golden vectors:
  • Generated by the Rust side, because the chain is authoritative.
  • Asserted by both sidestests/vectors.rs and test/voucher.test.ts.
  • TypeScript also verifies signatures that Rust produced, so the check covers the crypto and not only the byte layout.
If the two ever drift, the vectors are where it surfaces — not in the field.

Encoding

Decoding is total — a packet of the wrong length, or carrying an unknown version, is refused rather than partially parsed.

remaining_after is a claim, not a fact

This is the field most likely to be misread. remaining_after is what the payer’s device asserts its balance will be once this voucher settles. It exists so a merchant with no network can sanity-check a voucher against the last vault balance they saw cached. The program does not verify it. It cannot — it has no idea what other vouchers are in flight. What the program checks is the real constraint: amount <= vault.balance at redemption, and the replay window.
A payer running a modified device can sign successive sequences that each carry a plausible remaining_after while together exceeding their collateral. The replay window stops the same sequence settling twice; it does not stop different sequences over-spending in aggregate.report_conflict only accepts two vouchers at the same sequence, so that pattern does not trigger the freeze either. This is the exposure the insurance line prices — see the reserve model. Widening the conflict proof to accept an inconsistent-remaining_after pair would shorten the window a compromised device keeps trading in; it is an open design decision, not an oversight.

Low-S, and why it bites

P-256 signatures have two valid forms for every signature: (r, s) and (r, n − s). Android returns whichever it computes. The Solana precompile accepts only the low-S form. The failure is nasty because it is intermittent and silent-looking: roughly half your signatures verify fine on the phone and are rejected on chain with no obvious pattern.
Both the normalisation and the failure mode are pinned by tests that run without a handset.