Skip to content

Releases: TLAY-IO/boat-mer

BoAT MER v1.0.1 — buyer replay guard

Choose a tag to compare

@linyao0177 linyao0177 released this 01 Sep 13:48

Buyer integrators should read the first section before upgrading — this release changes the size of a public struct.

⚠️ Behaviour change — boat_buyer_ctx_t grows, and must be allocated statically

The buyer engine's nonce replay guard changed from a 256-byte Bloom filter to a bounded exact-prefix set held inside boat_buyer_ctx_t. sizeof(boat_buyer_ctx_t) grows from 480 bytes to 33,000 (~32 KiB).

Allocate the context statically. A stack-allocated boat_buyer_ctx_t will overflow typical embedded task stacks. tests/test_buyer.c and the reference app in docs/writing-a-buyer-app.md already use static; if your app does too, no source change is needed.

boat_buyer_init() is now a macro that forwards sizeof *ctx to boat_buyer_init_sz(), which returns BOAT_ERR_INVALID_ARG when the caller and the library disagree on the context size. Existing call sites compile and behave unchanged. Taking the address of boat_buyer_init no longer works — nothing in the tree did.

No wire change and no API signature change. A v1.0.0 buyer and a v1.0.1 buyer interoperate with the same seller.

Why: the Bloom filter stopped a healthy paying session

On 2026-08-29 a live session stopped after 102 slices. The 2048-bit k=2 Bloom filter had returned a false positive, and a Bloom hit is indistinguishable from a real replay, so the engine refused to sign a slice that was fine.

Not a freak event. Measured over 20,000 simulated sessions against the v1.0.0 filter:

slices in a session probability it completes unbroken
50 96.4%
102 72.9%
200 10.9%
300 0.09%

The filter was sized for short sessions and real sessions outgrew it. (It indexed off four nonce bytes, but that is not the cause — a uniform hash over all 32 bytes into 2048 bits false-positives at the same rate. The cause is the filter's size.)

What replaced it

A bounded exact-prefix set: the first 8 bytes of each paid nonce, 4096 entries by default (32 KiB). Nonces are full-entropy, so a false rejection now needs a 64-bit prefix collision — 4.55 × 10⁻¹³ across a full window, against the 27% above. False accepts are impossible: a true replay always matches its own prefix. Truncation is fail-safe adversarially too — a seller crafting a prefix collision can only get its own slice rejected, which it could achieve by sending nothing.

A nonce is now recorded only after the authorization is signed and written to 0xEE04. In v1.0.0 it was recorded before, so any later failure — including a transient BLE write failure — burned it, and the seller's retry of that slice was rejected as a replay for the life of the session. That silently lost one slice of revenue per transport hiccup. Fixed.

Capacity: the window rolls over, and money bounds a long charge

The set holds 4096 entries — at a 5-second slice cadence, about 5.7 hours of one continuous session. When it fills, the window rolls over in place and the session continues. cumulative_micro and the monotonic slice_id high-water mark are both preserved across a rollover, so session_cap_micro, not this set, is what ultimately bounds a long charge.

Stated rather than glossed: a rollover forgets nonces paid before it, so one could in principle be signed twice. Two backstops remain — slice_id monotonicity survives the rollover, so a replay must arrive under a new higher slice_id and the seller cannot simply re-send; and EIP-3009 authorizations are single-use on (payer, nonce) at settlement, so a second submission is refused.

Sizing note: the set is bounded in slices, i.e. in time — independently of power level or rate. Size sessions against the slice count, not against a µUSDC figure.

Diagnostics

A rollover logs at level 0, naming both the cause and the consequence. A replay is still reported as a replay: the reuse check runs before the capacity rollover, so a full set never launders a replay and the two conditions never share a message.

Upgrading

  1. Confirm your boat_buyer_ctx_t is static (or otherwise not on a stack).
  2. Rebuild. No source change is required.
  3. To trade capacity for memory, BOAT_BUYER_NONCE_CAP is overridable at build time — define it for the entire build (e.g. target_compile_definitions(... PUBLIC ...)), never in a single translation unit. A per-TU value used to corrupt memory silently; it is now caught and returned as BOAT_ERR_INVALID_ARG.

Verification

test_buyer 26/26 · test_all 23/23 · x402 10/10 · lightning 5/5 · cd tests && make 0 failures · full, buyer and seller profiles all build clean · the seller profile's exported symbols are identical to v1.0.0.

Reviewed in the open across three patch revisions between the buyer-firmware author and the repository maintainer; every revision was independently rebuilt and re-tested by the other party before it was accepted.

Full diff: v1.0.0...v1.0.1

BoAT MER v1.0.0

Choose a tag to compare

@linyao0177 linyao0177 released this 01 Sep 13:47

A lightweight Apache-2.0 C runtime for constrained machines. Device identity and payment signing stay on the device; the runtime provides portable cryptographic, attestation and payment primitives plus reusable buyer and seller engines over a canonical BLE streaming protocol.

Target class: ESP32-family MCUs, portable elsewhere through a small platform abstraction layer.

This Release note is published after the fact. The v1.0.0 tag was cut on 2026-08-12 and the repository has carried it since; no code changed. It exists so the tag has a note.

What it is — and what it is not

It is a device-side runtime: key custody, signed attestations, and device-signed payment envelopes, produced on the machine, with the private key never leaving it.

It is not a settlement service, a wallet backend, or a node. This repository contains no server — settlement happens off-device, through whatever service the integrator points it at.

Layers

Tier Header What it does
0 boat_crypto.h Ed25519 + secp256k1 keygen, signing, ecrecover, Keccak-256, SHA-256
1 boat_identity.h DID generation (did:mw:…), device registration
2 boat_attest.h SHA-256 hash + Ed25519 signature, unpaid and paid submission
3 boat_nano.h, boat_pay.h EIP-712 typed data, EIP-3009 sign/verify/settle
3 boat_lightning.h, boat_hashkey.h additional payment integrations
4 boat_x402.h x402 in both directions: buyer pay, seller offer
— protocols/ BLE GATT buyer and seller engines over the canonical 0xEE00 wire
— boat_pal.h platform abstraction

Build

idf.py -DBOAT_PROFILE=full build     # everything
idf.py -DBOAT_PROFILE=buyer build    # buy only
idf.py -DBOAT_PROFILE=seller build   # sell only, fits ESP32-C3

Host: cd tests && make && ./test_all, or cmake -B build && cmake --build build.
Host builds link OpenSSL and curl; the ESP-IDF device build links neither.

Scope, stated plainly

The intent is for BoAT MER to stay provider-neutral. That is the direction, not a description of this release. As shipped, v1.0.0 is bound to EVM chains and EIP-3009-style authorization: tiers 0 and 1 and unpaid attestation carry no chain binding, while paid attestation and the nanopayment wire do. There is no scheme or rail field in the v1 wire; another scheme would need a versioned wire and adapter code.

Per-profile code-size figures published before this tag were measured on older builds and were not re-measured for it — treat them as approximate.

Apache-2.0. See NOTICE and THIRD_PARTY_LICENSES.