Transaction Science open standards monorepo. Each subdirectory is a self-contained reference protocol with its own README and licence. - openpay/ Apache-2.0 payment-acceptance stack (Rust, 26 crates) - smart-byte/ CC-BY-4.0 content-addressed value carrier (spec) - eoc/ CC-BY-4.0 energy-optimised compute (spec) The wire format and the right to fork are public. The optional hosted services are the offer; the protocols are not.
5.6 KiB
Phase 3 — op-emv Complete
Status: Draft v0.3 Date: 2026-05-17
What shipped
A zero-allocation BER-TLV codec for EMV payment payloads. This is what the
FFI layer (op-ffi-swift, op-ffi-jni) will hand bytes from ProximityReader
on iOS and IsoDep on Android, and what op-fraud will walk to extract
feature vectors.
Modules
| Module | Responsibility |
|---|---|
error |
Sealed Error enum with byte-offset reporting on every variant. |
tag |
Tag (u32-packed 1–4 byte tag), TagClass, 22 named EMV tag constants. |
stream |
TlvIter + TlvRef<'a> — zero-alloc, no_std-compatible streaming decoder. |
tree |
Tlv + TlvBody — heap-allocated tree with find() and encoded_len(). Behind std feature. |
Design choices
- Zero alloc on the hot path.
TlvIterborrows from the input slice and never allocates. The secure-element interface and on-device fraud pre-pass use this layer. no_std-compatible. Default feature isstd; turn it off to ship into bare-metal contexts.#![forbid(unsafe_code)]. Every index is bounds-checked. Combined with property tests, the parser is panic-safe on arbitrary bytes — critical because the secure element hands us untrusted input.- Padding tolerated. EMV Book 3 §B.3 permits
0x00bytes between TLVs; the iterator silently skips them. - Error variants carry byte offsets. Debugging a parse failure on a 1 KB EMV response without knowing where in the bytes it failed is miserable; every error variant carries the offending offset.
Verified ground truth
| Claim | Source / verification |
|---|---|
| BER-TLV tag is 1–4 bytes | EMV Book 3, Annex B |
Multi-byte tag indicator = lower 5 bits of first byte = 0x1F |
EMV Book 3 §B.1 |
| Tag continuation flag = bit 8 of subsequent bytes | EMV Book 3 §B.1 |
| Constructed bit = bit 6 of first tag byte | EMV Book 3 §B.1 |
| Short-form length = 0–127 in a single byte | EMV Book 3 §B.2 |
Long-form length: 0x8N + N value bytes, N ≤ 4 |
EMV Book 3 §B.2 |
Indefinite length (0x80 alone) banned in EMV |
EMV Book 3 §B.2 |
FCI Template tag 6F is constructed Application class |
Independently verified via Python computation |
DF Name 84 is primitive Context-Specific (NOT Application) |
Independently verified — caught and corrected during testing |
FCI Proprietary A5 is constructed Context-Specific |
Independently verified |
| All 22 named tag constants' wire-length classifications | Independently verified by Python script |
| Canonical FCI test vector parses correctly | Hand-decoded the 28-byte sequence; assertions match |
| Tap-to-Pay flat byte offsets (0, 9, 18, 23, 28, 33) | Independently verified by Python walk |
Test coverage
| File | Tests | Notes |
|---|---|---|
tag.rs |
16 | Tag class, P/C bit, multi-byte parsing, round-trip write/read |
stream.rs |
16 | Length forms, indefinite rejection, padding, offsets, constructed children |
tree.rs |
6 | Owned tree, find, encoded_len, nested parsing |
conformance.rs |
9 | Real vectors: FCI template, Tap-to-Pay flat, Tap-to-Pay nested |
properties.rs |
3 | proptest: no panics on random bytes, stream/tree agreement |
| Total Phase 3 | 50 | |
| Cumulative (Phases 1+2+3) | 112 |
Conformance vectors
| File | Contents | Source |
|---|---|---|
fci_template.hex |
6F1A840E315041592E5359532E4444463031A5088801025F2D02656E |
Canonical Payment System Environment FCI from EMV Book 1 Annex A. Selects the 1PAY.SYS.DDF01 directory; declares English language; SFI = 2. |
taptopay_flat.hex |
6 standard transaction-data tags | $1.00 USD purchase, USA, 2026-05-17. Synthetic but byte-for-byte what a terminal kernel emits. |
taptopay_nested.hex |
Same fields wrapped in E1 private constructed |
Tests recursion. |
Bugs caught during construction (kept for the record)
-
DF_NAMEclass assertion was wrong. I initially assertedTag::DF_NAME.class() == TagClass::Application. The Python cross-check showed0x84starts with bits10, making itContextSpecific. Fixed before any phase artifact shipped. -
Nested-find test vector was malformed. My first draft had
6F 0C A5 0A ...— but A5's declared length (10) exceeded its actual inner content (8 bytes). Python overrun-detection caught it. Corrected to6F 0A A5 08 ....
Both bugs were caught because every numerical claim in the test suite is independently verified by Python before committing to Rust. The same Python scripts live in our verification log; they're how we keep ourselves honest without a Rust toolchain in the sandbox.
What is NOT yet implemented (deferred)
- Encoder. We can decode any EMV BER-TLV blob. Encoding from a
Tlvtree back to bytes is straightforward but waits on Phase 4 (op-rails-card) because that's where we need it: producing the response payload to send back through the PSP for refunds and reversals. - EMVCo certificate validation. Verifying the issuer-side cryptogram
(
9F26) requires the public-key infrastructure: schemes' root CAs, issuer certificates, signed static/dynamic data. That's a substantial subsystem with its own crate —op-emv-crypto— deferred to afterop-rails-cardis wired up. - Fuzz corpus. We have property tests; a dedicated
cargo-fuzzcorpus with AFL-discovered crashes goes in once the encoder lands.
Next: Phase 4 — op-rails-card
The PSP adapter trait plus the first concrete driver: Hyperswitch. This is where the verified-but-deferred ISO 20022 builder bindings get wired up and where the EMV decoder feeds real Tap-to-Pay payloads into a sandbox HTTPS flow.