BNAP
BNAP is an open protocol for priced and permissioned requests between
machines over HTTP and MCP. A server answers a request with HTTP 402 and a
signed offer that names the price, the action and the schemes it accepts.
The client sends one credential with the retried request: one payment (for
example blind ecash notes, L402 or MPP) and any attestations. The server
records each payment as used once only, runs the action, and answers with a
signed receipt. The default scheme, bnap:notes, uses blind notes bought
over Lightning.
Warning
BNAP is pre-alpha. Draft-00 is the current text, and anything in it can change without notice.
How to read this book
Every chapter under Specification, Schemes and History is a file from
the spec/ directory of the repository, included unchanged when the book is
built. The book adds no protocol text of its own, so it cannot drift from the
repository.
- Socket draft-00 is the normative text: byte layouts, algorithms and test vectors that every deployment shares.
- Design overview v0.5 explains the design. Where it and draft-00 differ, draft-00 wins.
- Errata lists the known differences between draft-00, the reference code and vectors, and the overview, and Provisional decisions records how the open ones are resolved for now.
- bnap:notes (draft-00) is the default credential scheme in draft-00 terms; its v0.4 profile is kept, superseded.
- The History chapters are superseded versions, kept unchanged.
The specification index explains where each file came from and how to read references to “tabs” in the text.
Test vectors, registry and conformance
The test vectors live in
vectors/vectors.json.
They are generated by the standard-library Python reference encoder,
reference/python/bnap_ref.py,
and CI checks on every change that the encoder still reproduces the file byte
for byte.
The registry records what each scheme, rail, verifier and carrier guarantees. The conformance suite checks a running gate from the outside, with Python 3.11 or later and no packages:
python3 -I conformance/run.py --target https://gate.example --profile server --report out/
Implementations
- Caparra, the Rust reference implementation, with its own guide at https://bnap-protocol.github.io/caparra/.
License
The specification text is licensed under CC-BY-4.0. Code in the repository is licensed under either of MIT or Apache-2.0, at your option. Source: https://github.com/bnap-protocol/bnap.
BNAP specification
Spec text licensed CC-BY-4.0, (c) BNAP contributors.
Everything in this directory was exported on 2026-10-13 from the Claude Doc WJztndZC9mY1jogSMesi8n, one file per tab, transcribed verbatim. Each file starts with an HTML comment naming its source tab and the doc revision it was exported at.
Files
| File | Source tab | Rev | Status |
|---|---|---|---|
| socket-draft-00.md | Socket spec draft-00 | 39 | Normative: the socket every deployment shares (byte layouts, algorithms, test vectors) |
| overview-v0.5.md | BNAP v0.5 (current) | 33 | Design overview, v0.5.1; informative where draft-00 covers the same ground |
| schemes/bnap-notes.md | bnap:notes scheme (from v0.4) | 23 | The default credential scheme, still in its v0.4 form |
| history/v0.3.md | v0.3 archive | 35 | Superseded; kept unchanged |
| history/v0.2.md | v0.2 archive | 53 | Superseded; kept unchanged (working name Caparra, “CPR”) |
| ERRATA.md | (written for this repo) | Differences between draft-00, the reference code and vectors, and the overview |
Outside this directory:
- ../reference/python/bnap_ref.py: the standard-library Python reference encoder that draft-00 section 14 cites.
- ../vectors/vectors.json: the test vectors it generates.
python3 -I reference/python/bnap_ref.pyreproduces the file byte for byte.
Precedence
socket-draft-00.md wins wherever it and overview-v0.5.md differ. Both documents say so: the overview says the draft-00 tab “wins where the two differ”, and draft-00 section 1 says “this draft wins and the difference is listed in section 15”. Draft-00 section 15 lists the known differences; ERRATA.md records the rest, plus the points that need an owner decision.
Reading tab references
The texts are verbatim, so they still point at tabs, not files:
- “the first tab” (in draft-00) means
overview-v0.5.md. - The overview’s “second tab” means the bnap:notes scheme,
schemes/bnap-notes.md, and its “third tab” meanssocket-draft-00.md. These follow an older tab order; the doc now lists the overview, draft-00, bnap:notes, v0.3, v0.2. - In the archives, “the first tab” means whichever version was current when that archive was made.
Not exported
Seven figures are interactive diagrams in the source doc: one in the overview, two each in bnap-notes, v0.3 and v0.2. Each file keeps the figure’s caption and its widget id in a quoted note where the figure stood. Draft-00 has no figures. Everything else, every table, code block and formula included, is in the files.
BNAP socket — draft-00
2026-10-11 · Dominic
1. Status and scope
Status. Working draft 00 of the BNAP socket, the part every deployment shares. It turns the design in the first tab (v0.5.1) into byte layouts, algorithms and test vectors. It is meant to become the repository’s normative text and, later, an IETF individual draft. Where this draft and the first tab differ, this draft wins and the difference is listed in section 15.
Scope. This draft defines the Offer, the Credential, use IDs and the use store, pricing with attestations, the Response and its receipt, errors, the manifest, and the HTTP carrier. It does not define any credential scheme: bnap:notes, L402, MPP and the rest are plugs with their own registry entries, and they plug into the slots defined here. Nothing in this draft depends on Cashu, on Lightning or on any one model or framework.
2. Conventions
The key words MUST, MUST NOT, REQUIRED, SHOULD, SHOULD NOT, RECOMMENDED, MAY and OPTIONAL are to be read as described in BCP 14 (RFC 2119, RFC 8174) when, and only when, they appear in all capitals.
| Notation | Meaning |
|---|---|
H(x) | SHA-256 of x |
H_tag(x) | BIP-340 tagged hash: H(H(tag) ‖ H(tag) ‖ x), with tag as UTF-8. Every hash the socket defines is tagged, and no two uses share a tag |
‖ | Byte concatenation |
| BigSize | The variable-length unsigned integer of Lightning BOLT 1. Non-minimal encodings are MALFORMED |
tu64 | An unsigned 64-bit integer, big-endian, with leading zero bytes removed; zero is the empty string. Non-minimal encodings are MALFORMED |
lp(x) | BigSize(len(x)) ‖ x |
| TLV stream | BOLT 1 records: BigSize(type) ‖ BigSize(length) ‖ value, types strictly increasing, no duplicates |
| Entry list | lp(stream₁) ‖ lp(stream₂) ‖ …, each stream a nested TLV stream. Used wherever a record holds several items |
| Flag | A record with an empty value; present means true |
| Key | A BIP-340 x-only public key, 32 bytes |
| Signature | A BIP-340 Schnorr signature, 64 bytes. Signers SHOULD use fresh auxiliary randomness; test vectors use 32 zero bytes |
b64u(x) | Base64url without padding (RFC 4648 section 5) |
| empty | The zero-length byte string. Wherever a formula says “or empty”, H(empty) is SHA-256 of zero bytes and lp(empty) is the single byte 00. An absent record and a present record with an empty value hash the same, and MUST mean the same. |
One encoding. Every socket object on the wire is a TLV stream. Scheme payloads are opaque bytes inside it, so a scheme that speaks JSON or CBOR keeps doing so. The manifest is the one exception: it is JSON, because it is fetched and read by tools that already parse JSON.
Type ranges. These apply to top-level socket streams. Nested streams, such as pay entries, use only 0–239, the experimental range and GREASE; the signature over the outer stream covers them.
| Range | Use |
|---|---|
| 0–239 | Socket fields defined by this document |
| 240–1000 | Signature records, never covered by any signature or MAC, as in BOLT 12 |
| 1001–999,999,999 | Reserved for future socket fields |
| 1,000,000,000–1,999,999,999 | Experimental; never registered |
| 3,000,000,001–3,000,000,999, odd only | GREASE |
| Every other value | Reserved |
The even/odd rule. A receiver that meets an unknown even type MUST reject the object as MALFORMED. It MUST ignore an unknown odd type. In a signed object, such as an Offer or a Response, the signature still covers that record. A Credential is not signed as a whole, so its unknown odd records outside ext MUST NOT affect processing in any way. Senders SHOULD add one GREASE record, of random odd type and random short value, to every Offer and Credential, so that receivers keep honouring the rule.
Sizes. Unless the manifest’s limits raise them, an Offer MUST NOT exceed 4,096 bytes, a Credential 16,384 bytes and a Response 8,192 bytes. Larger objects are rejected with TOO_LARGE before anything beyond the length is parsed. A credential can name its offer by OfferID instead of repeating it (5.1), which keeps most credentials under 2 KiB.
Time is Unix seconds. Verifiers allow 60 seconds of clock skew on every expiry check.
3. Overview
A gate runs the socket in front of an application. Plugs supply everything the socket does not fix: the scheme that checks a credential, the rail that took the payment, the carrier that names the action, the pricer, the store and the signer.
Client Gate: socket + plugs Application
| request, no credential ----------->| carrier computes the action scope
|<------ 402 + Offer (+ native challenges)
| request + Credential ------------->| 1 parse, check the Offer, scope, expiry
| | 2 scheme.verify -> use IDs, rail IDs, value
| | 3 price with attestations, check value
| | 4 commit: every ID + ReplayKey, PENDING, atomic, durable
| |------- action ----------------------------->|
| |<------ result ------------------------------|
| | 5 settle OK or FAILED once; scheme.settle
|<------ result + Response (signed receipt)
The gate holds no state about clients. Its state is the use store, which holds records about credentials, and a small cache of the offers it issued (5.1).
4. The Offer
An Offer is a TLV stream, signed by an online offer key, that prices one action or one route and lists every scheme the server accepts for it.
4.1 Records
| Type | Name | Value | Presence |
|---|---|---|---|
| 0 | version | tu64, 1 for this document | Required |
| 2 | scope | 32 bytes: an action scope or a route scope (4.3) | Required |
| 4 | route_scope | Flag: scope names a route, not one action | Optional |
| 6 | headers | UTF-8, comma-separated lower-case field names the carrier hashes into the action scope, as the route lists them | When the route lists any |
| 8 | metered | Flag: the charge is set at settlement, up to the entry’s amount (7) | Optional |
| 10 | expires | tu64 | Required |
| 12 | info | UTF-8 URI of the manifest, on the same origin | Optional |
| 14 | offer_key | Key that signed this offer | Required |
| 16 | nonce | 16 random bytes | Required |
| 18 | pay | Entry list of pay entries, in preference order | Required unless attest holds a waive |
| 20 | attest | Entry list of attest entries | Optional |
| 24 | issued_at | tu64 | Required when mac is absent |
| 26 | native | Entry list of native entries | Exact-scope offers only |
| 240 | offer_sig | Signature | Required |
| 242 | mac | 32 bytes, HMAC-SHA256 | Required, except in offers published in advance (4.4) |
A pay entry is a nested stream:
| Type | Name | Value |
|---|---|---|
| 0 | scheme | UTF-8 scheme ID, unique within pay. Required |
| 2 | amount | tu64, in the unit’s base denomination; the ceiling in a metered offer. Required |
| 4 | unit | UTF-8: sat, msat, an ISO 4217 code (minor units) or a CAIP-19 asset ID (smallest indivisible unit). Required |
| 6 | quote_expires | tu64; defaults to expires and MUST NOT exceed it |
| 8 | params | Bytes defined by the scheme’s registry entry |
| 10 | rate | tu64: in a metered offer, the price per 1,000 units of the route’s meter, in unit. Required in metered offers |
An attest entry is a nested stream:
| Type | Name | Value |
|---|---|---|
| 0 | scheme | UTF-8 scheme ID, unique within attest. Required |
| 2 | effect | tu64: 0 required, 1 waive, 2 discount, 3 quota. Required |
| 4 | value | tu64: basis points for a discount, from 1 to 10,000; actions per epoch for a quota, at least 1 |
| 6 | epoch | tu64 seconds, for a quota, at least 1 |
| 8 | params | Bytes defined by the scheme’s registry entry |
An offer with an effect outside 0–3, or a value or epoch out of range, is MALFORMED.
A native entry carries an artifact the server made for one pay entry:
| Type | Name | Value |
|---|---|---|
| 0 | pay_index | tu64, zero-based index into pay; that entry’s scheme MUST be class O. Required |
| 2 | kind | UTF-8 label from the scheme’s registry entry, such as bolt11 or l402-macaroon. Required |
| 4 | data | The artifact’s bytes. Required |
4.2 OfferID, signature and MAC
The server builds an offer in this order:
- Write every record except the signature range and
native. LetS₀be their serialization in stream order. - Compute
OfferID = H_"bnap/offer"(S₀). - Make each native artifact so that it embeds the OfferID, in the way its scheme’s registry entry defines, such as an invoice description hash, a macaroon caveat or an MPP
id. Add them as thenativerecord. - Let
Sbe the serialization of every record outside the signature range, natives included. Thenoffer_sig = Sign(offer_key, H_"bnap/offer-sig"(S))andmac = HMAC-SHA256(K_mac, S).
Anyone can recompute the OfferID from the offer bytes by dropping the signature range and native. Records of unknown odd type are part of S₀ and S, so nobody can add or remove them unnoticed. The server keeps K_mac secret and rotates it no faster than its longest offer lifetime. It keeps each retired key until every offer made under it has expired, and compares MACs in constant time.
4.3 Scope modes
Exact. Without route_scope, scope is the carrier’s action scope for the one request this offer prices. A credential sent with any other request is WRONG_SCOPE.
Route. With route_scope, scope is the carrier’s route scope, and the offer may be used for any number of requests on that route until it expires. Each credential is still tied to its own request, because the Context includes that request’s action scope (5.2). Only schemes of binding class S or A may appear in a route-scope offer, in pay or in attest. Class O and None artifacts do not cover the Context, so they need an exact-scope offer, and a route-scope offer carries no native record. Route scope is what lets a client pay for a stream of API calls with one round trip each.
4.4 Checks
A client that receives an offer MUST parse it fully and MUST check:
- that
versionis supported,expiresis in the future, andissued_at, if present, is not in the future; - that
offer_keyis listed for useofferin the origin’s pinned manifest and has not expired (9.3). Without a pinned manifest, the client is trusting on first use; - that
offer_sigverifies; - that
info, if present, is on the origin the offer came from; - that
scopematches the request it is about to send, or the manifest route it expects; - that every pay entry it might use fits its spend policy.
It ignores entries whose scheme it does not know.
A gate that receives a credential MUST check the offer inside it before calling any plug, in this order:
- Size. Reject an oversize object with TOO_LARGE.
- Parse and version. Reject a parse failure with MALFORMED, and an unsupported version with VERSION.
- Authenticity. An offer with
macpasses only if the MAC verifies under a current or retainedK_mac. An offer withoutmacwas published in advance. It MUST carryissued_at, and it passes only ifoffer_keyis a key this gate holds for useofferand lists in its current manifest, andoffer_sigverifies under it. A gate MUST NOT trust a key because the offer names it. A failure here is BAD_OFFER. - Expiry. Check
expires, the chosen entry’squote_expires, and thatissued_atis not in the future. A failure is STALE. - Scope. A mismatch is WRONG_SCOPE.
- Offered schemes. Every scheme the credential uses MUST appear in the offer, or the result is NOT_OFFERED.
5. The Credential
5.1 Records
| Type | Name | Value | Presence |
|---|---|---|---|
| 0 | version | tu64, 1 | Required |
| 2 | offer | The Offer, byte for byte as received | Exactly one of offer and offer_id |
| 4 | pay | Nested stream: 0 scheme, 2 body, 4 witness | At most one |
| 6 | attest | Entry list of nested streams: 0 scheme, 2 body, 4 witness; sorted by scheme ID, bytewise | Optional |
| 8 | binding | 32 bytes: the RFC 9266 tls-exporter value of the client’s connection | Only if a chosen scheme is class S |
| 10 | ext | A nested TLV stream of extensions | Optional |
| 12 | offer_id | 32 bytes: the OfferID of an offer the gate issued and still caches | Exactly one of offer and offer_id |
A credential holds pay, attest, or both. These are MALFORMED: an attest list out of order or with a scheme twice; a binding when no chosen scheme is class S; both or neither of offer and offer_id.
Offer by reference. A gate issues one route-scope offer per route per issuing interval, the same to every client, and caches it until it expires. It also caches exact-scope offers that carry natives (5.4). Nothing an unauthenticated request does adds to the cache beyond that. A client then sends offer_id instead of the whole offer. The gate looks the offer up and runs the checks of 4.4 on it as if it had been sent. An unknown OfferID is BAD_OFFER, and the gate answers with a fresh offer.
Body and witness. Every scheme splits what it sends in two. The body is everything the scheme needs except signatures made over the Digest; the witness holds those signatures. Only class S schemes have a witness. The split is what lets the Digest cover the body without covering the signature that is made over it.
Everything verified is in the credential. Everything a scheme verifies MUST be in its body or witness. An adapter for a scheme that natively lives in HTTP fields, such as Web Bot Auth’s Signature or a DPoP proof, MUST have the client copy those fields into the body. It MUST NOT read them from the request instead, because the Digest would not cover them and anyone in the path could swap them. When such a signature covers request components, the adapter MUST build the signature base from the gate’s own Context and the carrier’s scope inputs, never from raw request fields.
Canonical bodies. A scheme MUST reject a body that contains any byte it does not verify: no padding, no unknown records, no alternative encodings. Otherwise someone on the path could alter a class A or None body, commit it first under a different ReplayKey, and turn the real client’s retry into SPENT.
5.2 Context and Digest
action_scope = the carrier's scope of THIS request (equals the offer's scope in exact mode)
Context = H_"bnap/ctx"( OfferID ‖ action_scope ‖ lp(pay scheme ID, or empty)
‖ BigSize(n) ‖ lp(attest scheme ID₁) ‖ … ‖ lp(attest scheme IDₙ)
‖ H(ext value, or empty) )
Digest = H_"bnap/cred"( Context ‖ lp(binding, or empty)
‖ H(pay body, or empty)
‖ H(attest body₁) ‖ … ‖ H(attest bodyₙ) )
n may be 0. "Or empty" is the empty byte string of section 2.
Attestations appear in both formulas in the order of the credential’s attest list, which is sorted.
The Context fixes everything the client chose: the offer, the request, the value scheme, every attestation scheme and the extensions. Class A artifacts embed it. The Digest adds the TLS binding and every body. Class S witnesses sign it. The gate computes both itself; a scheme plug receives them read-only.
5.3 Binding
If binding is present, the gate MUST compute the tls-exporter value (RFC 9266: label EXPORTER-Channel-Binding, empty context, 32 bytes) of the connection the request arrived on and reject a mismatch with BINDING. A gate that cannot see the client’s TLS session, because a front terminates it, MUST reject credentials that carry a binding, and its routes MUST NOT require one. Binding is in the Digest but not in the Context, so a client retrying on a new connection re-signs a new Digest under the same Context and ReplayKey, and the retry is recognized (6.4). Only class S credentials are TLS-bound.
5.4 Native credentials
A client may pay through a scheme’s own header, with no BNAP Credential. The scheme’s adapter then MUST:
- recover the OfferID from the native artifact, which embeds it, and authenticate the artifact without state: a macaroon, an HMAC’d MPP id, an invoice its own node issued;
- recover the Offer itself, either from the artifact or from a cache of offers the gate issued with natives, keyed by OfferID and kept until they expire.
The gate then runs the same checks as for a Credential (4.4) and builds a synthetic envelope:
Context = H_"bnap/ctx-native"( OfferID ‖ action_scope ‖ lp(scheme ID) )
Digest = H_"bnap/cred-native"( Context ‖ H(native credential bytes) )
The scheme’s registry entry defines the native credential bytes, such as the L402 macaroon:preimage token. From here, use IDs, rail IDs, commit, replay and the receipt work exactly as for a Credential. The receipt goes in the BNAP response header next to the scheme’s own. A request carrying more than one value credential, native or BNAP, is MALFORMED.
6. Uses and the use store
6.1 Identifiers
A scheme’s verify returns local IDs: bytes that its registry entry defines, such as a note’s Y or an L402 payment hash. The gate turns each into a socket ID itself, so no scheme can name an ID in another scheme’s space:
UseId = H_"bnap/use"( lp(scheme ID) ‖ local ID )
RailId = H_"bnap/rail"( lp(rail kind) ‖ canonical payment bytes )
ReplayKey = H_"bnap/replay"( Context ‖ H(pay body, or empty) ‖ H(attest body₁) ‖ … ‖ H(attest bodyₙ)
‖ BigSize(m) ‖ id₁ ‖ … ‖ idₘ ) ids sorted bytewise
The ReplayKey is the Digest without the binding, plus every UseId and RailId of every scheme in the request, whatever its use model. It therefore names one credential, sent for one request. Two clients that send the same request with different credentials never share a ReplayKey. A retry of the same credential on a new connection does share it.
A credential that yields no ID under the single model, such as one carrying only reusable attestations, is protected from re-running only while its outcome record exists. The gate MUST NOT admit such a credential unless its offer expires at least 60 seconds before that record can be deleted: expires + 60 ≤ now + replay_window.
Rail IDs. Schemes are untrusted, so the canonical payment bytes come from the rail plug, which is operator-trusted. A scheme returns a rail reference: the rail kind and its own reference to the payment, such as a payment hash or a transaction. The gate asks that rail to confirm the payment, and the rail returns the canonical bytes, from which the gate computes the RailId. A scheme MUST return a rail reference for every payment its credential relies on. A scheme that mints credit from a payment, such as notes funded over Lightning, MUST have the gate commit that payment’s RailId when it mints. Because the RailId is the same whichever scheme refers to the payment, one Lightning payment offered under L402, MPP and x402 admits one action, not three. Initial rail kinds:
| Kind | Canonical payment bytes |
|---|---|
ln | Payment hash, 32 bytes |
onchain | txid, 32 bytes ‖ output index as 4 bytes big-endian |
evm | lp(CAIP-2 chain ID) ‖ token contract, 20 bytes ‖ payer, 20 bytes ‖ authorization nonce, 32 bytes |
card | lp(processor name) ‖ lp(processor’s payment ID) |
6.2 Use models
| Model | Store key | Rule |
|---|---|---|
| single | UseId | One record. Kept for as long as the scheme’s local artifact could still verify under any offer, which for a note means for as long as its keyset is accepted; forever if that has no end |
| rate(k, epoch) | H_"bnap/rate"(UseId ‖ tu64(k) ‖ tu64(epoch) ‖ tu64(⌊now / epoch⌋)) | A counter of at most k |
| reusable | none | Nothing consumed beyond the outcome record. Any limit comes from a quota attestation (7) |
A quota attestation counts in H_"bnap/quota"(UseId ‖ tu64(value) ‖ tu64(epoch) ‖ tu64(⌊now / epoch⌋)), at most value. Its UseId MUST name a stable identity, such as a key or an account, which the scheme’s registry entry declares as quota_id. A scheme whose IDs change with every token, as with Privacy Pass, cannot carry a quota.
RailIds are always single. The registry sets each scheme’s model as the socket enforces it, which can be stricter than the native scheme: L402 tokens are reusable natively but single in BNAP.
6.3 Commit
The store MUST run the commit as one serializable transaction and MUST make it durable before the action starts:
commit(rk, singles, counters): singles = single UseIds and RailIds; counters = (key, limit)
if outcome[rk] exists: return Replay(outcome[rk])
for id in singles: if used[id] exists: abort, return SPENT
for (key, limit) in counters: if counter[key] >= limit: abort, return RATE_LIMITED
for id in singles: used[id] = rk
for (key, limit) in counters: counter[key] += 1
outcome[rk] = { status: PENDING, created: now, counters: [keys], charged: none,
scheme_result: none, response: none }
return New
An abort consumes nothing, so a client whose commit fails keeps its credential and may retry it while the offer is valid.
If the store cannot be reached, the gate fails closed with UNAVAILABLE and never runs the action.
6.4 Settle and replay
Settle. The gate first computes the charge and calls the scheme’s settle, which is deterministic: the same verified credential, outcome and charge always give the same scheme_result. It then writes status, charge and scheme_result to outcome[rk] in one conditional update that succeeds only from PENDING, so the status changes exactly once. On FAILED, single uses stay spent, the value scheme refunds as its registered refund property allows, and the counters listed in the outcome are given back. If the gate crashes between the action and this write, the outcome stays PENDING. Because settle is deterministic, a replay can recompute the change it would have returned. The gate MUST NOT send the Response, or any change in it, before this write is durable; otherwise a crash followed by the timeout would pay the client twice.
Timeout. An outcome still PENDING after the manifest’s pending_timeout moves to FAILED, with the refund that implies. The gate MUST stop or discard any action still running at that point, so pending_timeout MUST exceed the route’s longest action, streams included. A crash that leaves a finished action PENDING therefore costs the operator, never the client.
Replay. Before commit, the gate has already verified the credential and its binding against this request. If the commit finds outcome[rk], the request is the same credential for the same request:
- A PENDING outcome returns PENDING with a
retry_after. - An OK or FAILED outcome inside
replay_windowgets the stored response, if the carrier kept one, withreplayedset; the action never runs twice. - A carrier that did not keep the response returns the receipt alone, with the
scheme_resultrecorded at settlement. The scheme MUST NOT pay out anything beyond that recorded result.
After replay_window the outcome record may be deleted, but the single-use records remain, so a later replay is SPENT. Replaying a credential is also how a client fetches a receipt it missed, for example at the end of a stream that had no trailer.
A replay hands the stored response to whoever presents the same valid credential, so the response is only as confidential as the scheme’s theft property allows.
7. Pricing, attestations and metering
The offer is the quote. The Pricer runs when the gate builds an offer and writes one amount per pay entry, converting between units as it likes. At verification time the gate uses only what the offer says, so a client can predict a fixed price exactly, and can check a metered charge against the offer’s rate:
price(base): base = entry.amount, or the metered amount at settlement
p = base
for each verified attestation, in offer order, effect waive: p = 0
for each verified attestation, in offer order, effect discount: p = p - ⌊p × value / 10000⌋
if p > 0: for the first verified quota attestation, in offer order, whose counter has room:
p = 0, and add its counter to the commit
return p
admit:
for each required attestation in the offer: if not verified -> POLICY
p = price(entry.amount, or 0 if the credential has no pay)
if p > 0 and no pay -> UNDERPAID
if the scheme's verified value < p -> UNDERPAID
An attestation that fails verification and is not required has no effect, and causes no error.
Discounts multiply, so two 20% discounts give about 36% off, never 40%. Each step rounds down the amount taken off, so the order is fixed by the offer and the result is exact. Only attestations of binding class S, A or O may waive, discount or carry a quota, and the gate MUST NOT build an offer that breaks this. A route’s require applies to attestations exactly as to value schemes. Attestation use IDs and quota counters commit in the same transaction as the value IDs (6.3). If a quota had room at pricing but is full at commit, the commit aborts with RATE_LIMITED and consumes nothing.
Metering. A metered route names its meter in the manifest, such as tokens. In a metered offer, each pay entry’s amount is a ceiling and its rate is the price per 1,000 meter units. The credential must carry value of at least price(amount). At settlement the gate counts the units used, computes charged = min(price(⌈units × rate / 1000⌉), price(amount)), and reports both metered_units and charged in the Response, so the client can check the arithmetic. The gate computes it in arbitrary precision, so a product beyond 2⁶⁴ simply yields the ceiling. Settlement reuses the attestation results and quota decision made at admission; it never consumes a quota again. The client cannot check the unit count itself; that is trust in the operator, which the signed receipt makes attributable.
The scheme returns the rest of the value: notes as change outputs, a facilitator scheme by settling the lower amount. A pay entry may appear in a metered offer only if its scheme’s registry entry has refund = "atomic" or upto = true. On a metered route, other schemes get non-metered exact-scope offers priced at the ceiling.
Overpayment. If the value exceeds the price in a non-metered offer, a scheme that can return change does so; otherwise the excess is kept. Clients SHOULD pay exact amounts.
8. Response, receipt and errors
8.1 Response
Every request that reached commit gets a Response, whichever scheme paid:
| Type | Name | Value | Presence |
|---|---|---|---|
| 0 | version | tu64, 1 | Required |
| 2 | status | tu64: 0 OK, 1 FAILED | Required |
| 4 | replayed | Flag | On replays |
| 6 | charged | tu64, in unit | Required; 0 when nothing was charged |
| 8 | unit | UTF-8, the chosen entry’s unit | Required when the credential has pay |
| 10 | offer_id | 32 bytes | Required |
| 12 | digest | 32 bytes, the Digest of the request this Response answers | Required |
| 14 | metered_units | tu64, the meter’s count | Required in metered offers |
| 16 | scheme_result | Bytes defined by the scheme: change signatures, a settlement reference | Optional |
| 18 | receipt_key | Key | Required |
| 20 | settled_at | tu64 | Required |
| 240 | ack | Signature by receipt_key over H_"bnap/ack"(every record outside 240–1000) | Required |
A replay is signed afresh over the replaying request’s Digest, with replayed set. It keeps the original settled_at, charged and scheme_result, so a receipt fetched late still shows when the action was settled.
Using a receipt as proof. A Response, the manifest that lists its receipt_key, and the Credential it answers let a third party check three things: that the operator of the origin acknowledged this outcome for this credential, at this time, for this charge. A client keeps every Response it paid for.
8.2 Errors
Errors are TLV streams too: 0 version, 2 code (tu64), 4 scheme_code (UTF-8, namespaced, such as bnap:notes/OUTPUT_REUSED), 6 detail (UTF-8, at most 256 bytes, for logs only), 8 retry_after (tu64 seconds). Errors are not signed. A client MUST NOT treat any error as proof of anything.
| Code | Name | Meaning | HTTP |
|---|---|---|---|
| 1 | VERSION | Unsupported socket version | 400 |
| 2 | MALFORMED | Bad encoding, order or framing | 400 |
| 3 | TOO_LARGE | Over a size limit | 413 |
| 4 | BAD_OFFER | Offer MAC or signature fails | 402, fresh offer |
| 5 | STALE | Offer or quote expired | 402, fresh offer |
| 6 | WRONG_SCOPE | Offer is for another action or route | 402, fresh offer |
| 7 | NOT_OFFERED | A scheme the offer does not list | 402 |
| 8 | POLICY | Properties or a required attestation missing | 403 |
| 9 | UNDERPAID | Value below the effective price | 402 |
| 10 | BAD_CREDENTIAL | The scheme rejected the credential; see scheme_code | 402 |
| 11 | BINDING | TLS binding mismatch or not checkable | 400 |
| 12 | SPENT | A use ID is already committed under another ReplayKey | 409 |
| 13 | PENDING | The same credential is still in flight | 409, retry_after |
| 14 | RATE_LIMITED | A rate counter is full | 429, retry_after |
| 15 | UNAVAILABLE | Store, signer or verifier unreachable; the gate fails closed | 503 |
| 16 | REQUIRED | The route needs a credential and none was sent | 402, fresh offer |
Codes up to 1,000 are reserved for this document; the registry assigns the rest.
9. Manifest and pinning
9.1 Document
The manifest is a JSON object served at https://<origin>/.well-known/bnap with media type application/json. It holds only strings, booleans, objects, arrays and integers no larger than 2⁵³.
| Member | Value |
|---|---|
bnap | 1 |
origin | The origin it covers, serialized as in RFC 6454: lower case, IDNA A-labels, default port omitted |
seq | Integer; increases with every change |
issued | Unix time |
max_age | Seconds; at most 2,592,000 (30 days) |
root | Hex key that signs this manifest |
backup | Hex key of the offline key that may sign the next root’s manifest |
keys | Online keys: [{"kid": hex key, "use": "offer" or "receipt", "not_after": time}] |
required | Boolean, the bnap-required flag (9.3) |
limits | replay_window, pending_timeout (seconds); max_offer, max_credential, max_response (bytes) |
carriers | Carrier names, such as ["http", "mcp"] |
routes | [{"id", "match", "scope": "exact" or "route", "metered", "meter", "price": {unit: amount}, "pay": [scheme IDs], "attest": [{"scheme", "effect", "value", "epoch"}], "require": {property: value}, "headers": [field names]}] |
sig | Hex signature by root over H_"bnap/manifest"(JCS(manifest without sig)) |
JCS is the JSON Canonicalization Scheme of RFC 8785. Member names are ASCII, so sorting them by code point equals JCS order. A route’s match is a method, one space, and a path, compared with the request path without its query; a path ending in /* matches every path below it. A route listed with "scope": "route" gets route-scope offers for its class S and A schemes, and exact-scope offers for the rest. headers lists request fields that the HTTP carrier hashes into the scope (10.1).
9.2 Keys
The root key stays offline and signs only manifests. Online offer and receipt keys SHOULD expire within 30 days and are replaced by publishing a new manifest with a higher seq.
A manifest is always signed by the key in its own root member. A manifest signed by the current root MUST keep the same backup, so a stolen root cannot also take the recovery path. To retire the root, the operator signs a manifest with the backup key, which that manifest names as its root, and names a fresh backup. A stolen backup is as bad as a stolen root, so the backup is kept offline and apart from the root.
A client that missed a rotation still holds the old pin, and accepts only manifests signed by the keys it pinned. Operators MUST NOT rotate twice within 30 days. A client that cannot validate any manifest keeps the pinned keys until its pin lapses, then trusts on first use again.
9.3 Client rules
fetch(origin): HTTPS only, certificate valid for origin; at most once per 5 minutes
m = GET https://origin/.well-known/bnap
require m.bnap == 1, m.origin == origin, m.sig valid under m.root
require m.issued - 60 <= now <= m.issued + m.max_age + 60
if pin[origin] is live:
require m.root == pin.root and m.backup == pin.backup,
or m.root == pin.backup a rotation
require m.seq > pin.seq, or H(m bytes) == pin.hash
pin[origin] = {root: m.root, backup: m.backup, seq: m.seq, hash: H(m bytes),
required: m.required, keys: m.keys,
until: min(m.issued + m.max_age, now + 30 days)}
on an offer: offer_key listed in the pin with use "offer", not_after >= now;
otherwise fetch(origin), then reject
on a receipt: receipt_key listed in the pin with use "receipt", not_after >= settled_at;
without replayed, settled_at within 60 s of the client's clock on arrival
on a 402 from an origin pinned with required = true:
pay only through a BNAP offer that passes 4.4, never through a native header,
until the pin lapses
always: pay a native challenge only if its bytes equal a native entry of an offer
that passes 4.4, or the client has no pin for the origin
What pinning does and does not do. Clients pin the root and its backup, never per-route scheme lists, which change whenever a rail goes down. offer_sig already stops anyone removing a scheme from an offer. required is the only defence against deleting the BNAP header so that a client falls back to a native scheme, and it protects only clients that fetched the manifest before. First contact is trust on first use. The 30-day cap bounds how long a hostile first contact can pin a key. Operators who want more publish the root key out of band, in DNS, on Nostr or in their documentation.
10. The HTTP carrier
10.1 Scopes
action scope = H_"bnap/scope/http"( lp(method) ‖ lp(target) ‖ lp(content hash, or empty)
‖ BigSize(h) ‖ lp(name₁) ‖ lp(value₁) ‖ … ‖ lp(nameₕ) ‖ lp(valueₕ) )
route scope = H_"bnap/route/http"( lp(origin) ‖ lp(route id from the manifest) )
- method is the request method exactly as sent; methods are case-sensitive.
- target is the manifest’s
originfollowed by the request-target in origin form, exactly as received: path and query bytes unchanged, with no normalization. The gate MUST forward that same request-target to the application, so the action that was paid for is the action that runs. Using the manifest origin, not the scheme and authority the gate sees, keeps the scope the same behind a TLS-terminating front. - content hash is the raw SHA-256 of the request content as sent, after any content coding, or empty when there is no content. Clients SHOULD also send
Content-Digest(RFC 9530), so intermediaries can check the body, but the gate always hashes the body itself. - headers are the fields the route lists in the manifest, in that order, for requests whose meaning depends on more than the body, such as
anthropic-versionorAccept. The name is in lower case, and the value is the field value as received, with repeated fields combined as in RFC 9110 section 5.3. A listed field that is absent has an empty value. Exact-scope offers repeat the list in record 6, so a client without the manifest can compute the scope. Most routes list none, so h = 0.
10.2 Header fields
| Field | Direction | Value |
|---|---|---|
WWW-Authenticate: BNAP offer="…" | Response, with 402 | b64u(Offer). A 402 MAY carry several BNAP challenges, such as a route offer and an exact offer, next to native challenges in their own fields |
BNAP-Credential | Request | b64u(Credential) |
BNAP-Context | Request, optional | b64u(Context), for schemes whose native signature covers header fields, such as Web Bot Auth. The signature, with this field among its covered components, is copied into the attest body (5.1). The gate MUST reject the request as MALFORMED if this field differs from the Context it computed |
BNAP-Receipt | Response | b64u(Response) on every response to a request that reached commit |
BNAP-Error | Response | b64u(Error), with the HTTP status from 8.2 |
The credential has its own field, not Authorization. That keeps Authorization free for the API’s own credentials, such as an LLM API key. Native schemes that use Authorization, such as L402, still clash with an API key there; such clients must choose one. On BNAP routes, gates MUST accept a BNAP-Credential field of at least 12,288 bytes, enough for a credential that carries a 4,096-byte offer inline. Clients SHOULD send offer_id rather than the whole offer, which keeps a notes credential far below that.
Caching. The credential is not in Authorization, so RFC 9111’s rule against storing authorized responses in shared caches does not apply automatically. Every response to a request that carried BNAP-Credential MUST include Cache-Control: private, no-store, and the gate MUST add it if the application did not.
Streaming. When the charge is known only at the end, as with a streamed LLM completion, the receipt cannot travel in the response header:
- If the client sent
TE: trailers, the gate sends the receipt as an HTTP trailerBNAP-Receipt. - Otherwise, for
text/event-stream, it sends a final eventbnap-receiptwhose data isb64u(Response). The gate MUST strip anybnap-receiptevent the application itself emits. - Otherwise the client fetches the receipt by replaying the credential after the stream ends (6.4).
A client that disconnects mid-stream is charged for what was produced. A stream that would outlast pending_timeout is cut off at that point, so routes with long streams need a long pending_timeout.
10.3 Example: the LLM inference proxy
The operator puts the gate in front of an OpenAI-compatible endpoint. Its manifest has one route, chat, metered in tokens. It gets route-scope offers for bnap:notes, at 500 sat per 1,000 tokens up to 2,100 sat per call, with a 20% discount for agents that sign with Web Bot Auth. Exact-scope, non-metered L402 offers at the ceiling serve clients that have no BNAP code.
POST /v1/chat/completions HTTP/1.1
Host: api.example
Content-Type: application/json
{"model":"m-1","messages":[{"role":"user","content":"hi"}],"max_tokens":256}
HTTP/1.1 402 Payment Required
WWW-Authenticate: BNAP offer="AAEBAiCGLWQeZ4E_bniyCoGqkpUZ5mqXjmnopNPicoCZBpfzoAQACAAKBGrLLUwM…"
WWW-Authenticate: BNAP offer="…" (exact scope: L402, 2,100 sat, with native)
WWW-Authenticate: L402 macaroon="<caveat bnap_offer = OfferID>", invoice="lnbc…"
BNAP-Error: AAEBAgEQ (REQUIRED)
POST /v1/chat/completions HTTP/1.1
Host: api.example
Content-Type: application/json
BNAP-Context: <b64u Context>
BNAP-Credential: AAEBAv0BMQABAQIghi1kHmeBP254sgqB… offer, notes worth 1,680 sat with change outputs,
Web Bot Auth body over BNAP-Context, binding
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: private, no-store
data: {"choices":[{"delta":{"content":"Hello"}}]}
…
event: bnap-receipt
data: AAEBAgAGAgFUCANzYXQKIFyGQCYfbx3jIGounNHC… OK, 850 tokens, charged 340 sat
The ceiling after the discount is 2,100 − 420 = 1,680 sat, so the client sends notes worth 1,680 sat. The call used 850 tokens: ⌈850 × 500 / 1000⌉ = 425 sat before the discount and 340 sat after it, so the notes scheme signs 1,340 sat of change. Every later call sends offer_id instead of the offer and reuses it until it expires, so each call is one round trip.
11. Registry entries
A scheme plugs into the socket only through a registry entry. The entry tells the gate everything it needs, so the gate never trusts a plug’s own description of itself. An entry for a value scheme:
[scheme."bnap:notes"]
role = "value" # value | attestation | both
spec = "https://…/bnap-notes"
binding = "S" # S | A | O | none
use = "single" # single | rate | reusable, as the socket enforces it
local_id = "Y of each input, 33-byte compressed point"
quota_id = "" # attestations only: the stable identity a quota counts against
rail_refs = "none at spend; the funding payment at minting, confirmed by the rail"
body = "TLV, section 3 of the scheme spec"
witness = "one BIP-340 signature per input, over the Digest"
native = [] # kinds, how each embeds the OfferID, native credential bytes
units = ["sat"]
refund = "atomic" # none | out-of-band | atomic
upto = false
properties = { theft = "key-witnessed", link-payment = "unlinkable", link-actions = "unlinkable",
issuer = "self-issued", verify = "offline", finality = "final-on-verification",
custody = "operator-iou", pq = "no", maturity = "experimental" }
conformance = "https://…/results/bnap-notes.json"
Rails and verifiers have entries too, with their rail kind, canonical payment bytes, finality and custody. The gate computes each pay entry’s effective property vector as the per-property minimum over its scheme, rail and verifier entries, with unknown counted as weakest. To be listed, an entry needs:
- a public specification;
- test vectors;
- passing conformance results;
- review by the designated expert.
The maintainer is the designated expert until two independent implementations exist; the registry then moves to IANA.
12. Security considerations
The threat table in the first tab still applies. These points are specific to the bytes:
- Coverage. Offer signatures and MACs, and Response acks, cover every record outside 240–1000, unknown odd ones included. A Credential is not signed as a whole. The Digest covers its offer, schemes, extensions, bodies and binding, and class S witnesses sign that. Anything else in a Credential is ignored. Every hash is tagged, so a value from one context can never be reused in another.
- Offers published in advance are accepted only under keys the gate itself holds and lists (4.4). Without that rule, anyone could mint a cheaper offer.
- Order of checks. The gate checks size, encoding, version, authenticity, expiry and scope before any plug sees the bytes (4.4), so a hostile plug never receives unauthenticated offers.
- Route-scope offers can be reused until they expire. That is safe only because the Context includes the action scope, and only class S and A schemes may use them. A gate MUST NOT list a class O or None scheme, or any native, in a route-scope offer.
- Class O credentials are bearer within their offer, and the registry records their theft property as bearer.
- Untrusted schemes, trusted rails. Canonical payment bytes come from the rail, never from the scheme (6.1), so a broken scheme cannot make one payment count twice.
- Durability. A commit that is not durable when the action starts can let a crash re-admit a spent credential. Stores MUST make commits durable synchronously.
- Fail closed. An unreachable store, signer, rail or verifier means UNAVAILABLE, never admission.
- Retention. Single-use records live as long as the artifact could verify under any offer; deleting them early re-enables double spending.
- Work before authentication. A reusable route offer lets anyone make the gate call a scheme plug with garbage. The gate MUST bound the plug work and the native challenges that unauthenticated requests can cause, per client address and per offer. It SHOULD rate-limit verification, run cheap checks first, and mint native challenges at most once per offer nonce, only on request or under a rate limit.
- Caches. Paid responses carry
Cache-Control: private, no-store(10.2). - Time. Expiry checks allow 60 seconds of skew. Offer lifetimes SHOULD be minutes for exact scope and hours at most for route scope.
- Side channels. MACs are compared in constant time. The
detailtext of an error never reaches a model or a user as instructions. - Privacy. Offers carry a random nonce and no client identifier. Clients never send capability lists. GREASE keeps parsers honest.
13. Registry considerations
This document asks for these registries, held in the BNAP repository until they move to IANA:
| Registry | Initial contents | Policy |
|---|---|---|
| Socket TLV types | Sections 4, 5 and 8 | Standards action |
| Error codes | Section 8.2 | Specification required, codes above 1,000 |
| Rail kinds | ln, onchain, evm, card | Specification required |
| Schemes, rails, verifiers | As section 11 | Expert review |
| Carriers | http; mcp by the MCP carrier note | Specification required |
To IANA directly: the HTTP authentication scheme, used for challenges only, BNAP; the header fields BNAP-Credential, BNAP-Receipt and BNAP-Error, plus BNAP-Context; the well-known URI bnap.
14. Test vectors
These vectors come from a reference encoder of about 360 lines of standard-library Python (bnap_ref.py). Its BIP-340 code reproduces the official BIP-340 vector 0. Secret keys are SHA-256 of the strings shown, and auxiliary randomness is 32 zero bytes. Scheme bodies are placeholders, because no scheme is part of this document; the note’s Y, for instance, is a 32-byte stand-in for a 33-byte point. An independent reviewer has reproduced these vectors from the prose alone. The full set, with every intermediate value, is in vectors.json.
| Vector | Input | Output |
|---|---|---|
| Tagged hash | H_"bnap/offer"("") | 4d1dac84920fd0e91e9cdf1788ac22fed3a61f72bab6752010b0b191b05367ed |
| BigSize | 0, 252, 253, 65535, 65536, 2³² | 00, fc, fd00fd, fdffff, fe00010000, ff0000000100000000 |
| tu64 | 0, 1, 256, 1791700000 | empty, 01, 0100, 6acb2c20 |
| HTTP action scope | POST, target https://api.example/v1/chat/completions, the body in 10.3, no listed headers | 02be38b55551b02bee2daab2ca3d752e9538cc288f57a6dea67ccaf6971b3717 |
| HTTP route scope | origin https://api.example, route chat | 862d641e67813f6e78b20a81aa929519e66a978e69e8a4d3e27280990697f3a0 |
| Offer key | SHA-256(“bnap test offer key”) | b28dad5f717c5144bdb0aa6db94bb6c79afa9b8bd9bc84825a2c1baafe64add9 |
| OfferID | The route offer below | 5c8640261f6f1de3206a2e9cd1c2599a2e9256547242061c95f9d2e06cf6696d |
| Context | OfferID, the action scope above, bnap:notes, httpsig:web-bot-auth, empty ext | e9f5603656d8e54dd25f9f7cee291c56e01cc8e6a80536185d69b1ed12158e6d |
| Digest | That Context, binding SHA-256(“tls-exporter placeholder”) | 7c8f6196ad3adb3cf3199c1ebb79ef8b84ae26d20a01f3a89e336666d530d4c9 |
| Digest, other connection | The same, binding SHA-256(“other connection”) | c9ef748b8fd377c66e6392311fe2e69adcdc30439c6c486444c11dada36a207a |
| UseIds | notes Y = SHA-256(“note secret 1”); bot key SHA-256(“bot key thumbprint”) | b7ca97d6…232b6456, 381a38d9…4bc2fb5e |
| ReplayKey | That Context, both bodies, both UseIds | 74bd8a797c022ff864706809c6e9b23e9affd1829a093db365178db29b0c2771, the same on both connections |
| Attestation only | Exact offer 2511ffa4…f3dadfc8 whose only entry is an httpauth:DPoP waive; no pay, ext or binding | Context 04500cf8…1d5231ca, Digest 475c3682…550c3f47, ReplayKey c9248cf0…8961be9c |
| Native envelope | Exact offer 919103b5…6a2e7562, payment:lightning/charge, bytes “MPP credential placeholder” | Context 38a036b6…6cbb1b78, Digest 198cd8d6…f0a2a04e |
| RailId | ln, payment hash SHA-256(“preimage”) | 5f210bd3b07d0ff1fc78e2f6715ddcad5dcb2bc0147b06d0261c5703f1867744 |
| Native invariance | That exact offer with and without a native record | Same OfferID, different offer_sig |
The route offer used above, 305 bytes: route scope, metered, bnap:notes up to 2,100 sat at 500 sat per 1,000 tokens, a 20% Web Bot Auth discount, a GREASE record of type 3,000,000,001, and the signature and MAC:
0001010220862d641e67813f6e78b20a81aa929519e66a978e69e8a4d3e27280990697f3a0040008000a046acb2d4c
0c2468747470733a2f2f6170692e6578616d706c652f2e77656c6c2d6b6e6f776e2f626e61700e20b28dad5f717c51
44bdb0aa6db94bb6c79afa9b8bd9bc84825a2c1baafe64add91010000102030405060708090a0b0c0d0e0f121a1900
0a626e61703a6e6f7465730202083404037361740a0201f4141e1d0014687474707369673a7765622d626f742d6175
7468020102040207d0f040fa3a850dff40a2fe54f5662580783b3e4d63fbc3a99ea4832465d60427d849cf31d35c75
9da7958591b12c2fc51a4aee30288636999720a9813b714d996d2d3af220a29714a6310d1153b6477c0a06e297d0d1
8f614a52d2bf31926900693620fc13feb2d05e01020a0a
The Response for that call, 850 tokens and 340 sat charged, signed with the receipt key SHA-256(“bnap test receipt key”):
00010102000602015408037361740a205c8640261f6f1de3206a2e9cd1c2599a2e9256547242061c95f9d2e06cf669
6d0c207c8f6196ad3adb3cf3199c1ebb79ef8b84ae26d20a01f3a89e336666d530d4c90e020352101d6368616e6765
207369676e61747572657320706c616365686f6c64657212205f49b61a80a8105a2f727c4c53ee1718252b1e8bc05f
6c62f0242e56286b83db14046acb2c4af040c1d4270a82036d4b4c89f0d39ad62ea3a541fcd299eca075a02a7b1e04
ee01ef784a08eba0996365ae8acb460ae0ee7474602381eba209456ab629bfef6f70f7
The conformance suite will add negative vectors:
- a non-minimal BigSize, records out of order and an unknown even type;
- a flipped bit in each signed record;
- an offer without
macsigned by a key the gate does not hold; - a class O scheme or a native in a route-scope offer;
- an attest list out of order, and a binding with no class S scheme;
- one payment referred to by two schemes;
- two clients sending the same request with different reusable attestations;
- a replay with a changed binding;
- a replay of an OK outcome whose response was not kept;
- a manifest rollback and a double rotation.
15. Changes from v0.5.1
This draft changes the first tab’s design in these places. The first tab will be brought into line.
| Change | Why |
|---|---|
| The Credential is TLV, not CBOR | One encoding for every socket object; one parser to harden |
| Scheme payloads split into body and witness | v0.5.1’s Digest hashed the payload that holds the signature over the Digest, which is circular |
| The Context includes the request’s action scope; offers may name a route | One round trip per call for the LLM proxy; still one request per credential |
| Signature range 240–1000, as in BOLT 12 | Room for more signature types later |
The credential travels in BNAP-Credential, the receipt in BNAP-Receipt | Authorization stays free for the API’s own key |
| Receipts can arrive in a trailer or a final stream event | Metered, streamed completions know their charge only at the end |
| New error codes UNDERPAID, BAD_CREDENTIAL, BINDING and UNAVAILABLE, all numbered | Every failure path in the algorithms has a code |
| The ack signs the whole Response stream | Same fields as v0.5.1’s formula, and extensible |
| The ReplayKey covers bodies and every ID, not only committed ones | Review of draft-00: two clients with reusable credentials could share a ReplayKey and receive each other’s responses |
| Rails, not schemes, supply canonical payment bytes | Schemes are untrusted; a wrong RailId would let one payment count twice |
| Metered offers carry a rate, and Responses the metered units | A client can check the charge, not only its ceiling |
| Credentials may name their offer by OfferID | Keeps credentials inside common header limits |
| The HTTP target is hashed exactly as received, plus listed headers | Normalization was ambiguous and could pay for one action and run another |
| The manifest backup changes only through a rotation; the issued time is checked | Stops rollback, and stops a stolen root from also taking the recovery key |
BNAP: a universal plug for priced and permissioned actions — Draft Specification v0.5.1
2026-10-11 · Dominic
Abstract and status
BNAP is a universal plug for priced and permissioned actions: a small socket every deployment shares, and plug points for everything else. A service can accept any credential scheme, take payment over any rail, speak any carrier, choose its own pricing, storage and key custody, and serve agents built on any model or framework; BNAP does not choose for it. Whatever is plugged in, the socket guarantees four things: an offer the server signed, a credential bound to one offer and, where the scheme allows, one request, an outcome recorded once, and a uniform signed receipt.
Terminology. BNAP (“bee-nap”, Blind-Note Admission Protocol) names the protocol; blind notes are its default scheme, not its only one. Caparra is the Rust reference implementation.
| Term | Meaning |
|---|---|
| Socket | The rules every deployment shares |
| Plug | Any swappable block: a scheme, rail, carrier, store, signer, pricer, wallet or agent adapter |
| Scheme | A way to prove payment or a property, such as blind notes, L402, x402 or Privacy Pass |
| Badge | A label for plug mixes guaranteed to interoperate, such as Core-1 |
| HTTP+BNAP/LN | BNAP carried in HTTP, funded over Lightning |
Status. Pre-draft, version 0.5.1. A fourth design round set a plug architect against a flexibility skeptic and tested the result against the systems in the plug catalogue. An independent review of v0.5 then found four blockers, all fixed in this revision: one payment could be redeemed through several schemes, the Context was circular for artifacts the server makes, payments through native headers bypassed the socket, and the plugin-safety claim was overstated. The v0.4 Cashu profile is now the bnap:notes scheme, specified in the second tab. Key words follow BCP 14.
| Owner decision | Choice |
|---|---|
| Architecture | A universal plug: one socket, plugs for everything else |
| Baseline | A badge, not a gate: any mix that keeps the socket rules is BNAP; Core-1 labels the mix guaranteed to interoperate |
| Non-Bitcoin rails | Pluggable with guardrails: off by default, own unit, held until final |
| Name | Blind-Note Admission Protocol; blind notes are the default scheme |
| Earlier decisions kept | Modest framing; withdrawal is the optional BNAP-W; first deployment AI agents and paid APIs |
| Next step (11 October) | Draft the core socket spec with exact byte layouts and test vectors (third tab) |
| First gateway | An LLM inference proxy: per-call, metered pricing in front of an OpenAI-compatible endpoint |
| Agent-1 | No x402 on Lightning until its spec is published |
What v0.5 changed.
- The core is no longer a Cashu profile but a scheme-agnostic socket; the profile is now one scheme,
bnap:notes, the recommended default. - Fourteen plug points cover every block, from credential schemes to AI-agent adapters.
- L402, MPP, x402, Privacy Pass, Web Bot Auth, OAuth and more plug in as peer schemes or attestations; all but two of the systems tested fit, most through adapters that follow conventions already allowed by the native spec.
- The server signs its offer set, so no proxy can strip a strong scheme from a BNAP offer, and clients that have seen the manifest refuse to fall back to native headers.
- Route policies check scheme properties from a registry, not brand names, so a weak scheme can never silently satisfy a strong requirement.
- One payment admits one action, whichever schemes share it: the socket commits each payment’s rail ID alongside the scheme’s use IDs.
- Plugs never hold keys or write the use store. A hostile scheme can wrongly admit actions only on routes that enable it; it cannot touch other schemes’ uses, keys or receipts.
Why BNAP exists
Agents, scrapers and spammers act at machine speed, and putting a price or a permission on each action stops the actions that are only worth doing for free:
p > v_{action}
Many schemes now solve part of this: L402, x402, MPP, Cashu, Privacy Pass, Web Bot Auth, API keys. Each has its own headers, replay rules, receipts and wallets, so an operator who wants several must build and secure each one, and an agent must speak them all.
BNAP is the socket they share. It replaces none of them and adds four guarantees on top: signed offers, request binding, once-only outcomes and a uniform receipt. Its default scheme, blind notes, adds what none of the others offer together: prepaid credit, spent offline, unlinkable to the payment that funded it.
Non-goals: proving personhood (attestation schemes may do that), stopping well-funded attackers or volumetric floods, replacing the operator’s abuse classifier, and being a token or payment network.
Design principles
- Plug anything, check everything. Plugs propose; the socket decides.
- Properties, not brands. A route requires properties such as single-use, request-bound or unlinkable; any scheme with those properties qualifies.
- Label, don’t gate. Any mix of plugs that keeps the socket rules is BNAP; badges say which mixes are guaranteed to interoperate.
- Reuse existing names. Schemes keep their native identifiers and wire formats; BNAP wraps them, never renames them.
- Contain plugs, trust few. The socket alone computes digests, prefixes and commits use IDs and rail IDs, checks value against price and builds every message the signer signs. Only the store, the signer and the rails are trusted, and the operator supplies them.
- The model never chooses. No language model picks a scheme, sets a cap or holds a key; spending policy lives in code.
- End to end. Abuse is judged by the application; BNAP only makes prices and permissions verifiable.
- No per-client state. The server stores records about credentials, never about people.
- It’s OK to be odd. Unknown even extensions are rejected, unknown odd ones ignored, and GREASE keeps the rule alive.
Architecture: one socket, fourteen plugs
The socket is the only part every deployment shares; every other block is a plug the operator or the client chooses.
Figure: The universal plug · one socket, fourteen plug points. The source tab shows this as an interactive diagram (widget
node/aeebbc11-bb14); it is not part of this export. See the source doc.
Plugs propose and the socket decides: schemes plug in from the registry, carriers carry the result, and client and server plugs supply everything else.
| Plug | Side | What it does | On the wire | Examples |
|---|---|---|---|---|
| Credential scheme | Both | Proves value, yields use IDs, settles change or refunds | Yes | bnap:notes, L402, MPP Lightning, x402, ACT, Cashu NUT-24 |
| Attestation scheme | Both | Proves a property that waives, discounts or meters a price | Yes | Privacy Pass, ARC, Web Bot Auth, OAuth with DPoP, NIP-98, World ID |
| Funding rail | Server | Creates payment requests and reports their status. Operator-trusted | Partly | Phoenixd, LDK Node, NWC, LND, CLN, Ark, Fedimint, Spark, Cashu mints, x402 facilitators, Stripe or Tempo |
| Verifier | Server | Checks a credential locally or through a facilitator, in two phases: verify, then settle | No | Local, x402 facilitator, MPP processor |
| Carrier | Both | Defines the scope and carries offers, credentials and receipts | Yes | HTTP, MCP, SASL, Nostr, gRPC, WebSocket, A2A |
| Pricer | Server | Maps an action to a price | Price only | Static table, load-based, per-token metering, fiat-reference feed |
| Judge | Server | Admits, queues or refuses before commit; hooks for abuse after | No | Rules, WASM policy, classifier sidecar |
| Use store | Server | Atomic records for single-use, rate-limited and reusable credentials and for rail payments. Operator-trusted | No | Postgres, SQLite, FoundationDB, Redis with append-only log |
| Signer | Server | Signs only messages the socket built, and blind signatures up to the change cap the socket set; keys stay in its process. Operator-trusted | Key IDs only | File, separate process, HSM, seed wrapped at rest by a KMS, threshold signer |
| Front | Server | Puts the gate in front of an existing API | No | Tower layer, standalone proxy, Envoy ext_authz, Caddy forward_auth, nginx auth_request |
| Pay source | Client | Pays a payment request | No | NWC, LDK, lnget, Cashu, Ark, Spark or Fedimint wallets, custodial APIs, cards via MPP |
| Spend policy | Client | Caps, allowed schemes, required properties | No | Budget files, NWC budgets, AP2 mandates |
| Wallet | Client | Picks an offer and builds the credential | No | Notes wallet, L402 client, x402 signer |
| Agent adapter | Client | Gives any model the same three tools: paid fetch, budget, receipts | No | MCP server, OpenAI, Anthropic or Gemini tool definitions, LangChain, LlamaIndex, CLI |
The socket
The socket is five objects and their rules: the Offer, the Credential envelope, the use record, the Receipt and the manifest. Every deployment MUST implement them exactly; everything else is a plug.
Normative text. Byte layouts, algorithms and test vectors now live in the Socket spec draft-00 tab, which wins where the two differ. Its section 15 lists the differences, among them: TLV instead of CBOR for the Credential, a body and witness split, a ReplayKey that covers bodies, route-scope offers, and rail IDs supplied by the rail rather than the scheme.
Offer
The Offer generalizes v0.4’s CHALLENGE. It is a TLV stream (Lightning BOLT 1 encoding, BIP-340 tagged hashes) that lists every scheme the server accepts for this action.
| Type | Field | Meaning |
|---|---|---|
| 0 | version | Socket version, 1 |
| 2 | scope | 32-byte hash of the action, defined by the carrier to cover content and recipient |
| 8 | metered | Optional: the final charge is set at settlement, up to the chosen entry’s amount. Only entries whose scheme refunds atomically or has an up-to mode may appear |
| 10 | expires | Unix time after which the offer lapses |
| 12 | info | URI of the manifest |
| 14 | op_key_id | Which online offer key, listed in the manifest, signed this offer |
| 16 | nonce | 16 random bytes |
| 18 | pay | Accepted value schemes in preference order. Each entry: scheme ID, amount, unit (sat, an ISO 4217 code or a CAIP-19 asset), quote expiry and scheme parameters |
| 20 | attest | Accepted attestations: scheme ID, parameters, and effect (required, waive, discount or quota) |
| 24 | issued_at | Self-issued offers only |
| 26 | native | Artifacts the server made for one pay entry, such as an invoice, a macaroon or x402 requirements. Excluded from the OfferID; each embeds it |
| odd, reserved | GREASE | Ignored by every receiver; servers SHOULD include one |
| 240 | offer_sig | BIP-340 signature by the online offer key over every record below 240 |
| 242 | mac | Server MAC over every record below 240, so the server stays stateless |
Types 240 to 255 are signature records, as in BOLT 12. The server builds an offer in two steps. It first computes OfferID = H_“bnap/offer”(every record below 240 except native), then makes each native artifact so that it embeds the OfferID: an invoice’s description hash, an L402 caveat, an MPP id, an x402 extra field. offer_sig and mac then cover everything below 240, natives included, so a client can check that the whole offer set is complete and authentic, and the server can check without state that it issued it. Each pay entry carries its own amount and unit: a route priced in sat and in USDC lists both, and the Pricer converts when it builds the offer. A credential’s value is compared only with its own entry. Clients MUST ignore unknown schemes.
Credential envelope
Credential = {v, offer, pay?, attest[], binding, ext} deterministic CBOR
pay = {scheme, payload} at most one
attest = {scheme, payload} zero or more
OfferID = H_"bnap/offer"(offer records below 240, natives excluded)
Context = H_"bnap/ctx"(OfferID ‖ value scheme ID ‖ sorted attestation scheme IDs ‖ H(ext))
Digest = H_"bnap/cred"(Context ‖ binding ‖ H(pay payload) ‖ H(each attest payload))
ReplayKey = H_"bnap/replay"(Context ‖ sorted socket use IDs) 32 bytes, stored with every use
A request carries at most one value proof and any number of attestations. A scheme that was not in the signed offer is rejected with NOT_OFFERED. The Context covers every chosen scheme and the extensions, so nobody who sees a credential in transit can strip an attestation or change ext without breaking it. Binding is empty or the RFC 9266 tls-exporter value. It enters the Digest but not the Context or the ReplayKey: only class S credentials are TLS-bound, and a retry on a new connection is still recognized as the same credential. Fronts that terminate TLS outside the gate, such as Envoy ext_authz and nginx auth_request, cannot pass the exporter value today, so routes behind them MUST NOT require TLS binding.
Every scheme ties its credential to the offer, and where it can to the request, in one of four registered binding classes. Who makes the artifact decides the class: an artifact the client makes after choosing can embed the Context, while one the server made before the client chose can only embed the OfferID.
| Class | How the credential is tied | Examples |
|---|---|---|
| S | A signature over the Digest: offer, chosen schemes, extensions, payloads and TLS binding | bnap:notes, ACT |
| A | A client-made artifact embeds the Context: offer, chosen schemes and extensions | x402 exact on EVM with nonce = Context, NIP-98 tags, a DPoP claim, a Web Bot Auth signed component, World ID signal, Hashcash |
| O | A server-made artifact embeds the OfferID: one offer and its scope, but not the client’s other choices | L402 macaroon caveat, MPP challenge id, Lightning invoices (payment:lightning/charge, x402 on Lightning), Privacy Pass and ARC redemption contexts |
| None | A bearer credential | Allowed only at price 0, and never with a waive or discount effect |
Class O credentials are bearer within their offer: anyone who sees one before the server does can spend it on that action, so the registry records their theft property as bearer. The gate MUST NOT list in pay a scheme whose class is weaker than the route requires.
Uses and outcomes
Every credential yields use IDs. The socket prefixes each with the scheme ID itself, so no scheme can write into another’s namespace: bnap:notes:<Y>, httpauth:L402:<payment hash>. A scheme that relies on a rail payment MUST also report that payment’s canonical rail ID, such as ln:<payment hash> or evm:<chain>:<token>:<from>:<nonce>, and so MUST any scheme that mints credit from a payment. The socket commits rail IDs in the same atomic step as use IDs, so one Lightning payment offered under L402, MPP and x402 at once still admits one action. The scheme’s registered use model says how use IDs count:
| Use model | Rule | Examples |
|---|---|---|
single | Each use ID admits one action | Blind notes, per-action Lightning, Privacy Pass |
rate(k, epoch) | At most k actions per use ID per epoch | ARC, World ID nullifiers |
reusable(quota) | A metered allowance per use ID | OAuth tokens, API keys, Web Bot Auth keys |
The socket owns one use store and one outcome model for all schemes:
- Verify. Each scheme checks its credential without side effects. A value scheme returns use IDs, rail IDs and a value in its pay entry’s unit; an attestation returns use IDs and its effect.
- Price. The Pricer sets the price in the chosen entry’s unit. Attestations then apply in offer order: every waive first, then discounts, multiplied (two 20% discounts give 36% off, not 40%). Only attestations of class S, A or O may waive or discount.
requireapplies to attestations as to value schemes. - Commit. The socket checks value against the effective price and records every value, attestation and rail use ID with the ReplayKey and status PENDING, in one transaction. Any conflict aborts the whole commit.
- Act. The carrier runs the action and keeps the response for the replay window. A carrier that cannot keep it answers a replay with the stored receipt alone, never a further refund (draft-00, section 6.4).
- Settle. Status moves exactly once to OK or FAILED. For metered actions, the charge is the amount used. On FAILED, value uses stay spent and the scheme refunds as its refund property allows; single-use attestations stay spent; rate counters are given back. A use still PENDING after the manifest’s
pending_timeout(default 300 s) moves to FAILED. - Replay. A request whose use IDs are already committed is a replay only if its ReplayKey matches the record. The socket re-verifies the credential and checks its binding fresh, then returns the recorded outcome with
replayed = true; the action never runs twice. Any other match is SPENT. A replay hands the response to whoever presents a valid credential, so the response is only as private as the scheme’s theft property.
Receipt
Every response carries the same receipt, whichever scheme paid:
Response = {status, replayed, scheme_result, ack}
ack = BIP-340 signature by an online receipt key over
H_"bnap/ack"(Digest ‖ status ‖ charged ‖ unit ‖ H(scheme_result))
An operator whose keys live in an HSM MAY add an ES256 signature as well; BIP-340 stays mandatory.
Errors
The socket’s codes are VERSION, MALFORMED, TOO_LARGE, BAD_OFFER, STALE, WRONG_SCOPE, NOT_OFFERED, POLICY (properties not met), SPENT, PENDING and RATE_LIMITED. Scheme errors are namespaced, such as bnap:notes/OUTPUT_REUSED.
Manifest and pinning
/.well-known/bnap is signed by an offline root key. It lists versions; the short-lived online offer and receipt keys, with their expiry; one backup root key; routes with prices and offers in preference order; carriers; limits such as replay_window and pending_timeout; a sequence number seq; and a max_age of at most 30 days.
Clients pin the root key and its backup, never per-route scheme lists. Scheme lists change whenever an operator pulls a broken adapter or a rail goes down, and pinning them would lock clients out, as HPKP did; offer_sig already stops anyone stripping a scheme from a BNAP offer. A client that meets an offer key it does not know refetches the manifest over HTTPS and accepts it if the root or the backup signed it with a higher seq.
A manifest MAY set bnap-required. A client that has seen it MUST NOT pay that origin through native headers until max_age lapses, as with HSTS. This is the only defence against the real downgrade, deleting the BNAP header so a client falls back to L402, MPP or x402, and a tier-0 client that never saw the manifest cannot detect it. First contact is trust on first use; operators who want more publish the root key out of band, in DNS, on Nostr or in their documentation.
Extensibility
TLV records follow the even/odd rule: an unknown even type is rejected, an unknown odd type ignored. GREASE TLV types are odd, so receivers keep ignoring them; scheme IDs and carrier names reserve GREASE values too. Types 240 to 255 hold signatures. Carriers that use JSON, such as MCP, canonicalize it with RFC 8785.
Scheme properties and route policy
Every registered scheme carries a property vector, and a route admits a scheme only if every property meets the route’s requirement. This is how flexibility stays safe: an operator can plug in any scheme, but a weak one can never quietly satisfy a strong requirement.
| Property | Values, weakest to strongest |
|---|---|
| use | reusable → rate-limited → single, as the socket enforces it |
| binding | none → O → A → S |
| theft | bearer → key-witnessed |
| link-payment | linkable to the payment → unlinkable from it |
| link-actions | linkable across actions → unlinkable across them |
| issuer, as the server trusts it | third party → facilitator → self-issued |
| verify | online facilitator → offline |
| finality | reversible → freezable → final at settlement → final on verification |
| custody, as the client trusts it | third-party custodial → operator IOU → client self-custody |
| refund | none → out of band → atomic in protocol |
| pq | no → yes |
| maturity | experimental → standard |
Rules:
- Property vectors come from registry entries, set from conformance results. Plugins MUST NOT declare properties at runtime.
- Schemes, rails and verifiers each have entries. The gate computes each pay entry’s effective vector as the per-property minimum over its scheme, its rail and its verifier: notes funded by card are reversible and linkable to the payer, and x402 through a custodial facilitator is custodial.
- An unknown or missing property counts as its weakest value.
- The check is per property; there is no overall score. Link is two properties because an EVM address can be linkable to the payment and across actions at once.
- Units are not a property: each pay entry carries its own, and a route’s
priceis a map of units. - The gate never lists in
payan entry whose effective vector fails the route’srequire, and refuses to start if a route can be satisfied by none of its enabled schemes. - Experimental schemes need
allow_experimentalon each route that uses them.
[[route]]
match = "POST /v1/chat"
price = { sat = 21 } # a map of units; the Pricer converts
require = { use = "single", binding = "O", link-actions = "unlinkable" }
accept = ["bnap:notes", "payment:lightning/charge", "httpauth:L402"]
attest = [{ scheme = "httpsig:web-bot-auth", effect = "discount", percent = 20 }]
Here a reusable bearer token could never admit a chat request, however it was configured. Blind notes (class S) and per-action Lightning through MPP or L402 (class O) all qualify, and all three share one rail ID per payment. Raise the route to binding = "A" and the gate offers blind notes alone.
Negotiation
The server signs the list of what it accepts; the client picks one and never says what it supports. One 402 response carries the BNAP Offer plus each foreign scheme’s own native challenge, so clients that only speak L402, MPP or x402 still work unchanged.
HTTP/1.1 402 Payment Required
WWW-Authenticate: BNAP offer="<base64url Offer: scope, expires, nonce,
pay=[bnap:notes 21 sat, payment:lightning/charge 21 sat, httpauth:L402 21 sat, GREASE],
native=[invoice with description hash = OfferID, macaroon], offer_sig, mac>",
info="/.well-known/bnap"
WWW-Authenticate: Payment id="<embeds OfferID>", realm="api.example", method="lightning",
intent="charge", expires="…", request="…"
WWW-Authenticate: L402 macaroon="<caveat bnap_offer = OfferID>", invoice="lnbc210n1…"
POST /v1/chat … Authorization: BNAP <base64url Credential{offer, pay: bnap:notes, binding}>
HTTP/1.1 200 OK Authentication-Info: bnap="<base64url Response with ack>"
Client choice, in order:
- Verify
offer_sigwith an offer key the pinned root signed; on an unknown key, refetch the manifest. - Keep the offers the client can use.
- Drop offers its spend policy forbids. By default a linkable scheme is used only when no unlinkable one is offered, or the user has opted in.
- Rank by total cost: each entry’s own amount converted to the budget’s unit, rail fees and a linkability penalty. The server’s order breaks ties.
- Send exactly one value credential and any attestations.
Native credentials. A tier-0 client pays through a scheme’s own header and sends no envelope. Every adapter MUST therefore embed the OfferID in its native artifact and check it on redemption, and the artifact must be one the server can authenticate without state (a macaroon, an HMAC’d MPP id, an invoice its own node issued); otherwise the server keeps the offer in a short cache keyed by nonce. The socket then builds a synthetic envelope: Context = H_“bnap/ctx”(OfferID ‖ scheme ID) and Digest = H_“bnap/cred”(Context ‖ H(native credential bytes)). Use IDs, rail IDs, replay and the BNAP receipt follow from it, and the receipt goes in Authentication-Info next to the scheme’s own. A request carrying more than one value credential, native or BNAP, is rejected.
Native challenges and abuse. Native challenges cost the server an invoice and a signature, so they must not turn an anti-spam protocol into an amplifier. A server MUST mint at most one invoice per offer nonce, MAY reuse one native challenge per scope for a short TTL, and SHOULD rate-limit minting per client address. It MAY instead send only the BNAP offer and mint natives when a client asks for a scheme, at the cost of tier-0 clients.
Plug catalogue
The abstraction was tested against every system in the table below, some rows covering several related systems. All but two fit as scheme, attestation, rail, carrier or client plugs: Cloudflare pay-per-crawl, whose payment side is closed, and AP2 as a direct per-action payment, though AP2 mandates work as a client spend policy. “Adapter” means the scheme fits once its artifact carries a BNAP value, such as an L402 caveat, an MPP id or an x402 nonce. Each is a convention the native spec already allows, but both sides must follow it.
| System | Plugs into | Binding | Use model | Fit |
|---|---|---|---|---|
| Blind notes (bnap:notes) | Credential | S | single | Native, the default |
| L402 (bLIP-26) | Credential + funding | O, OfferID caveat | single, enforced by the socket | Adapter |
| MPP (draft-httpauth-payment) | Credential envelope + methods | O, OfferID in the HMAC’d id | single | Adapter |
| x402 on EVM chains | Credential + facilitator verifier | A, client nonce = Context | single | Adapter, guarded rail |
| x402 on Lightning | Credential + facilitator verifier | O, invoice | single | Pending a published spec |
| Cashu NUT-24, NUT-18, NUT-26 | Credential + carrier | O when locked to the operator’s key, else None | single | Adapter; unlocked only at price 0 |
| Cashu NUT-22 blind auth | Credential | None, bearer | single | Native at price 0 |
| ACT | Credential | S | single | Native, reserved |
| Privacy Pass (RFC 9576–9578) | Attestation | O, redemption context | single | Native |
| ARC | Attestation | O, presentation context | rate | Native |
| Web Bot Auth | Attestation | A, Context as a signed component | reusable | Adapter |
| OAuth and API keys, with DPoP | Attestation | A with a DPoP claim, else None | reusable | Adapter; without DPoP, never waive or discount |
| NIP-98 | Attestation + Nostr carrier | A, tags | reusable | Adapter |
| World ID | Attestation | A, signal | rate | Adapter |
| Hashcash, Anubis | Credential, unit work | A | single | Adapter, weak |
| NWC (NIP-47) | Pay source, funding rail | — | — | Native |
| Fedimint, Ark, Spark | Pay source, funding rail | — | — | Adapter |
| MCP, A2A | Carrier, agent adapter | Scope from tool and arguments | — | Adapter |
| AP2 mandates | Client spend policy | — | — | Adapter |
| Cloudflare pay-per-crawl | Pricer signal only | — | — | Does not fit |
Naming and the registry
Scheme IDs reuse existing registries as <registry>:<id>[@<qualifier>], so BNAP never renames anything:
| Prefix | Source registry | Examples |
|---|---|---|
bnap: | BNAP’s own schemes | bnap:notes |
payment: | HTTP Payment Methods and Intents (draft-httpauth-payment) | payment:lightning/charge |
x402: | x402 schemes and networks as x402 names them: CAIP-2 for EVM chains; lnbtc is x402’s own label, not CAIP-2 | x402:exact@lnbtc, x402:exact@eip155:8453 |
pp: | Privacy Pass token types | pp:0x0002 |
httpauth: | HTTP authentication schemes | httpauth:L402, httpauth:DPoP |
httpsig: | HTTP message signature tags | httpsig:web-bot-auth |
cashu:, nip: | Cashu NUTs, Nostr NIPs | cashu:nut24, nip:98 |
vnd. | Unregistered vendor schemes | vnd.example.pow |
The registry starts as a file in the BNAP repository: each entry needs a public specification, a property vector set by conformance results, and the maintainer’s review as designated expert. It moves to IANA once two independent implementations exist.
The default scheme: bnap:notes
bnap:notes is the v0.4 Cashu profile, now one plug among many and the recommended default, because it is the only scheme that is unlinkable, verified offline and refunded atomically, all at once. Its full specification is the second tab; the changes for v0.5 are small.
How it works, in one paragraph. The service runs its own Cashu-style issuer. A client buys credit over Lightning (Cashu NUT-23, NUT-04, NUT-20) and holds it as Notes: Cashu proofs whose secret is the hex of a public key K. To spend, it signs the socket’s Digest with each K and sends blinded outputs for change and refunds. The issuer checks the notes offline, and the socket records each note’s Y as a single-use ID. Wallets recover from a seed through NUT-13, NUT-09 and NUT-07.
| Property | Value |
|---|---|
| use / binding / theft | single / S / key-witnessed |
| link-payment / link-actions | unlinkable, except that the first action after funding can be correlated by timing / unlinkable |
| issuer / verify | self-issued / offline |
| finality / custody | final on verification / operator IOU |
| refund | atomic in protocol |
| pq / maturity | no / experimental until the NUTs are accepted |
These are the scheme’s own entries. Funded by a card or another reversible rail, the effective vector drops to reversible and linkable to the payer, as the rule on effective vectors requires. The notes unit is sat.
What moved into the socket: the CHALLENGE became the Offer, with the notes-specific fund_quote now a parameter of the notes offer; witnesses sign the socket Digest; commit, replay, receipts and the core error codes are the socket’s; scheme errors are namespaced bnap:notes/….
What stays in the scheme: the Note format, key isolation, input fees, refund outputs, funding inside the 402, seed recovery, the spend-store layout and the BNAP-W withdrawal extension.
Value rails
Rails are plugs: BNAP fixes how a payment request is asked for and confirmed, never which network or provider carries it. Lightning BOLT11 is the default rail and part of the Core-1 badge; everything else is the operator’s choice.
| Rail | Finality | Status in the reference implementation |
|---|---|---|
| Lightning (Phoenixd, LDK Node, NWC, LND, CLN, LSPs) | Final | Default |
| Ark, Spark, Fedimint, Cashu mints | Final once settled; custody varies | Optional plugs |
| On-chain Bitcoin | Final after confirmations | Optional plug |
| Stablecoins (x402 on EVM chains, Tempo) | Final at settlement, freezable by the issuer | Guarded, off by default |
| Cards (MPP through Stripe) | Reversible for months | Guarded, off by default |
Guardrails for non-Bitcoin rails. These rails plug in freely, under rules that keep them from weakening anyone else:
- They are off by default: a build feature plus an explicit setting turns each one on.
- Credit they fund carries its own unit (
usd,euror a CAIP-19 asset) and is never mixed intosatcredit. - A reversible rail may only fund credit, never admit an action directly, and that credit is held until the payment is final or capped per epoch. A stolen card must not be able to buy spam.
- The manifest marks every such rail with its finality and custody properties, so clients and route policies see them.
- Facilitators (x402) are verifier plugs with their trust level declared: non-custodial relay, custodial, or merchant of record.
Carriers
A carrier defines two things: the scope function that names one action, and where offers, credentials and receipts travel. Any protocol with a slot for them can carry BNAP.
| Carrier | Scope covers | Offer, credential and receipt travel in | Status |
|---|---|---|---|
| HTTP | Method, normalized absolute URI, Content-Digest | WWW-Authenticate, Authorization, Authentication-Info | Core-1 |
| MCP | Server URI, tool name, RFC 8785 canonical JSON of the arguments | JSON-RPC error data, request _meta, result _meta | Agent-1 badge |
| SASL | Service, mechanism and server nonce | SASL challenge and response | Optional |
| Nostr | Relay URL and event ID | Event tag and relay OK message; offers self-issued | Optional |
| gRPC | Service, method and message digest | Metadata | Optional |
| WebSocket | Endpoint and message digest | Per-message envelope | Optional |
| A2A | Agent card and task digest | Task metadata | Optional |
Recent MCP revisions favour stateless servers, which suits BNAP: an Offer carries everything needed to verify the credential later. TLS binding is not available through most MCP hosts, so MCP credentials rely on scope binding.
Agents and clients
BNAP never names a model, a framework or a wallet. Every agent adapter exposes the same three tools to any model that can call tools: fetch_paid, budget and receipts.
| Client plug | Choices |
|---|---|
| Agent adapter | MCP server, OpenAI, Anthropic or Gemini tool definitions, LangChain, LlamaIndex, the bnap CLI |
| Wallet | Notes wallet with seed recovery, L402 client, x402 signer, any third-party wallet that implements a scheme |
| Pay source | NWC, LDK, lnget, Cashu, Ark, Spark or Fedimint wallets, custodial APIs, cards through MPP |
| Spend policy | Budget files, NWC budgets, AP2 mandates, organisation rules |
Rules for every client, whatever its plugs:
- Spending caps per origin and per task are enforced in code. A model never picks a scheme, sets a cap or holds a key, and a prompt-injected “pay with X” goes through the same policy check as anything else.
- The client verifies
offer_sigwith a key its pinned root signed before paying, and honours bnap-required. - It never advertises which schemes it supports.
- It keeps the receipt of every paid action.
| Tier | The client has | Example |
|---|---|---|
| 0 | Any single-scheme client | lnget for L402, an x402 or MPP client |
| 1 | A BNAP wallet plus any pay source | bnap get with an NWC connection |
| 2 | A BNAP wallet with its own seed and several schemes | Picks per route by properties and cost |
Reference implementation: Caparra as a plug host
Caparra is a host for plugs: the socket in one small crate, every plug point a Rust trait, and three ways to load a plug so that no operator is tied to Rust or to the project’s own choices.
| Crate | Role |
|---|---|
bnap-core | The socket: Offer, envelope, Digest, use store logic, outcome state machine, receipts, test vectors |
bnap-plug | The plug traits, plus the WIT interface definition generated from them |
bnap-scheme-* | notes, l402, mpp, x402, privpass, webbotauth, oauth |
bnap-rail-* | nwc, phoenixd, ldk, lnd, ark, fedimint, plus off-by-default x402-evm and mpp-card |
bnap-store-*, bnap-signer-*, bnap-carrier-* | Postgres and SQLite; file, process and KMS-unwrap signers; HTTP, MCP, SASL, Nostr |
caparra-gate, caparra-wallet, bnap-mcp, bnap-cli | The gateway, the client wallet, the MCP server and the CLI |
#![allow(unused)]
fn main() {
#[async_trait] pub trait CredentialScheme: Send + Sync {
fn id(&self) -> SchemeId; // properties come from the registry, not from here
async fn offer(&self, cx: &OfferCtx) -> Result<SchemeOffer>; // any native artifact embeds cx.offer_id
async fn verify(&self, c: &Credential, cx: &VerifyCtx) -> Result<Verified>; // no side effects; cx.now, cx.signer
async fn settle(&self, v: &Verified, out: Outcome, s: &CappedSigner) -> Result<SchemeResult>;
}
pub struct Verified { pub use_ids: Vec<LocalUseId>, // bnap-core prefixes these with the scheme ID
pub rail_ids: Vec<RailId>, // ln:<payment hash>, evm:<chain>:…; committed with the use IDs
pub value: Amount } // in the chosen pay entry's unit
pub trait Carrier { fn scope(&self, r: &Request) -> Scope; fn extract(&self, r: &Request) -> Option<RawCredential>;
fn offer(&self, o: &SignedOffer) -> Response; fn receipt(&self, r: &Receipt, out: &mut Response); }
#[async_trait] pub trait FundingRail { async fn request(&self, a: Amount, memo: [u8; 32]) -> Result<PayRequest>;
async fn status(&self, id: &PayId) -> Result<PayState>; } // operator-trusted
#[async_trait] pub trait UseStore { // operator-trusted
async fn commit(&self, ids: &[UseId], key: ReplayKey) -> Result<Commit>; // New | Replay | Conflict, all or nothing
async fn finalize(&self, ids: &[UseId], key: ReplayKey, s: Final) -> Result<()>;
async fn expire_pending(&self, older_than: Duration) -> Result<u64>; } // PENDING past pending_timeout -> FAILED
#[async_trait] pub trait Signer { // operator-trusted
async fn schnorr(&self, k: KeyRef, msg: [u8; 32]) -> Result<Sig>; // msg built by bnap-core only
async fn verify_unblinded(&self, ks: KeysetId, y: Point, c: Point) -> Result<bool>;
async fn blind_sign(&self, ks: KeysetId, outs: &[(Amount, Point)], cap: Amount) -> Result<Vec<(Point, Dleq)>>; }
// refuses when outs sum above cap; bnap-core sets cap = value - charged
#[async_trait] pub trait Pricer { async fn price(&self, a: &Action, unit: &Unit) -> Result<Amount>; }
#[async_trait] pub trait Judge { async fn admit(&self, a: &Action, p: &Properties) -> Decision; }
#[async_trait] pub trait PaySource { async fn pay(&self, r: &PayRequest, b: &Budget) -> Result<PayProof>; }
}
Only bnap-core computes the OfferID, Context, Digest and ReplayKey, prefixes use IDs, compares value with price, commits uses and builds every message the signer signs. A scheme gets a CappedSigner that can verify notes and sign blinded outputs up to the change cap bnap-core computed, and nothing more. Every other plug gets read access to what it needs and returns data.
Three ways to load a plug:
| Way | For | Isolation | Release |
|---|---|---|---|
| Compiled in (Cargo features) | First-party plugs | Same process | v0.5 |
| Sidecar over gRPC or HTTP, same interface | Any language, heavy dependencies such as an x402 facilitator, LND or a Python classifier | Separate process | v0.6 |
WASM component (WIT world bnap:plug, WASI 0.3) | Third-party schemes, pricers and judges | Sandbox with fuel and memory limits, no ambient network | v0.7 |
Fronts and bindings. Operators on any stack put caparra-gate in front as a standalone proxy, Envoy ext_authz, Caddy forward_auth or nginx auth_request, so they need no Rust at all. Client libraries ship through UniFFI (Swift, Kotlin, Python), napi-rs (Node) and wasm-bindgen (browsers).
One config, any mix:
[gate] origin = "https://api.example"; front = "envoy-ext-authz"; replay_window_s = 86400; pending_timeout_s = 300
[manifest] root_key = "file:/etc/caparra/root.pub"; backup_key = "file:/etc/caparra/backup.pub"; max_age_s = 604800; bnap_required = true
[store] kind = "postgres"; url = "env:DATABASE_URL"; durability = "sync"
[signer] kind = "process"; socket = "unix:///run/caparra-signer.sock"; seed_wrapped_by = "aws-kms"
[[rail]] id = "ln"; kind = "phoenixd"; url = "http://127.0.0.1:9740"
[[rail]] id = "ln2"; kind = "nwc"; uri = "env:NWC_RECEIVE_ONLY"
[[scheme]] id = "bnap:notes"; rails = ["ln", "ln2"]; max_fund_sat = 10000
[[scheme]] id = "httpauth:L402"; rail = "ln"
[[scheme]] id = "x402:exact@eip155:8453"; unit = "eip155:8453/erc20:0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
facilitator = "https://fac.example"; enabled = false
[[attest]] id = "httpsig:web-bot-auth"
[pricing] kind = "table"; file = "prices.toml"
[judge] kind = "sidecar"; url = "grpc://classifier:7001"
Plugin security
Plugs may come from anyone, so the host assumes any plug can be buggy or hostile and limits what it can reach.
- Two trust levels. The use store, the signer and the funding rails are operator-trusted: the operator supplies them and the gate runs only versions it pinned. Schemes, attestations, verifiers, carriers, pricers and judges are contained.
- What a hostile scheme can do. It can admit unpaid actions on the routes that enable it, as any broken payment check could. It cannot write another scheme’s use IDs, because bnap-core prefixes them. It cannot reuse a payment another scheme used, because bnap-core commits rail IDs. It cannot get a signature on a message bnap-core did not build, or blind signatures above the change cap. Enable third-party schemes only on routes whose losses you accept.
- No keys. No plug sees an issuer key or a note key. The signer itself verifies unblinded notes and signs blinded outputs.
- Sandboxes. Third-party plugs run as WASM components with only the capabilities their WIT world declares, CPU and memory limits, and no ambient network or files. The host passes the current time in each call, so expiry checks still work. No plug runs inside the signer process.
- Supply chain. Plugs are signed (Sigstore), logged in a transparency log and built reproducibly. The gate pins plug hashes and checks a revocation list, and refuses a known-broken version even when configured.
- Safe defaults. Experimental schemes and non-Bitcoin rails stay off until turned on per route.
Badges, profiles and conformance
Any deployment that keeps the socket rules is BNAP, whatever its plugs. Badges label the mixes that are guaranteed to work together, so flexibility never costs interoperability for those who want it.
| Badge | Requires, on server and client | Guarantees |
|---|---|---|
| Core-1 | The socket, bnap:notes, HTTP, Lightning BOLT11 funding | Any two Core-1 parties can transact with notes that are unlinkable across actions. Provisional until the notes NUTs are accepted; until then Core-1 is the one place an experimental scheme is on by default |
| Agent-1 | Core-1 plus the MCP carrier and httpauth:L402 or payment:lightning/charge | Agents with no BNAP code can pay over Lightning, and MCP tools can be priced. payment:lightning joins as of right once it is registered; x402 on Lightning once its spec is published |
| Fiat-Funding | A separate unit, credit held until final | Cards and stablecoins can fund credit without weakening sat credit |
| Nostr-1, SASL-1 | The carrier profile | Priced relay writes; priced mail and chat |
An L402-only or x402-only deployment that keeps the socket rules is still BNAP, just without a badge.
Conformance is the product. A badge is earned by passing the public test suite, not by a claim. The suite includes negative tests: stripped offers, cross-scheme replay, a wrong scope, a status flip on replay, an unsatisfiable route, and a plug trying to sign or commit.
Security and privacy considerations
Flexibility adds threats a single scheme does not have; each has a socket rule.
| Threat | Closed by |
|---|---|
| A proxy strips the strong scheme from an offer | Signed offers; the chosen schemes inside the Context |
| A proxy deletes the BNAP header so the client pays natively | bnap-required for clients that saw the manifest. A tier-0 client cannot detect it |
| A weak scheme satisfies a strong route | Registry vectors, effective vectors over scheme, rail and verifier, per-property checks, unknown = weakest |
| One payment admits actions through several schemes | Canonical rail IDs committed with the use IDs; one value credential per request |
| An observer re-sends a credential with altered ext or attestations | The Context covers attestation IDs and ext; bearer attestations cannot waive or discount; class O is registered as bearer |
| A reusable token treated as single-use | Use model from the registry, enforced by the use store |
| Stolen cards fund spam | Reversible rails fund credit only, in their own unit, held until final |
| A hostile scheme admits unpaid actions | Contained to the routes that enable it; prefixed use IDs, rail IDs and the change cap keep it from anything else |
| A hostile plug forges receipts or inflates notes | No plug reaches a key; the signer signs only bnap-core’s messages and change within the cap |
| Native challenges used as a DoS amplifier | One invoice per offer nonce, cached or lazy natives, rate limits |
| A first-contact attacker pins a hostile key | Pinning only for HTTPS-fetched manifests, max_age capped at 30 days, backup key, higher seq replaces |
| A crash leaves uses PENDING | pending_timeout moves them to FAILED |
| A route that nothing can satisfy | The gate refuses to start |
| Prompt injection steers an agent’s payment | Spend policy in code; the model never chooses |
| Clients fingerprinted by their schemes | Clients never advertise capabilities |
| Privacy split across many schemes | At most one privacy-bearing scheme per unit per origin is RECOMMENDED |
The scheme-level threats and residual risks of blind notes stay as specified in the second tab. Two residual risks are new: each linkable scheme an operator enables lowers the privacy of the clients who use it, and each facilitator or custodial rail adds a party who can censor or freeze payments. Route policies and client policies make both visible; they cannot remove them.
Roadmap and standardization
The socket ships first, with the plugs agents need on day one; the plug runtimes and the registry grow with adoption.
| Release | Adds |
|---|---|
| v0.5 | The socket in bnap-core; compiled-in plugs: bnap:notes, payment:lightning/charge, httpauth:L402; OAuth with DPoP and Web Bot Auth as attestations; HTTP and MCP carriers; NWC and Phoenixd rails; the conformance suite |
| v0.6 | Sidecar plugs over gRPC or HTTP; the x402 adapter, on Lightning once its spec is published; SASL and Nostr carriers; Ark and Fedimint rails; guarded fiat funding behind the Fiat-Funding badge |
| v0.7 | WASM component plugs; Privacy Pass, ARC and ACT schemes |
Documents, in order:
- The BNAP socket (about 15 pages): Offer, envelope, use and outcome model, receipt, manifest, properties, registry rules. Published in the repository, later an IETF individual draft.
- NUT-XX key-secret proofs and NUT-XX HTTP 402 with a self-issued mint: the
bnap:notesscheme, for the Cashu NUTs. - HTTP Payment Method registration of
cashu, and support for registeringlightning, in the empty registries of draft-httpauth-payment. - The MCP carrier note, aligned with the MPP and x402 MCP transports.
- NIP-XX priced relay writes, then SASL.
The registry lives in the repository with the maintainer as designated expert until two independent implementations exist; then it moves to IANA.
Appendix A: Debate record
Round 4, 11 October: the universal plug
A plug architect designed the socket and plug points, a flexibility skeptic attacked them, and an ecosystem mapper tested them against the systems in the plug catalogue. Where they still differed, the owner decided.
| Question | Positions | Verdict |
|---|---|---|
| Where can plugs attach? | Architect: anywhere. Skeptic: anywhere except the credential, which only registered suites may fill | Anywhere, but credential and attestation schemes need registry entries with property vectors |
| Who states a scheme’s properties? | The plugin, or a registry | The registry, set by conformance results; unknown counts as weakest |
| Offer authentication | Unsigned (v0.4), or a signed offer set plus pinning | Signed offer set plus pinning; a blocker even before v0.5 |
| Baseline | Notes mandatory everywhere, or no baseline | Owner: a badge, not a gate (Core-1) |
| Non-Bitcoin rails | Into sat credit, a separate unit held until final, or none | Owner: pluggable with guardrails |
| Credentials per request | One scheme, or one payment plus attestations | One value credential plus any attestations |
| Reusable credentials | Exclude them, or model them | Use models: single, rate-limited, reusable |
| Scheme names | A new namespace, or existing registries | Existing registries with prefixes |
| Plug runtime | Native only, or WASM for third parties | Compiled now, sidecars next, WASM after |
| Name | Rename, or keep | Owner: keep Blind-Note Admission Protocol |
Earlier rounds
| Round | Settled |
|---|---|
| 1, 9 October | The operator issues its own notes; per-action prices over refundable bonds; application carriers first |
| 2, 10 October | One primitive, three verbs; the signature covers the whole spend; bonds and transport binding leave the core |
| 3, 10 October | Notes become a Cashu profile with seed recovery, NUT-23 funding, refund outputs and recomputed replies |
Independent reviews of v0.1, v0.3, v0.4 and v0.5 found fifteen blockers between them, all fixed.
Review of v0.5, 11 October
| Finding | Fix in v0.5.1 |
|---|---|
| Blocker: one Lightning payment offered under L402, MPP and x402 could admit one action per scheme | Schemes report canonical rail IDs; the socket commits them with the use IDs |
| Blocker: the Context was circular for artifacts the server makes before the client chooses | OfferID; class O replaces N; class A only for client-made artifacts |
| Blocker: payments through native headers had no Context, Digest or replay | Adapters embed the OfferID; the socket builds a synthetic envelope |
| Blocker: “a hostile plugin can never cheat” was false | Trusted store, signer and rails; prefixed use IDs; a capped signer for change; the claim restated |
| Digest malleable by anyone who saw the credential | Context covers attestation IDs and ext; no waive or discount for bearer attestations |
| Attestation effects undefined | Waive, then multiplied discounts; one transaction for every use ID; rules on FAILED |
| Replay broken across connections and crashes | ReplayKey without binding; re-verification; cached responses; pending_timeout |
| One unit per offer | Amount and unit per pay entry; price as a map of units |
| Metered actions overpaid | Only refundable or up-to schemes; the ack covers the charge |
| Pinning scheme lists repeats HPKP | Pin the root key only; bnap-required; tier-0 limit stated |
| Properties depend on rail and facilitator | Effective vectors computed by the gate |
| Invoices as a DoS amplifier | One invoice per nonce, cached or lazy natives, rate limits |
| Minor | Two link properties, viewpoints labelled; None rule aligned with Cashu; lnbtc labelled; badges provisional; catalogue claim corrected; odd GREASE, signature range, RFC 8785; fronts without TLS binding; KMS wraps the seed at rest; time passed to WASM |
Open questions
- Socket byte layout. Define the Offer’s
payandattestrecord encodings and publish test vectors. - Foreign headers. Verify the current x402 v2 header names and the L402 and MPP challenge syntax before shipping their adapters.
- Registry seed. Write the first property vectors for every scheme in the catalogue and the conformance tests behind them.
- First gateway routes. The first gateway is an LLM inference proxy: set metered prices per model, the maximum tokens per offer, and which schemes each route accepts.
- Cashu maintainers: NUT numbers, the NUT-13 derivation type, alignment with NUT-24.
- HTTP Payment Methods: draft the
cashuregistration. - KMS signing. AWS KMS cannot produce BIP-340 signatures, so the seed is KMS-wrapped at rest and lives in the signer process; confirm that is acceptable, or use a threshold signer. x402 on Lightning: confirm its network label and whether a client can choose a nonce, which would move it from class O to A.
- Legal reading of non-withdrawable credit, BNAP-W, and fiat-funded credit.
Sources
Opened during round 4:
- draft-httpauth-payment-01 and paymentauth.org
- MPP reference
- x402 specification v2 and x402 network support
- Block adds Lightning to x402
- RFC 9577 and RFC 9578
- RFC 7457: TLS attacks, including stripping, RFC 8446, RFC 6749, Logjam
- Web Bot Auth draft
- AAIF: MCP and payments
- Cashu NUT-24
- Cloudflare pay-per-crawl
- Spark on AP2, published by a competing protocol
- WASI 0.3 release
Sources for earlier rounds are listed in the other tabs.
Errata: draft-00, reference code and vectors, v0.5 overview
Spec text licensed CC-BY-4.0, (c) BNAP contributors. Compiled 2026-10-13.
This table lists every inconsistency found between three sources:
- draft-00: socket-draft-00.md, doc rev 39. Normative.
- ref / vectors: bnap_ref.py (sha256
aaede669…bbc47d1e) and vectors.json (sha256f21ff83a…4094ae14). - overview: overview-v0.5.md, v0.5.1, doc rev 33.
Rows 30 and 31 are internal to draft-00 or concern the bnap:notes tab; rows 32 to 34 come from findings outside these sources, supplied with the export task; row 35 comes from the end-to-end conformance run against a real gate.
Rule. Draft-00 wins unless a vector proves otherwise. Where draft-00 is silent, the row says so, and adding a normative rule is marked as an owner decision.
Draft-00 against the vectors. No row: they agree. All 11 full hex values and all 9 shortened values in draft-00 section 14, and both hex blocks (the 305-byte offer and the Response), match vectors.json. The base64url prefixes in the 10.3 example decode to the start of the vector offer, credential and Response, and BNAP-Error: AAEBAgEQ decodes to code 16. The 10.3 arithmetic holds (1,680 sat ceiling, 425 sat before and 340 sat after the discount). The BIP-340 code reproduces official vector 0, and regenerating the vectors gives identical bytes.
| # | where | draft-00 says | other source says | resolution (draft-00 wins unless the vector proves otherwise) | owner decision needed (yes/no) |
|---|---|---|---|---|---|
| 1 | Credential encoding: draft-00 2, 5.1, 15; overview “Credential envelope” | The Credential is a TLV stream: 0 version, 2 offer, 4 pay, 6 attest, 8 binding, 10 ext, 12 offer_id; exactly one of offer and offer_id | Overview: Credential = {v, offer, pay?, attest[], binding, ext} in deterministic CBOR, with no offer_id. Ref: the credential vector (581 bytes) is TLV | TLV, as draft-00 section 15 lists; the vector agrees. The overview is to be brought into line | no |
| 2 | Scheme payloads: draft-00 5.1; overview “Credential envelope” | Each scheme sends a body and a witness; only class S has a witness; the Digest covers bodies, never witnesses | Overview: pay = {scheme, payload}, attest = {scheme, payload}, and the Digest hashes each payload. Ref: the notes body and witness are separate records (types 2 and 4 of the pay stream), and the witness signs the Digest | Body and witness, as draft-00 section 15 lists (the v0.5.1 Digest was circular) | no |
| 3 | Context: draft-00 5.2; overview “Credential envelope” | H_"bnap/ctx"(OfferID ‖ action_scope ‖ lp(pay scheme ID, or empty) ‖ BigSize(n) ‖ lp(attest scheme ID₁) ‖ … ‖ H(ext value, or empty)) | Overview: H_"bnap/ctx"(OfferID ‖ value scheme ID ‖ sorted attestation scheme IDs ‖ H(ext)), with no action scope, length prefixes or count. Ref: context() follows draft-00; credential.context = e9f56036…12158e6d | Draft-00; the vector proves it | no |
| 4 | Digest: draft-00 5.2; overview “Credential envelope” | H_"bnap/cred"(Context ‖ lp(binding, or empty) ‖ H(pay body, or empty) ‖ H(attest body₁) ‖ …) | Overview: H_"bnap/cred"(Context ‖ binding ‖ H(pay payload) ‖ H(each attest payload)), binding without a length prefix. Ref: digest() follows draft-00; credential.digest = 7c8f6196…d530d4c9, digest_other_binding = c9ef748b…a36a207a | Draft-00; the vector proves it | no |
| 5 | ReplayKey: draft-00 6.1; overview “Credential envelope” | H_"bnap/replay"(Context ‖ H(pay body, or empty) ‖ H(attest body₁) ‖ … ‖ BigSize(m) ‖ id₁ ‖ … ‖ idₘ) over every UseId and RailId, sorted bytewise | Overview: H_"bnap/replay"(Context ‖ sorted socket use IDs). Ref: replay_key() follows draft-00; credential.replay_key = 74bd8a79…9b0c2771, attest_only.replay_key = c9248cf0…8961be9c | Draft-00, as section 15 lists; the vector proves it | no |
| 6 | Response and ack: draft-00 8.1, 15; overview “Receipt” | The Response is a TLV stream of 11 records (version, status, replayed, charged, unit, offer_id, digest, metered_units, scheme_result, receipt_key, settled_at) plus ack (type 240) over H_"bnap/ack"(every record outside 240–1000). Section 15 calls this “Same fields as v0.5.1’s formula” | Overview: Response = {status, replayed, scheme_result, ack} with the ack over H_"bnap/ack"(Digest ‖ status ‖ charged ‖ unit ‖ H(scheme_result)). Ref: build_response() signs the whole stream; the vector Response holds types 0, 2, 6, 8, 10, 12, 14, 16, 18, 20 and 240, ack c1d4270a…bfef6f70f7 | Draft-00; the vector proves it. The stream signs more than the v0.5.1 fields (also version, replayed, offer_id, metered_units, receipt_key, settled_at), so section 15 should say “a superset of v0.5.1’s fields” | no |
| 7 | Signature range and coverage: draft-00 2, 4.2; overview “Offer”, “Extensibility” | Signature records are types 240–1000. OfferID = H_"bnap/offer"(S₀) over every record except the signature range and native; offer_sig = Sign(offer_key, H_"bnap/offer-sig"(S)) and mac = HMAC-SHA256(K_mac, S), where S is every record outside the signature range | Overview: “Types 240 to 255 are signature records”; OfferID over “every record below 240 except native”; offer_sig and mac over “every record below 240”. Ref: sig_range() is 240–1000; the vector offer’s GREASE record (type 3,000,000,001, after mac in stream order) is inside OfferID, offer_sig and mac | Draft-00, as section 15 lists; the vector proves that records above 1000 are covered | no |
| 8 | Offer record 14: draft-00 4.1; overview “Offer” | offer_key: the 32-byte BIP-340 key that signed the offer | Overview: op_key_id, “Which online offer key, listed in the manifest, signed this offer”. Ref: record 14 of every vector offer holds the key itself, b28dad5f…fe64add9 | Draft-00; the vector proves it | no |
| 9 | Offer record set: draft-00 4.1, 4.3, 4.4; overview “Offer” | Adds route_scope (4), headers (6), the pay-entry rate (10) and the attest-entry value and epoch. mac may be absent only in offers published in advance, which then MUST carry issued_at. native only in exact-scope offers | Overview: no types 4 or 6; pay entries have no rate; attest entries list only scheme ID, parameters and effect; issued_at is for “Self-issued offers only”; scope is always “32-byte hash of the action”. Ref: the route offer vector uses records 4 and 8 and a pay entry with rate 500 | Draft-00 (section 15 lists route scope and rate); the vector agrees | no |
| 10 | Metering: draft-00 7, 8.1; overview “Uses and outcomes” | rate is the price per 1,000 meter units; charged = min(price(⌈units × rate / 1000⌉), price(amount)); the Response carries metered_units and charged | Overview: “For metered actions, the charge is the amount used”; no rate, no metered_units. Vectors: Response charged 340, metered_units 850 | Draft-00, as section 15 lists; the vector agrees | no |
| 11 | Discounts and route member names: draft-00 4.1, 9.1; overview “Scheme properties and route policy” | Attest value is in basis points, 1 to 10,000. Manifest routes use pay and attest: [{"scheme", "effect", "value", "epoch"}] | Overview TOML: accept = [...] and attest = [{ scheme = "httpsig:web-bot-auth", effect = "discount", percent = 20 }]. Vectors: manifest "pay": [...] and "value": 2000; offer attest entry value 2000 | Draft-00; the vector proves basis points and pay. The overview example and the Caparra config should follow | no |
| 12 | Effect in the manifest: draft-00 9.1 | Routes list effect but the text does not say how it is written in JSON (the Offer uses tu64 0 to 3) | Vectors: the manifest writes "effect": "discount"; the overview TOML also uses effect = "discount" | Draft-00 is silent; the vector settles it: string names required, waive, discount, quota. State this in 9.1 | no |
| 13 | Metered route in the manifest: draft-00 9.1, 10.3 | Routes have price: {unit: amount}, metered and meter, but no rate member, and the meaning of price on a metered route is not stated. 10.3 describes the route as 500 sat per 1,000 tokens up to 2,100 sat per call | Vectors: the manifest has "price": {"sat": 2100}, the ceiling; the rate 500 appears only in the offer | The vector shows that price is the ceiling. Whether the manifest should also carry the rate is open | yes |
| 14 | Error codes: draft-00 8.2, 15; overview “Errors” | 16 numbered codes. Section 15 names UNDERPAID, BAD_CREDENTIAL, BINDING and UNAVAILABLE as new, but not REQUIRED (16) | Overview: 11 codes, unnumbered, no REQUIRED. The 10.3 example’s BNAP-Error: AAEBAgEQ decodes to 00 01 01 02 01 10, code 16 | Draft-00’s table. Add REQUIRED to the section 15 row | no |
| 15 | HTTP header fields: draft-00 10.2; overview “Negotiation”, “Carriers” | Offer in WWW-Authenticate: BNAP offer="…"; credential in BNAP-Credential; receipt in BNAP-Receipt (or a trailer, or a final bnap-receipt event); errors in BNAP-Error; optional BNAP-Context. The manifest URI is only Offer record 12 | Overview: credential in Authorization: BNAP …, receipt in Authentication-Info: bnap="…"; the carriers table lists WWW-Authenticate, Authorization, Authentication-Info; the 402 example adds info="/.well-known/bnap" as an auth-param. Vectors: the offer’s info record is the absolute URI https://api.example/.well-known/bnap | Draft-00, as section 15 lists | no |
| 16 | HTTP scope: draft-00 10.1; overview “Carriers” | lp(method) ‖ lp(target) ‖ lp(content hash, or empty) ‖ BigSize(h) ‖ listed headers; target is the manifest origin plus the request-target exactly as received, with no normalization; the gate hashes the body itself and Content-Digest is only a SHOULD | Overview: the scope covers “Method, normalized absolute URI, Content-Digest”. Ref: http_scope() follows draft-00; action scope 02be38b5…971b3717 | Draft-00, as section 15 lists; the vector proves it | no |
| 17 | Native envelope: draft-00 5.4, 15; overview “Negotiation” | Context = H_"bnap/ctx-native"(OfferID ‖ action_scope ‖ lp(scheme ID)), Digest = H_"bnap/cred-native"(Context ‖ H(native credential bytes)); offers cached by OfferID; receipt in the BNAP response header. Section 15 does not list this change | Overview: Context = H_"bnap/ctx"(OfferID ‖ scheme ID), Digest = H_"bnap/cred"(Context ‖ H(native credential bytes)), the same tags as a BNAP Credential; cache keyed by nonce; receipt in Authentication-Info. Ref: native_context() and native_digest() follow draft-00; context 38a036b6…6cbb1b78, digest 198cd8d6…f0a2a04e | Draft-00; the vector proves it. Add a section 15 row | no |
| 18 | UseId and RailId form: draft-00 6.1; overview “Uses and outcomes” | UseId = H_"bnap/use"(lp(scheme ID) ‖ local ID) and RailId = H_"bnap/rail"(lp(rail kind) ‖ canonical payment bytes), 32-byte hashes; evm bytes are lp(CAIP-2 chain ID) ‖ token ‖ payer ‖ authorization nonce | Overview: string prefixes such as bnap:notes:<Y>, httpauth:L402:<payment hash>, ln:<payment hash>, evm:<chain>:<token>:<from>:<nonce>. Ref: use_id() and rail_id() follow draft-00; use IDs b7ca97d6…232b6456, 381a38d9…4bc2fb5e; rail ID 5f210bd3…f1867744 | Draft-00; the vector proves it. Section 15 lists only who supplies the rail bytes; consider a row for the hashing too | no |
| 19 | Who supplies rail IDs; plug traits: draft-00 6.1, 6.3; overview “Uses and outcomes”, “Reference implementation” | The scheme returns a rail reference; the operator-trusted rail plug confirms it and returns the canonical bytes; the gate computes the RailId. commit(rk, singles, counters) also takes rate and quota counters | Overview: “A scheme that relies on a rail payment MUST also report that payment’s canonical rail ID”; the Rust sketch has Verified { rail_ids: Vec<RailId> }, UseStore::commit(ids, key) with no counters, and no body and witness split | Draft-00, as section 15 lists. The Caparra trait sketch should follow | no |
| 20 | Use models: draft-00 6.2, 7; overview “Uses and outcomes” | single, rate(k, epoch) and reusable, where reusable consumes nothing beyond the outcome record; limits come from a quota attestation (effect 3) counted against a registered quota_id | Overview: reusable(quota), “A metered allowance per use ID” | Draft-00 | no |
| 21 | Timeout defaults: draft-00 2, 9.1; overview “Uses and outcomes” | limits holds replay_window and pending_timeout, with no default for either and no rule that they are present. Only the size limits have defaults | Overview: “pending_timeout (default 300 s)”; Caparra config replay_window_s = 86400, pending_timeout_s = 300. Vectors: the manifest uses 86400 and 300 | Draft-00 is silent; the vector is an example, not a default. Choose defaults (300 s and 86,400 s) or make both members required | yes |
| 22 | ES256 co-signature: draft-00 8.1, 15; overview “Receipt” | One ack, BIP-340, type 240; no other signature record is defined. Section 15: the 240–1000 range leaves “Room for more signature types later” | Overview: “An operator whose keys live in an HSM MAY add an ES256 signature as well; BIP-340 stays mandatory” | No ES256 record exists in draft-00. Keep the MAY only if a record type is assigned | yes |
| 23 | GREASE scope: draft-00 2; overview “Offer”, “Extensibility”, “Negotiation” | GREASE is TLV types 3,000,000,001 to 3,000,000,999, odd only; senders SHOULD add one record to every Offer and Credential | Overview: “scheme IDs and carrier names reserve GREASE values too”; the 402 example lists GREASE inside pay; servers SHOULD include one. Vectors: the offer has one GREASE record (value 0a0a); the credential has none, which a SHOULD allows | Draft-00 defines no GREASE scheme IDs or carrier names. Keep or drop that overview feature | yes |
| 24 | Native challenge minting: draft-00 12; overview “Negotiation” | The gate MUST bound native challenges per client address and per offer; it SHOULD mint native challenges at most once per offer nonce | Overview: “A server MUST mint at most one invoice per offer nonce” | Draft-00 wins by rule (SHOULD), but this weakens a MUST and section 15 gives no reason | yes |
| 25 | Class None value schemes: draft-00 4.3, 7, 12; overview “Credential envelope” | Class None needs an exact-scope offer (4.3), never appears in a route-scope offer (12), and may not waive, discount or carry a quota (7). Nothing limits a None value scheme to price 0 | Overview class table: None is “Allowed only at price 0, and never with a waive or discount effect” | Draft-00 is silent on the price-0 half, so the overview rule is not contradicted. Adding it to draft-00 section 7 is a normative change | yes |
| 26 | Unit strings: draft-00 4.1; overview “Offer”, “Value rails” | sat, msat, an ISO 4217 code (minor units) or a CAIP-19 asset ID | Overview: “sat, an ISO 4217 code or a CAIP-19 asset”, no msat; the guardrails write usd, eur in lower case. Vectors: sat only | Draft-00: msat is allowed, and fiat units are ISO 4217 codes, which are upper case (USD, EUR). Draft-00 could say that units compare exactly | no |
| 27 | Manifest and pinning: draft-00 9.1 to 9.3; overview “Manifest and pinning” | Member required (the bnap-required flag); bnap: 1, origin, issued. A manifest signed by the current root keeps the same backup; the backup signs only a rotation; accept a higher seq or identical bytes; issued checked with 60 s of skew; pin until min(issued + max_age, now + 30 days); no two rotations within 30 days | Overview: “A manifest MAY set bnap-required”; it “lists versions”; a client accepts a refetched manifest “if the root or the backup signed it with a higher seq”. Vectors: the manifest uses required, bnap, origin, issued and backup | Draft-00, as section 15 lists for the backup and issued-time rules | no |
| 28 | Tab references: draft-00 1, 12, 15; overview “Abstract and status”, “Security and privacy considerations” | Calls the overview “the first tab”, which is still true | Overview: the bnap:notes spec is “the second tab” and draft-00 is the “third tab”; the doc now orders the tabs overview, draft-00, bnap:notes, v0.3, v0.2 | Texts stay verbatim; README.md maps each tab reference to its file | no |
| 29 | Stale open question: draft-00 4.1, 14; overview “Open questions” | Defines the pay and attest encodings and publishes vectors | Overview, unticked: “Socket byte layout. Define the Offer’s pay and attest record encodings and publish test vectors” | Done by draft-00 and vectors.json; tick it when the overview is brought into line | no |
| 30 | Header size, internal to draft-00: draft-00 2, 10.2 | A Credential may be up to 16,384 bytes (2); gates MUST accept a BNAP-Credential field of at least 12,288 bytes (10.2) | Arithmetic: base64url of 16,384 bytes is 21,846 characters, and 12,288 characters carry 9,216 bytes. Vectors: the credential is 581 bytes (775 characters), far below both | No vector settles it. Raise the field minimum to 21,846 bytes, or cap Credentials on HTTP at 9,216 bytes | yes |
| 31 | bnap:notes tab, outside the three-way comparison: draft-00 2, 5.1, 11; schemes/bnap-notes.md | Schemes split body and witness. The bnap:notes registry example gives body = "TLV, section 3 of the scheme spec" and a witness over the socket Digest; the credential travels in BNAP-Credential. Section 2 lets a scheme keep JSON or CBOR payloads | The bnap:notes tab is still the v0.4 profile: a CBOR SPEND, its own H_"bnap/spend" Digest and ack, Authorization: BNAP (or BNAP-Authorization) and Authentication-Info. It has no numbered section 3 | Draft-00 wins for every socket part. The notes scheme spec needs a draft-00 rewrite, and its body encoding (TLV, as the registry example says, or CBOR) needs choosing | yes |
| 32 | x402 on Lightning (external finding): overview “Abstract and status”, “Plug catalogue”, “Badges”, “Roadmap”, “Open questions” | Nothing beyond the RailId rule: one Lightning payment offered under L402, MPP and x402 admits one action | Overview: owner decision “No x402 on Lightning until its spec is published”; catalogue “Pending a published spec”; Agent-1 and v0.6 “once its spec is published”; sources link coinbase/x402. External: the x402 exact scheme on Lightning (lnbtc) was published 2026-09-23 in x402-foundation/x402 | The Agent-1 condition is met; x402 on Lightning stays deferred to v0.6. Update the catalogue and badge wording. The open question on the network label and on a client-chosen nonce (class O or A) can now be checked against the published spec | no |
| 33 | MPP receipts (external finding): draft-00 5.4, 10.2; overview “Negotiation” | The receipt “goes in the BNAP response header next to the scheme’s own”, that is BNAP-Receipt | Overview: “the receipt goes in Authentication-Info next to the scheme’s own”. External: MPP (draft-httpauth-payment-01) returns its receipt in a Payment-Receipt header, not Authentication-Info | Draft-00: BNAP-Receipt travels next to MPP’s Payment-Receipt. The MPP registry entry should name that field | no |
| 34 | L402 (external finding): draft-00 4.1, 5.4, 10.3; overview “Plug catalogue”; bnap:notes “Sources” | Native credential bytes “such as the L402 macaroon:preimage token”; the 10.3 example sends WWW-Authenticate: L402 macaroon="…", invoice="…" | Overview catalogue: “L402 (bLIP-26)”; the bnap:notes tab links bLIP-26 at blob/master/blip-0026.md. External: bLIP-26 is still an unmerged PR | Cite bLIP-26 as an open PR. The L402 adapter accepts both token= and macaroon=, and both L402 and LSAT. Draft-00’s example stays as it is | no |
| 35 | MPP native entry (end-to-end finding): draft-00 4.1, 4.2 step 3, 5.4; registry payment:lightning/charge | The native artifact embeds the OfferID “in the way its scheme’s registry entry defines, such as … an MPP id”; 5.4 lists “an HMAC’d MPP id” as a stateless artifact | Registry (before): mpp-challenge “the challenge’s HMAC’d id carries the OfferID”, and a bolt11 entry listed beside it as if always present. draft-httpauth-payment-01: the id is the server’s HMAC over the bound challenge parameters, so it cannot also be the OfferID; opaque is the parameter for server data. Caparra’s gate and the conformance runner (profile bnap-conf-mpp-2): the mpp-challenge entry is the Payment challenge exactly as sent, the OfferID is in opaque as base64url of the JCS object {"bnap_offer": hex(OfferID)}, covered by the HMAC id, and the invoice is methodDetails.invoice inside the challenge | The OfferID goes in opaque, under the HMAC’d id; a gate that puts it in id itself fails OFR-NATIVE-EMBED. A bolt11 entry is optional for MPP. The registry wording is updated; draft-00 4.2 should read “an MPP opaque covered by its HMAC’d id” in the next revision | no |
35 rows; 8 need an owner decision (13, 21, 22, 23, 24, 25, 30, 31).
Provisional decisions (pending owner review)
These resolve the ERRATA rows marked “owner decision needed”. The build follows them until the owner overrides one; an override becomes a spec edit plus a code change.
| ERRATA # | Topic | Provisional decision |
|---|---|---|
| 13 | Metered route in the manifest | The manifest carries the price ceiling only. The per-unit rate travels in the pay entry parameters of the offer. No new manifest member. |
| 21 | Timeout defaults | pending_timeout defaults to 300 s and replay_window to 86,400 s when the manifest omits them. |
| 22 | ES256 co-signature | Not in v0.5. No record type is assigned; BIP-340 stays the only receipt signature. Revisit with an HSM signer. |
| 23 | GREASE for scheme IDs and carrier names | Not in v0.5. Odd GREASE TLV types stay mandatory-to-ignore and servers keep sending one. |
| 24 | Native challenge minting | Treated as MUST: at most one invoice per offer nonce, plus a per-address mint rate limit. Stricter than draft-00 SHOULD, safe against amplification. |
| 25 | Class None value schemes | Added to section 7: a binding-class None scheme is admissible only at price 0 and never with a waive or discount effect. |
| 30 | Credential size on HTTP | The HTTP carrier caps a Credential at 9,216 bytes (12,288 base64url characters). Larger credentials are refused with TOO_LARGE. |
| 31 | bnap:notes vs draft-00 | The notes scheme is rewritten in draft-00 terms (spec/schemes/bnap-notes-draft-00.md). Its body encoding is TLV, like every socket object. |
bnap:notes, scheme draft-00
2026-10-13 · BNAP contributors
1. Status and scope
Status. Working draft 00 of the bnap:notes credential scheme. It restates the v0.4 Cashu profile (bnap-notes.md) and the bnap:notes section of the v0.5.1 overview (overview-v0.5.md) as a plug for the socket of socket-draft-00.md (“draft-00” below). Where those texts and draft-00 differ, draft-00 wins; section 19 lists every such difference, and also the places where draft-00’s own text, read literally, contradicts itself or leaves its encoding open (19.2). Every choice this document had to make that none of its sources settles is marked “(provisional)” and collected in Appendix B for owner review.
Scope. This document defines everything the socket leaves to the scheme: the body and witness bytes (section 3, the section the draft-00 registry example points to), the Note format, the pay entry parameters, verification, the change and refund outputs, funding inside a 402, input fees, keyset rotation, swaps, seed recovery, the scheme’s error codes, its registry entry and its test-vector requirements. It does not restate the socket: the Offer, the Credential envelope, the Context, Digest and ReplayKey, the use store, pricing, the Response and its receipt are as draft-00 defines them, and this document only says what the scheme puts into them and takes out of them.
What the scheme is, in one paragraph. The operator runs its own Cashu-style issuer for its own origin. A client buys credit over Lightning with Cashu’s mint flow (NUT-23, NUT-04, NUT-20), or inside the 402 that asked it to pay, and holds the credit as Notes: Cashu proofs whose secret is the hex of a public key K. To pay for an action it puts notes and blinded change outputs in the body of a socket Credential and signs the socket’s Digest with each K. The gate checks the notes offline, commits each note’s Y as a single-use ID, and at settlement signs exactly value - charged worth of the client’s blinded outputs as change. Wallets recover from a seed through NUT-13, NUT-09 and NUT-07.
2. Conventions
The key words MUST, MUST NOT, REQUIRED, SHOULD, SHOULD NOT, RECOMMENDED, MAY and OPTIONAL are to be read as described in BCP 14 (RFC 2119, RFC 8174) when, and only when, they appear in all capitals.
2.1 Notation
Every notation of draft-00 section 2 applies: H, H_tag, ‖, BigSize, tu64, lp, TLV stream, entry list, flag, Key, Signature, b64u and empty. One error code changes. Draft-00’s BigSize and tu64 rows make a non-minimal encoding MALFORMED (2), the socket’s code for its own streams. The notes body and witness are opaque bytes to the socket (draft-00 section 2, “One encoding”), parsed only by the scheme, and draft-00 5.1 makes the scheme reject alternative encodings of a body; 3.5 applies the same rules to the witness. A non-minimal BigSize or tu64 inside them, like every other encoding error there, is therefore a scheme rejection, BAD_CREDENTIAL (10) with bnap:notes/NON_CANONICAL (3.5, 13.1), and never MALFORMED (provisional). The socket’s own streams, the pay record around the body and witness included, keep MALFORMED. In addition:
| Notation | Meaning |
|---|---|
| Point | A secp256k1 point in SEC1 compressed form: 33 bytes, first byte 02 or 03. The point at infinity has no encoding and is never valid |
G, n | The secp256k1 generator and group order |
hash_to_curve(x) | The NUT-00 map from a byte string to a point |
hex(x) | Lowercase hexadecimal, two characters per byte |
| Keyset ID | The NUT-02 keyset ID of version 01: 33 bytes, the byte 01 followed by a SHA-256 digest. v0.4 calls it the version-2 ID |
a, A | The private and public key of one keyset for one amount, A = a·G |
| NUT-xx | The Cashu specification of that number in the cashubtc/nuts repository |
2.2 Terms
| Term | Meaning |
|---|---|
| Issuer | The operator’s Cashu-style mint. It signs notes for its own origin only, and is the only party that accepts them |
| BNAP keyset | A Cashu keyset (NUT-01, NUT-02) whose keys sign notes and nothing else |
| Note | A Cashu proof {id, amount, secret, C} whose secret is hex(K) (section 4) |
| Note key | The private key k_n of a note and its x-only public key K |
Y | hash_to_curve(secret), the note’s single-use ID |
| Value | What verify reports: the sats the credential brings, after input fees (6.3) |
| Change | value - charged at settlement, returned as signed outputs (section 7) |
| Fund quote | A NUT-23 BOLT11 mint quote locked to a client key (NUT-20) and redeemed inside a credential (section 8) |
2.3 Hash tags
This document defines one tagged hash, H_"bnap:notes/mint" (8.5). Its tag starts with the scheme ID, so it can never equal a socket tag, which all start with bnap/.
3. Body and witness
A notes credential is the pay record of a socket Credential (draft-00 5.1) with scheme bnap:notes, a body (record 2) and a witness (record 4). Both are TLV streams. The body carries everything the gate verifies except signatures over the Digest; the witness carries those signatures (draft-00 5.1). The Digest covers H(body); nothing covers the witness, which is why it only holds signatures over the Digest.
3.1 Body records
| Type | Name | Value | Presence |
|---|---|---|---|
| 0 | inputs | Entry list of input entries (3.2), sorted by key, strictly ascending bytewise | When the credential spends notes |
| 2 | outputs | Entry list of output entries (3.3), in the client’s order | When value > 0 (6.3) |
| 4 | fund | UTF-8 quote ID of a paid NUT-23 BOLT11 mint quote, 1 to 64 bytes | When the credential redeems a fund quote (8.4) |
A body MUST hold inputs, fund, or both.
3.2 Input entries
An input entry is one note, as a nested stream:
| Type | Name | Value |
|---|---|---|
| 0 | keyset | 33 bytes: the full keyset ID. Required |
| 2 | amount | tu64: a power of two, at least 1, in sat. Required |
| 4 | key | Key: K, 32 bytes. The note’s secret is hex(K). Required |
| 6 | C | Point: the note’s unblinded signature. Required |
The body carries K, not the secret, because the secret is a fixed function of K (4.1); the gate rebuilds the secret and Y itself. Inputs never carry a DLEQ proof or a blinding factor (4.2).
3.3 Output entries
An output entry is one Cashu blinded message, as a nested stream:
| Type | Name | Value |
|---|---|---|
| 0 | keyset | 33 bytes: the full ID of a keyset the offer lists as active (5.2). Required |
| 2 | amount | tu64: a power of two, at least 1, in sat. Required |
| 4 | blinded | Point: B', the NUT-00 blinded message. Required |
Outputs serve both as change and as refund (section 7). A client SHOULD list them in ascending amount order (provisional), which the canonical ladder of 6.3 already is.
3.4 Witness records
| Type | Name | Value | Presence |
|---|---|---|---|
| 0 | input_sigs | 64 × m bytes, where m is the number of inputs: the Signature by each input’s K over the Digest, in input order | When the body holds inputs |
| 2 | fund_sig | Signature over the Digest by the private key of the fund quote’s NUT-20 pubkey | When the body holds fund |
Each signature is a BIP-340 signature over the 32-byte Digest of draft-00 5.2. fund_sig verifies under the x-only form of the quote’s 33-byte compressed pubkey, that is its last 32 bytes. One signature is made per key; signatures are never aggregated (4.2).
3.5 Canonical form
Draft-00 5.1 requires a scheme to reject a body that contains any byte it does not verify. The body and the witness MUST therefore be rejected with BAD_CREDENTIAL and bnap:notes/NON_CANONICAL (section 13), not with socket MALFORMED (2.1), when any of these holds:
- a framing error: a truncated record or entry, a non-minimal BigSize or tu64, types not strictly increasing, or bytes left over;
- an input or output entry that lacks a record 3.2 or 3.3 marks Required, such as an input without
C, or an entry that is the empty stream (the single byte00in the entry list, a zero length); - a record type not listed in 3.1 to 3.4, even or odd. The even/odd rule of draft-00 section 2 governs socket streams; inside a notes body or witness every unknown record is rejected, GREASE included (provisional reading: a client puts its GREASE in the socket streams, never in a scheme body);
- a record of the wrong length, or with an empty value. No notes record is a flag, and every tu64 in a body is at least 1, so an empty value is never valid here;
- a Point that is not a valid compressed point on secp256k1, or a
keythat is not a valid x-only key (its value is at least p, or no point has that x coordinate); - inputs not in strictly ascending order of
key, which also rules out the sameKtwice; - two outputs with the same
blindedpoint; - a witness whose records do not match the body:
input_sigspresent withoutinputs, absent with them, or not exactly 64 bytes per input;fund_sigpresent withoutfund, or absent with it.
A body or witness that passes these checks has exactly one encoding. The scheme_result of 7.3 follows the same rules, except for the one empty value that 7.3 defines.
3.6 Sizes
The pay entry parameters cap the counts (5.1): max_inputs and max_outputs, each 16 by default (provisional). An input entry takes about 110 bytes on the wire, its length prefix included, and an output entry about 75. At the defaults, a credential has at most 16 inputs and 16 outputs, and 16 outputs that form a complete ladder hold at most 1, 2, …, 32,768 sat, whose total of 65,535 sat caps its value (6.3). Take 16 inputs on a keyset with no input fee, such as keyset B of 18.1: one each of 1, 2, 4, …, 16,384 sat and a second note of 16,384 sat, so I = 32,767 + 16,384 = 49,151, eight amounts of one byte (1 to 128) and eight of two bytes (256 to 16,384). With a 36-byte quote ID for a quote of F sat, 1 ≤ F ≤ 16,384, the value is 49,151 + F, from 49,152 to 65,535, so its bit length is 16 and the 16-output ladder covers it. That body is 2,974 bytes and its witness 1,094, and a credential that names its offer by offer_id and carries a binding and a GREASE record with an 8-byte value (14 bytes in all) is 4,177 bytes, 5,570 base64url characters. That is one 16-input example, not the largest credential. The largest body and witness at the defaults hold 16 inputs of two-byte amounts and a 64-byte quote ID (5.3): for example 256, 512, …, 16,384 sat once each and nine more notes of 256 sat, so I = 32,512 + 9 × 256 = 34,816, with a quote of 1 to 30,719 sat, for a value of 34,817 to 65,535 and again 16 outputs. That body is 2,974 + 8 + 28 = 3,010 bytes, eight amounts one byte longer and the quote ID 28, its witness again 1,094, and the credential built as above 4,213 bytes, 5,618 base64url characters. Only a credential whose inputs pay more than 3,841 sat in input fees, over 240 sat per input, can be larger: such a fee lets an input of 65,536 sat or more, a three-byte amount, stay under the value cap. It fits the HTTP carrier’s credential cap of 9,216 bytes (DECISIONS row 30) even with a 4,096-byte offer inline instead of offer_id, which makes it 4,213 − 34 + 4,100 = 8,279 bytes (the example: 4,177 − 34 + 4,100 = 8,243); clients SHOULD still send offer_id where draft-00 5.1 allows it.
The 1,680 sat of the draft-00 10.3 example is the price after its 20% Web Bot Auth discount, 2,100 − ⌊2,100 × 2,000 / 10,000⌋ = 1,680, so a credential that pays it also carries a Web Bot Auth attestation that verifies; without one the price is 2,100 sat (draft-00 7). The sizes that follow count that attestation’s attest record as 91 bytes, its size with the 64-byte attest body of the draft-00 section 14 vectors: 22 bytes for the scheme ID record, 66 for the body record, a 1-byte entry prefix and a 2-byte record header. A real Web Bot Auth body of L bytes changes each size by L − 64 while L is at most 227. Past that the BigSize length prefixes grow, and the change is 2 bytes more at L = 228, 4 more from 229 to 252, and 6 more from 253 up to the credential cap: the record is 1 + BigSize(n) + n bytes, where n = BigSize(m) + m is the entry with its prefix and m = 22 + 1 + BigSize(L) + L the entry itself. Notes are powers of two, so 1,680 sat take four inputs (1,024 + 512 + 128 + 16) on a keyset with no input fee, such as keyset B, and the 11-output ladder: that credential, built as above but with no quote and with the attest record, is 1,719 bytes. A wallet holding one 2,048-sat note on such a keyset instead sends one input and the 12-output ladder, 1,271 bytes. On keyset A of 18.1, at 100 ppk, the same four notes are worth 1,680 − (400 + 999) div 1000 = 1,679 sat, one short of the discounted price (UNDERPAID), and the 2,048-sat note is worth 2,048 − (100 + 999) div 1000 = 2,047 sat and takes the 11-output ladder, 1,196 bytes. These three credentials stay under the 2 KiB of draft-00 section 2; the 16-input credentials above, at 4,177 and 4,213 bytes, do not. nuts/tools/notes_sizes.py rebuilds every credential of this section and checks each size.
4. Notes: the Cashu profile
4.1 Note format
A Note is a Cashu proof whose secret is the hexadecimal encoding of a public key K, so only the holder of K’s private key can spend it. Everything else about the note is Cashu as the NUTs specify it.
k_n, r = NUT-13 derivation (section 12): seed, keyset ID, counter, derivation types 0x02 and 0x01
K = x-only public key of k_n (BIP-340), 32 bytes
secret = hex(K), 64 ASCII characters
Y = hash_to_curve(UTF-8 bytes of secret) NUT-00
B' = Y + r·G sent as an output
C' = a·B', with a NUT-12 DLEQ proof (e, s) returned by the issuer
C = C' - r·A the unblinded signature
Note = {id: keyset ID, amount, secret, C} a valid NUT-00 proof
witness = Signature by k_n over the socket Digest one per note, per spend
A wallet MAY store notes as ordinary Cashu proofs, for example in a NUT-00 V4 token, but a note is not transferable: spending it also needs k_n.
4.2 Rules beyond Cashu
- Spends need the key. An issuer MUST accept its notes only with a valid witness by each note’s
K: inside abnap:notescredential, or in the witnessed melt of a withdrawal extension (section 16), a NUT-05 melt in the key-secretmeltcontext. It never accepts them through a NUT-03 swap or a plain NUT-05 melt: without the witness rule,hex(K)reads to Cashu as an unlocked secret. - Key isolation. BNAP keysets MUST NOT be served by a mint that accepts plain swaps or melts for them. The issuer MUST refuse every NUT-03 request whose inputs belong to a BNAP keyset, and every NUT-05 request whose inputs do, unless it implements a withdrawal extension (section 16) and the melt carries a witness by every input’s
Kin themeltcontext of the key-secret proofs NUT (nuts/xx-key-secret-proofs.md) (provisional). The issuer listsmeltin thecontextsof its key-secret NUT-06 info only if it implements such an extension, and otherwise leavescontextsempty, so that its notes are spent only in external contexts: abnap:notescredential. An issuer without such an extension SHOULD mark NUT-05 as disabled in its NUT-06 info. A BNAP key never appears in any other keyset. NUT-04 minting (8.5), NUT-07 state checks and NUT-09 restore are served as section 12 says. - Keyset IDs. Bodies, parameters and derivation all use the full 33-byte keyset ID. The 8-byte short ID never appears in a notes body (provisional; v0.4 allowed it on the wire).
- Unit. Every BNAP keyset has unit
sat, and the notes pay entry has unitsat. - Final expiry. Every BNAP keyset MUST publish a
final_expiry(NUT-02) (provisional; without one, draft-00 6.2 would keep its spend records forever). - No signature aggregation. One BIP-340 signature per
K. Batch verification is fine. Buying a note never proves knowledge ofk_n, so a summed key could steal another holder’s note. - Denominations. Amounts are powers of two. A wallet uses each
Konce, at one origin. - Outputs. Every
B'is a valid point other than infinity. A credential is never rejected because one of itsB'was signed before: settlement signs it again (7.4), and only the client that reused it can lose value. Verification reads no use state at all (6.2), so a replay of a committed credential always reaches the socket’s replay path (draft-00 6.4). NUT-04 minting refuses aB'signed before, as NUT-04 does (8.5). - DLEQ. The issuer returns a NUT-12 DLEQ proof with every blind signature, made with NUT-12’s deterministic nonce, which this scheme makes a MUST (7.4). Clients never send a DLEQ proof or a blinding factor to the issuer, because either would link a spend to the purchase.
5. Offer parameters
5.1 The pay entry
A bnap:notes pay entry (draft-00 4.1) has unit sat. Its params record (pay entry type 8) is a TLV stream:
| Type | Name | Value | Presence |
|---|---|---|---|
| 0 | issuer | UTF-8: the absolute https URL of the issuer’s Cashu API base, on the origin the offer came from | Required |
| 2 | keysets | Entry list of keyset entries (5.2), sorted by id, strictly ascending | Required, at least one entry marked active |
| 4 | max_inputs | tu64, at least 1; 16 when absent | Optional |
| 6 | max_outputs | tu64, at least 1; 16 when absent | Optional |
| 8 | max_fund | tu64, in sat: the gate accepts funding requests (8.1) on this route up to this amount | When the route offers funding inside the 402 |
| 10 | fund_quote | Nested stream (5.3): a quote made for this client | Exact-scope offers only |
The parameters are bytes inside a signed offer, so offer_sig and the MAC cover them (draft-00 4.2) and they follow the even/odd rule of draft-00 section 2: a client ignores an unknown odd record and treats the pay entry as unusable if it meets an unknown even one (provisional; draft-00 leaves the encoding of params to the scheme). A client also treats the pay entry as unusable if params, a keyset entry or the fund_quote lacks a record this section marks Required. A gate never builds a record outside this table.
5.2 Keyset entries
| Type | Name | Value |
|---|---|---|
| 0 | id | 33 bytes: the keyset ID. Required |
| 2 | input_fee_ppk | tu64: the keyset’s NUT-02 input_fee_ppk; 0 when absent |
| 4 | active | Flag: the keyset signs outputs |
keysets lists the BNAP keysets this offer accepts for inputs. Each is not revoked, and its final_expiry is later than the offer’s expires plus 60 seconds. Entries marked active are the keysets the gate signs outputs on. input_fee_ppk MUST equal the fee the keyset publishes through NUT-02; it is in the offer so that a client holding only the offer can compute its value exactly (draft-00 7: the offer is the quote).
A client takes keyset IDs, fees and the active flag from the offer, and the keys themselves from the issuer’s NUT-01 and NUT-02 endpoints at issuer. It MUST recompute each keyset ID from the keys it fetched (NUT-02) and MUST NOT use a keyset whose recomputed ID differs from the offer’s.
5.3 The fund quote
| Type | Name | Value |
|---|---|---|
| 0 | quote | UTF-8 quote ID, 1 to 64 bytes. Required |
| 2 | request | UTF-8: the quote’s BOLT11 invoice. Required |
| 4 | amount | tu64, in sat: the quote amount. Required |
| 6 | expiry | tu64: the Unix time until which the invoice can be paid. Required |
| 8 | pubkey | 33 bytes: the compressed NUT-20 key the client sent (8.1). Required |
These are the NUT-23 quote fields a client needs, so it can pay from the 402 without another round trip. Inside the offer, offer_sig authenticates them.
5.4 Offer rules
-
bnap:notesis binding class S, so it MAY appear in route-scope and in exact-scope offers (draft-00 4.3), and its refund is atomic, so it MAY appear in metered offers (draft-00 7). -
fund_quoteMUST appear only in exact-scope offers, because a route-scope offer is the same for every client (draft-00 5.1) (provisional). -
A gate MAY send such an exact-scope notes offer, made for one request to carry a
fund_quote(8.2), on a route that the manifest lists with"scope": "route", next to that route’s route-scope offer (provisional). Draft-00 4.3 allows a class S scheme in an exact-scope offer. This document reads the rule of draft-00 9.1, that such a route “gets route-scope offers for its class S and A schemes”, as the route’s default offer set, not as a ban on a per-request exact-scope offer (19.2). -
A gate MUST NOT put a
fund_quotein an offer for a route whoserequiresetslink-paymenttounlinkable, because a credential that redeems it is linkable to the payment (section 14) (provisional). On such a route it also puts nomax_fundin the notes pay entry, and admits no credential that redeems a quote, whichever offer the quote came from (8.4) (provisional). -
A gate MUST NOT cache an exact-scope offer that carries a
fund_quote, so the client sends that offer inline, never itsoffer_id. Draft-00 5.1 caches route-scope offers and exact-scope offers with natives, and adds: “Nothing an unauthenticated request does adds to the cache beyond that.” TheBNAP-Fundrequest that gets such an offer (8.1) is unauthenticated. An inline offer fits the HTTP carrier’s credential cap (3.6). -
The notes pay entry carries no native record:
bnap:notesdefines no native artifact. -
Replay horizon. Draft-00 4.4 checks expiry before the commit looks for a replay (draft-00 6.3, 6.4), so a replay that arrives more than 60 seconds after the entry’s deadline (below; draft-00 section 2 allows 60 seconds of clock skew on every expiry check) is STALE even when its outcome exists. Without a limit on late admissions, a credential committed shortly before expiry could never collect its timeout refund (7.5) or a missed change. The deadline of a notes entry is the earlier of the offer’s
expiresand the entry’squote_expires; its admission cutoff is that deadline minuspending_timeoutminus 60 seconds. A gate that listsbnap:notes(provisional):- MUST NOT commit a new notes credential after the cutoff. It applies the cutoff inside the commit transaction, after the replay lookup of draft-00 6.3: a credential whose outcome exists is still served as a replay, and a new one gets STALE and consumes nothing;
- MUST NOT list
bnap:notesin an offer whose cutoff is less than 60 seconds after the offer is issued, and SHOULD leave several minutes. With the defaultpending_timeoutof 300 seconds, an exact-scope offer that listsbnap:notestherefore lives at least 7 minutes, and SHOULD live 10 minutes or more, which is still the “minutes” of draft-00 12; - SHOULD issue the next route-scope offer before the current one reaches its cutoff, so that clients always hold an offer that admits.
Every committed notes credential then reaches its
pending_timeoutat least 60 seconds before its deadline, and a client that replays as 7.5 says collects its refund. A socket-level cure, a gate that serves the replay of an authentic but expired offer instead of answering STALE, is proposed as a draft-00 erratum; with it, the cutoff could go.
6. Verification
6.1 What verify gets
The gate calls the scheme’s verify only after the offer checks of draft-00 4.4, and passes it the pay body and witness, the Digest the gate computed (draft-00 5.2), the chosen pay entry (amount, unit and parameters), the current time, read access to the issuer’s keyset and quote tables, and the signer’s check of unblinded notes. verify has no side effects: it writes nothing to any store.
6.2 Checks
The scheme runs these checks in this order, cheapest first. The first failure ends verification with socket code BAD_CREDENTIAL (10) and the scheme_code shown (section 13).
- Form. The body and witness pass 3.5 (
NON_CANONICAL). The body holdsinputsorfund(EMPTY). There are at mostmax_inputsinputs and at mostmax_outputsoutputs (LIMIT). - Keysets. Every input’s keyset is listed in the offer’s
keysets, is known to the issuer, is not revoked, andnowis before itsfinal_expiryplus 60 seconds: draft-00 section 2 allows 60 seconds of clock skew on every expiry check, this one included. Every output’s keyset is listed withactiveand is not revoked (UNKNOWN_KEYSET). The offer can only narrow what the issuer’s live table allows: a keyset revoked after the offer was made is refused. - Amounts. Every amount is a power of two for which its keyset has a key (
BAD_AMOUNT). - Fund. If the body holds
fund, the quote exists, has methodbolt11and unitsat, carries a NUT-20pubkey, and is paid in full (QUOTE_UNPAID). Verification does not ask whether the quote has issued: the RailId commit decides that atomically (8.4, 8.5), so a quote used before gives SPENT at commit, and a replay of the same credential still reaches the socket’s replay path (provisional). That replay verifies only while the quote record and the rail’s confirmation of its payment still exist, which 8.6 guarantees until the replay can no longer arrive. - Value and outputs. The value of 6.3 is defined, and the outputs form a complete ladder that covers it (
BAD_AMOUNTorBAD_OUTPUTS). - Witness. The i-th signature of
input_sigsverifies under the i-th input’sKover the Digest, andfund_sigverifies under the quote’spubkey(BAD_SIGNATURE). - Notes. For every input,
C = a·hash_to_curve(hex(K))for the keyaof its keyset and amount, checked by the signer (BAD_SIGNATURE).
The scheme does not compare the value with the price: the socket does that after pricing and answers UNDERPAID (draft-00 7).
No use state. Verification never asks whether anything in the credential was used before: not the use store, not whether the fund quote has issued (step 4), and not the signed-output table. The socket decides use at commit (draft-00 6.3), after verification, and recognizes a replay there. A check of earlier use inside verify would fail a replay of a committed credential before commit, and the client would lose the stored response, a missed receipt and the timeout refund (draft-00 6.4, 7.5). v0.4’s check that no output was signed before is therefore dropped (provisional): a B' signed before is signed again at settlement (7.4).
6.3 Value and the output ladder
I = sum of the input amounts
F = the fund quote's amount, or 0 without fund
fee = (sum over the inputs of their keyset's input_fee_ppk + 999) div 1000 NUT-02; the fund pays no fee
value = I + F - fee
All arithmetic is checked unsigned 64-bit. An overflow, or fee > I + F, is BAD_AMOUNT.
Ladder rule. Sort the output amounts in ascending order, a₁ ≤ a₂ ≤ … ≤ aₖ. The outputs form a complete ladder when every aᵢ ≤ 1 + (a₁ + … + aᵢ₋₁), which for powers of two means every amount from 0 to their total is the sum of some of them. The outputs MUST form a complete ladder whose total is at least value; otherwise the result is BAD_OUTPUTS. When value is 0 the body MAY omit outputs. Because any change from 0 to value can then be formed exactly, the gate never keeps change the client meant to get back, and a FAILED outcome always refunds the whole value (section 7) (provisional; v0.4 used separate exact change and refund lists, which a charge known only at settlement cannot use).
Canonical ladder. A client SHOULD send exactly one output of each amount 1, 2, 4, …, 2^(b-1), in that order, where b is the bit length of value, so that every wallet sends the same outputs for the same value (provisional). For the draft-00 10.3 example, a value of 1,680 sat gives b = 11 and the outputs 1 to 1,024, 2,047 sat in total. At the default max_outputs of 16, one credential carries a value of at most 65,535 sat.
6.4 What verify returns
- Local IDs: the
Yof each input, as a 33-byte compressed point, under the use modelsingle. The gate turns each intoUseId = H_"bnap/use"(lp("bnap:notes") ‖ Y)(draft-00 6.1). - Rail references: none for notes. A body with
fundadds one: rail kindlnand the payment hash of the quote’s invoice. The operator’s rail confirms the payment and returns the canonical bytes, the 32-byte payment hash, from which the gate computes the RailId and commits it with the UseIds (draft-00 6.1, 6.3). If the rail reports the payment unpaid, the result is BAD_CREDENTIAL withbnap:notes/QUOTE_UNPAID, and if the rail cannot be reached, UNAVAILABLE (provisional; draft-00 does not name the code for a failed rail confirmation). - Value:
valueof 6.3, in sat.
Outputs and quote IDs yield no local ID (provisional). The RailId already makes a fund quote single-use across credentials and NUT-04 minting (8.5), and signing a B' twice harms only the client who reused it (7.4).
7. Settlement: change and refund
7.1 The change cap
When the scheme’s settle runs, the socket has fixed charged (draft-00 6.4, 7): the effective price for a non-metered offer, min(price(⌈units × rate / 1000⌉), price(amount)) for a metered one, and 0 for a FAILED outcome (provisional reading: draft-00 8.1 sets charged to 0 “when nothing was charged”, and a FAILED action charges nothing). A FAILED Response to a metered offer reports metered_units 0, the count for which the metering formula of draft-00 7 also gives charged 0: ⌈0 × rate / 1000⌉ = 0 and price(0) = 0 (provisional; draft-00 8.1 requires metered_units in every Response to a metered offer but does not say what a FAILED action counts, and the byte-exact Response vectors of 18.2 item 6 need one value). Admission required value ≥ price(amount), and charged never exceeds price(amount), so
change = value - charged, 0 ≤ change ≤ value ≤ total of the outputs
The scheme signs outputs worth exactly change. The operator-trusted signer enforces the same cap on its own and refuses to sign more than value - charged (the CappedSigner of the v0.5.1 overview). Input fees are kept in both outcomes.
7.2 Selecting the outputs
select(outputs, change):
r = change; S = {}
for i in the output indices, by amount descending, then by index ascending:
if amount(i) ≤ r: S = S ∪ {i}; r = r - amount(i)
return S r is 0 here, by the ladder rule of 6.3
On a complete power-of-two ladder this greedy rule always reaches exactly change. OK and FAILED outcomes use the same rule; a FAILED outcome, with charged 0, returns the whole value. In the draft-00 10.3 example the value is 1,680 sat and the charge 340 sat, so the change is 1,340 sat and the scheme signs the outputs of 1,024, 256, 32, 16, 8 and 4 sat.
7.3 Scheme result
The scheme’s scheme_result (Response record 16, draft-00 8.1) is a TLV stream:
| Type | Name | Value | Presence |
|---|---|---|---|
| 0 | signatures | Entry list of signature entries, one per signed output, in ascending index order | When change > 0 |
A signature entry:
| Type | Name | Value |
|---|---|---|
| 0 | index | tu64: the zero-based index of the output in the body. Required, for index 0 too, whose value is empty |
| 2 | C_ | Point: C' = a·B' for that output’s keyset and amount. Required |
| 4 | e | 32 bytes: the NUT-12 DLEQ challenge. Required |
| 6 | s | 32 bytes: the NUT-12 DLEQ response. Required |
When the change is 0 the scheme returns empty bytes and the Response omits record 16 (provisional). The Response’s ack signs the whole scheme_result (draft-00 8.1), so a wrong change is attributable.
Encoding. The gate writes scheme_result canonically, under the rules 3.5 sets for a body, so that the same settlement always gives the same bytes (7.4) and every client parses them one way. One value in it can be empty: tu64 writes 0 as the empty string (draft-00 section 2), so the entry for the first output has an index record with an empty value. A Required record is always written, empty or not, and an optional record whose value is empty is left out, as record 16 is above. A gate therefore MUST write the index of output 0 as a present record with an empty value, the two bytes 00 00, and MUST NOT leave it out; draft-00’s own vectors write the Required status record of an OK Response the same way, as 02 00. The stream of a signature entry is then 105 bytes for index 0, starting 00 00 02 21, and 106 bytes for an index from 1 to 255, starting 00 01, the index byte, 02 21; with its length prefix an entry is 106 or 107 bytes. Draft-00 section 2 also says an absent record and an empty one MUST mean the same, so a client MUST read a signature entry without an index record as index 0, and MUST NOT reject the Response for that alone; a gate that leaves the record out still breaks this section, and its signed Response shows it (provisional).
The client knows value from its own body and the offer, and charged from the Response, so it recomputes select itself. It MUST check that the signed indices equal its own result, MUST verify every DLEQ proof against the keyset key A for that amount (NUT-12), and only then unblinds C = C' - r·A. A mismatch is the operator’s fault, and the signed Response is the evidence.
7.4 Deterministic signing and the signed-output table
Draft-00 6.4 requires settle to be deterministic. A blind signature a·B' already is. The issuer MUST make every DLEQ proof with NUT-12’s deterministic nonce, which NUT-12 itself only recommends; with it, the same verified credential, outcome and charge always give byte-identical scheme_result.
The issuer records every signed output, with its keyset, amount, B', C', DLEQ proof and source, in a signed-output table that it keeps until 60 seconds after the output keyset’s final expiry (section 10), as NUT-09 requires. The source is 32 bytes: H(body) of the credential body whose settlement signed the output, or rk_mint for a NUT-04 mint (8.5). The table is keyed by (B', source) and keeps every record: a B' signed under two sources has two records (provisional). These records MUST be durable no later than the outcome write of draft-00 6.4, and therefore before the Response is sent.
If a B' was signed before, under another credential or mint request, settle still signs it, does not fail, and adds its own record (provisional). Records for the same keyset, amount and B' carry the same C' and the same DLEQ proof, because C' = a·B' and NUT-12’s deterministic nonce is derived from a, A, B' and C' only. A NUT-09 restore therefore answers with any record whose keyset, amount and B' match the request. The two signatures share one Y, so at most one of them can ever be spent: the client that reused the output loses value, and the operator loses nothing. A third party that copies someone else’s B' gains nothing either (section 17).
7.5 Replays and timeouts
Replays are the socket’s (draft-00 6.4): a replay returns the stored scheme_result, and the scheme MUST NOT sign anything beyond it. When an outcome is still PENDING after pending_timeout, the socket moves it to FAILED with charge 0. The refund needs the client’s outputs, which the outcome record does not keep, so it is computed when the client replays the credential: the gate finds the FAILED outcome without a scheme_result, calls settle for FAILED with charge 0, stores the result once with a conditional write, and returns it (provisional reading of draft-00 6.4, which relies on settle being deterministic but does not say when the timeout refund is computed). A client whose request timed out therefore replays it to collect its refund.
When to replay. A client whose request has no final answer, or whose response or receipt went missing, replays the credential at once, again at each retry_after of a PENDING answer, and in any case as soon as pending_timeout has passed since it sent the credential. The admission cutoff of 5.4 leaves at least 60 seconds between that timeout and the offer’s deadline for this replay (provisional). More than 60 seconds after the deadline, draft-00 as written answers even a replay with STALE (5.4), so a refund, change or receipt not collected by then is lost to the client.
8. Funding inside the 402
Buying credit is Cashu’s Lightning mint flow, used verbatim: a NUT-23 BOLT11 quote locked to a client key (NUT-20), paid, then minted with NUT-04. BOLT11 is mandatory, because the issuer is always online and every payer stack accepts it. BOLT12 through NUT-25 is optional and is minted through NUT-25 only; a body’s fund redeems BOLT11 quotes only (provisional). This section adds one shortcut: a first-time client can get the quote inside the 402 and redeem it inside the paid request, so it funds and acts in one round trip.
8.1 Asking for a quote
On the HTTP carrier, a client asks for funding by sending the request field BNAP-Fund with the request that will get the 402 (provisional; draft-00 section 13 does not list this field, so it needs registering):
BNAP-Fund: amount=2100, pubkey="02c4…"
The field is an RFC 9651 Dictionary. amount is an Integer, the most the client will pay, in sat. pubkey is a String of 66 lowercase hex characters, a compressed secp256k1 key the client made fresh for this quote. Other members are ignored. Other carriers define no such member yet; their clients buy credit through NUT-23 and NUT-04 directly (8.5).
8.2 Making the quote
A gate that receives BNAP-Fund on a route whose notes pay entry carries max_fund:
- MAY ignore it, and then sends its offers without a
fund_quote; - otherwise creates a NUT-23 BOLT11 quote for
min(amount, max_fund)sat, locked topubkey(NUT-20), with a quote ID of at most 64 bytes, and puts it in thefund_quoteof an exact-scope offer for this request (5.3, 5.4), also on a route listed with"scope": "route"(5.4). It MAY also send its route-scope offer, without the quote, in the same 402; - MUST create at most one quote per offer nonce and MUST rate-limit quote creation per client address (provisional, following DECISIONS row 24 for native challenges), and SHOULD bound the open, unpaid quotes per address;
- MUST NOT create a quote for a request without
BNAP-Fund. A scraper gets an offer, never an invoice; - SHOULD set the quote’s
expiryno later than the admission cutoff of the offer that carries it (5.4), so that a client that pays in time can still use that offer (provisional). A quote paid later is not lost: it is redeemed with any other notes offer that admits a funded credential, or minted (8.4, 8.5); - MAY decline amounts below a minimum top-up of its choosing, in which case it sends no quote. v0.4 suggested
max(4 × price, 21)sat, because the operator’s inbound liquidity, not routing, is the real cost of a small payment.
8.3 Paying the quote
Before paying, a client MUST check that the offer passes draft-00 4.4, that pubkey is the key it sent, that amount is at most what it asked for and equals the amount of the BOLT11 invoice in request, and that the invoice has not expired. It MUST save the quote ID, the quote key and the offer before it pays, so that a request that fails can still mint the paid quote through NUT-04 (8.5). It then pays the invoice through any Lightning pay source, such as an NWC connection.
8.4 Redeeming the quote in a credential
The client puts the quote ID in the body’s fund and signs the Digest with the quote key as fund_sig. The quote may be redeemed with any offer that lists bnap:notes, not only the one that carried it, and alongside notes or alone, except under an offer for a route whose require sets link-payment to unlinkable. Such a credential is linkable to its payment (section 14), so on such a route the gate MUST reject a notes credential whose verify returns a rail reference, which it does exactly when the body holds fund (6.4), with POLICY (8), after verification and before commit (provisional). Nothing is consumed and the quote stays mintable through NUT-04, as for any failure before commit. It adds its amount to the value with no input fee (6.3). Verification checks the quote (6.2, step 4) and returns its payment as a rail reference (6.4), and the socket commits the RailId in the same transaction as the notes’ UseIds (draft-00 6.3).
If the request fails before commit, nothing is consumed and the quote stays mintable through NUT-04. After commit the quote counts as issued, whatever the outcome: on OK the change comes back as notes, and on FAILED the whole value, the quote’s amount included, comes back as notes (7.2).
A credential that redeems a quote links that one action to the Lightning payment. Notes received as change are unlinkable. A client that needs every action unlinkable buys credit through NUT-04 first and spends only notes. A route that requires link-payment = unlinkable holds every client to this: its gate offers no quote there (5.4) and admits no funded credential.
8.5 Minting through NUT-04
The issuer serves NUT-04 BOLT11 minting (POST /v1/mint/bolt11) for BNAP keysets, the ISSUE of v0.4. A quote for a BNAP keyset MUST carry a NUT-20 pubkey, and the mint request MUST carry a valid NUT-20 signature (provisional; NUT-20 makes both optional). Outputs follow NUT-04: they sum to the quote amount, are on active BNAP keysets, are powers of two, and no B' was signed before.
Draft-00 6.1 requires that “a scheme that mints credit from a payment … MUST have the gate commit that payment’s RailId when it mints”. The issuer therefore uses the gate’s use store, the same one the socket commits to, as follows (provisional):
rk_mint = H_"bnap:notes/mint"( lp(quote ID) ‖ BigSize(k) ‖ lp(output₁) ‖ … ‖ lp(outputₖ) )
outputᵢ = the output entry of 3.3 built from the i-th blinded message of the mint request
mint(quote, outputs):
confirm through the rail that the quote's invoice is paid otherwise the NUT-04 "not paid" error
sigs = blind signatures and DLEQ proofs for outputs deterministic, as in 7.4
in one serializable transaction:
if used[RailId] = rk_mint: return sigs a retry of the same mint request
if used[RailId] exists: abort NUT-04 error for an issued quote
if any output's B' has a record: abort NUT-04 error for an output signed before
used[RailId] = rk_mint; record sigs with source rk_mint
return sigs
A mint commit has no outcome record and no PENDING state, because signing comes before the transaction and the transaction is the whole operation. A retry with the same quote and outputs is recognized before the output check, and gets the same signatures, as NUT-19 cached responses would give, because signing is deterministic. An abort consumes nothing: the quote stays mintable, and a client whose outputs were refused derives fresh ones and signs a new NUT-20 request. The issuer keeps the RailId record at least as long as the quote record, and the quote record at least as long as the RailId record (8.6).
8.6 Quote state
The use store is the authority on whether a quote has issued: a quote is issued exactly when its RailId is committed, by a credential or by a mint. The issuer’s NUT-23 quote check MUST then report amount_issued equal to amount, and the deprecated state as ISSUED.
Retention. Every verification of a credential that redeems a quote, replays included, reads the quote record (6.2, step 4) and asks the rail to confirm the quote’s payment (6.4). A replay of a committed funded credential therefore reaches the socket’s replay path (draft-00 6.4) only while both still answer; if either is gone, the replay fails with QUOTE_UNPAID before commit, and the client loses the stored response, a missed receipt and the timeout refund (draft-00 6.4, 7.5). Therefore (provisional):
- the issuer MUST keep each quote record, with its amount,
pubkey, payment hash and paid state, for as long as the quote’s RailId record, so that the two are deleted together (8.5); - when a credential commits the RailId, the issuer MUST keep both at least until 60 seconds after the deadline (5.4) of that credential’s notes entry, the last moment a replay of it can pass the expiry check of draft-00 4.4. A gate can store that time with the RailId when it commits, or keep every redeemed quote for the longest lifetime of any notes offer it issues, plus 60 seconds, after the commit;
- the rail MUST be able to confirm the payment for as long. A rail plug whose Lightning node prunes settled invoices answers from the paid state that the quote record holds.
A quote minted through NUT-04 needs no such time, because a mint has no replay beyond the NUT-04 retry (8.5). If the draft-00 erratum of 5.4 lets replays through after the deadline, the bound becomes replay_window after the commit.
9. Input fees
Input fees follow NUT-02: each input contributes its keyset’s input_fee_ppk, and the fee is the sum rounded up to whole sats (6.3). The offer repeats each keyset’s fee (5.2). The fund quote pays no fee. Fees are kept on OK and on FAILED. Fees exist so that free swaps cannot bloat the store, so an operator that offers a swap route at price 0 (section 11) SHOULD set a nonzero input_fee_ppk on its keysets.
10. Keysets and rotation
- Lifecycle. A keyset is published, with its keys served but not active, at least 30 days before it signs anything. It then signs outputs for about 3 months, then accepts inputs only, and reaches its
final_expiryabout 15 months after it started signing. Oncefinal_expiryplus the 60 seconds of skew has passed (6.2, step 2), it is never accepted again. - Standby. The operator always keeps at least one published keyset that is not yet active, so that a compromised keyset can be replaced at once.
- Revocation. A compromised keyset leaves the
keysetsof every new offer and the issuer’s live table at once, and inputs and outputs on it are refused from then on (UNKNOWN_KEYSET). The operator cannot tell honest notes on that keyset from forged ones, so their holders lose them; this is an accepted residual risk (section 17). - Retention. A note still verifies for 60 seconds after its keyset’s
final_expiry, because draft-00 section 2 allows that skew on every expiry check (6.2, step 2). Its single-use record therefore stays in the use store untilfinal_expiryplus 60 seconds, which is how long draft-00 6.2 requires: a record deleted atfinal_expiryitself would let a note that still verifies be spent a second time. Signed-output records stay until the output keyset’sfinal_expiryplus 60 seconds (NUT-09). After that the issuer MAY delete both, as NUT-02 allows. Quote records and their RailId records are kept as 8.6 says. - Offers and expiry. An offer lists only keysets whose
final_expiryis later than itsexpiresplus 60 seconds (5.2). The issuer MUST keep signing on a keyset for as long as an unexpired offer lists it asactive, unless the keyset is revoked. - Consistent keys. Clients compare the full key list of each keyset from a second vantage point, such as another network path or a published mirror, and SHOULD NOT accept change on a keyset whose keys differ between them; this catches keys made to tag one user. The 30-day publication rule gives them time to do so.
- Wallets. A wallet SHOULD swap notes on a keyset that is within 30 days of its
final_expiryinto an active keyset (section 11). An operator announces retirement or shutdown at least 30 days ahead through the keyset’sfinal_expiry.
11. Swaps
A swap re-denominates notes, or moves them to an active keyset. It is an ordinary BNAP request on a route priced at 0; the body and witness are as for any other credential, and the whole value comes back as change through the outputs (section 7). The operator SHOULD list such a route in its manifest with id notes-swap, for example {"id": "notes-swap", "match": "POST /bnap/notes/swap", "scope": "route", "price": {"sat": 0}, "pay": ["bnap:notes"]}, and the gate answers it itself, with status 200, no content and BNAP-Receipt (provisional). A wallet finds the route in the manifest by its id. The swap’s scope is the carrier’s ordinary action scope (draft-00 10.1); v0.4’s H_"bnap/scope/swap"(origin) and its info-endpoint swap URL are gone. A plain NUT-03 swap is never accepted (4.2).
12. Seed recovery
12.1 Derivation
A wallet holds one BIP-39 seed. Note keys and blinding factors come from the NUT-13 derivation for keysets with version-01 IDs:
d(type) = HMAC-SHA256(seed, "Cashu_KDF_HMAC_SHA256" ‖ keyset ID ‖ counter as 8 bytes big-endian ‖ type)
k_n = int(d(0x02)) mod n the note key; derivation type 0x02 (provisional)
r = int(d(0x01)) mod n the blinding factor, as NUT-13 defines it
NUT-13 assigns types 0x00 (secret) and 0x01 (blinding factor). Type 0x02 for the note key is not assigned yet and is to be requested from the Cashu maintainers; until then this document uses it as written (provisional). Type 0x00 is not used, because a note’s secret is hex(K). If k_n or r is 0, the wallet skips that counter value. Before the test vectors of section 18 are frozen, this layout MUST be checked against the NUT-13 test vectors.
Counters. Counters are per keyset. The top 16 bits of the 64-bit counter name a device or agent slot, and the low 48 bits count within it, so two devices that share a seed never derive the same key (provisional; v0.4 split the counter the same way without fixing its width). Every output consumes one counter value. A wallet MUST NOT reuse a counter value once the B' derived from it has been sent in any credential or mint request, signed or not, because the same B' in two requests links them.
12.2 Restore
The issuer MUST serve NUT-09 restore and NUT-07 state checks for BNAP keysets:
- NUT-09 answers from the signed-output table (7.4, 8.5).
- NUT-07 answers from the gate’s use store: a
Ywhose UseId is committed isPENDINGwhile its outcome is PENDING andSPENTotherwise, even after the outcome record is deleted, and every otherYisUNSPENT.
To restore, a wallet takes the sites and slots recorded in its backup and, for each keyset of each issuer (inactive keysets inside their final expiry included), regenerates outputs in batches of 100 counter values, asks the issuer to restore them, and stops after three consecutive batches come back empty (NUT-13). It unblinds what comes back and checks each note’s Y with NUT-07.
Gaps. Unsigned outputs leave gaps in the counter sequence: a credential whose change is 0 leaves its whole ladder unsigned, up to max_outputs values. A long run of such credentials would hide later notes from the three-empty-batches rule. A wallet MUST therefore record, in its backup, the highest counter value it has used per keyset and slot, at least every time that value has advanced by 100 since the last record, and a restore MUST scan at least up to the recorded value before applying the gap rule (provisional).
Backup. The wallet’s site list, with each origin’s issuer URL, keysets, slots and counters, is backed up encrypted, as Cashu wallets back up their mint lists. The encryption is the wallet’s choice.
13. Errors
13.1 Scheme codes
Every scheme error is sent with socket code BAD_CREDENTIAL (10, HTTP 402) and one of these scheme_code values (draft-00 8.2):
| scheme_code | Meaning | Client action |
|---|---|---|
bnap:notes/NON_CANONICAL | The body or witness breaks 3.5, for example with a non-minimal BigSize or tu64, which inside them is this code and not socket MALFORMED (2.1) | Fix the encoder |
bnap:notes/EMPTY | The body has neither inputs nor fund | Add notes or a fund quote |
bnap:notes/LIMIT | More inputs or outputs than max_inputs or max_outputs | Swap into fewer notes first |
bnap:notes/UNKNOWN_KEYSET | An input keyset is not in the offer, unknown, revoked or past its final expiry, or an output keyset is not active | Refresh the offer and keysets; swap old notes |
bnap:notes/BAD_AMOUNT | An amount is not a power of two with a key, the arithmetic overflows, or the fees exceed inputs plus fund | Fix the amounts |
bnap:notes/BAD_OUTPUTS | The outputs are not a complete ladder covering the value | Send the canonical ladder (6.3) |
bnap:notes/BAD_SIGNATURE | A note, an input signature or the fund signature failed | Drop the bad input, or check the wallet’s keys |
bnap:notes/QUOTE_UNPAID | The fund quote is unknown, unpaid, not BOLT11 in sat, or has no NUT-20 key, or the rail does not confirm its payment. A quote already issued gives socket SPENT (12) at commit instead | Pay it, wait for the payment, or drop it |
A rail that cannot be reached gives UNAVAILABLE (15), never admission (draft-00 12).
bnap:notes/OUTPUT_REUSED, which v0.4 defined and draft-00 8.2 uses as its example of a scheme code, is reserved and never sent: verification does not reject a reused output (6.2), and settlement signs it again (7.4) (provisional).
13.2 The v0.4 codes in draft-00
| v0.4 code | Now |
|---|---|
VERSION | Socket VERSION (1) |
MALFORMED | Socket MALFORMED (2) for the socket streams; bnap:notes/NON_CANONICAL or bnap:notes/EMPTY for the body and witness, non-minimal integers included (2.1) |
TOO_LARGE | Socket TOO_LARGE (3) for sizes; bnap:notes/LIMIT for counts |
UNKNOWN_KEYSET | bnap:notes/UNKNOWN_KEYSET |
UNIT_MISMATCH | bnap:notes/UNKNOWN_KEYSET: only sat keysets are ever listed (provisional) |
BAD_CHALLENGE | Socket BAD_OFFER (4) |
STALE | Socket STALE (5) |
WRONG_SCOPE | Socket WRONG_SCOPE (6); a binding mismatch is BINDING (11) |
WRONG_AMOUNT | Socket UNDERPAID (9) when the value is below the price; bnap:notes/BAD_OUTPUTS when the outputs do not fit |
BAD_SIGNATURE | bnap:notes/BAD_SIGNATURE |
OUTPUT_REUSED | Not sent; bnap:notes/OUTPUT_REUSED is reserved (13.1) |
SPENT | Socket SPENT (12) |
QUOTE_UNPAID | bnap:notes/QUOTE_UNPAID |
PENDING | Socket PENDING (13) |
RATE_LIMITED | Socket RATE_LIMITED (14) |
v0.4 said error responses SHOULD carry an operator signature. Draft-00 errors are never signed (draft-00 8.2), and that rule is dropped.
14. Registry entry and property vector
The registry entry, in the form of draft-00 section 11:
[scheme."bnap:notes"]
role = "value"
spec = "https://github.com/bnap-protocol/bnap/blob/main/spec/schemes/bnap-notes-draft-00.md"
binding = "S"
use = "single"
local_id = "Y of each input: hash_to_curve(hex(K)), 33-byte compressed point"
quota_id = ""
rail_refs = "none for notes; kind ln, the invoice payment hash, for a redeemed fund quote and at minting"
body = "TLV, section 3 of the scheme spec"
witness = "TLV, section 3.4: one BIP-340 signature per input, plus one by the fund quote's NUT-20 key, all over the Digest"
native = []
units = ["sat"]
refund = "atomic"
upto = false
properties = { theft = "key-witnessed", link-payment = "unlinkable", link-actions = "unlinkable",
issuer = "self-issued", verify = "offline", finality = "final-on-verification",
custody = "operator-iou", pq = "no", maturity = "experimental" }
conformance = ""
Two fields differ from the bnap:notes example in draft-00 section 11, which gives rail_refs = "none at spend; the funding payment at minting, confirmed by the rail" and witness = "one BIP-340 signature per input, over the Digest". That example does not cover funding inside a credential (section 8). A credential that redeems a fund quote relies on the quote’s Lightning payment, and draft-00 6.1 says a scheme MUST return a rail reference for every payment its credential relies on, so rail_refs names the ln reference at spend as well as at minting (6.4). The quote’s NUT-20 signature is a signature over the Digest, which draft-00 5.1 puts in the witness, so witness names fund_sig too (3.4). Draft-00 6.1 and 5.1 govern, and the draft-00 example should read as this entry does (19.2).
Draft-00 section 11 lists an entry only once it has a public specification, test vectors, passing conformance results and expert review. This document is the specification; conformance stays empty, and the entry stays unlisted, until the conformance suite has produced results for it.
The property vector, weakest to strongest as the overview orders each property:
| Property | Value | Note |
|---|---|---|
| use | single | Each Y admits one action |
| binding | S | The witness signs the Digest: offer, chosen schemes, extensions, bodies and TLS binding |
| theft | key-witnessed | A note copied from a log or a proxy is useless without k_n |
| link-payment | unlinkable | Notes are unlinkable from the purchase that funded them. A credential that redeems a fund quote is linkable to that payment, so a gate never admits one on a route that requires link-payment = unlinkable (8.4). The first action after a purchase can still be correlated by timing |
| link-actions | unlinkable | Blinding; one K per note; the canonical ladder (6.3) |
| issuer | self-issued | The gate accepts only its own keysets |
| verify | offline | A signer check and signature checks; the rail is asked only for a fund quote |
| finality | final-on-verification | |
| custody | operator-iou | Credit is the operator’s promise; wallets keep balances small (section 15) |
| refund | atomic | Change and refunds come back in the Response (section 7) |
| pq | no | Forgery is not post-quantum; blinding keeps past spends private |
| maturity | experimental | Until the NUTs of nuts/ are accepted |
These are the scheme’s own values. The gate combines them with the rail and verifier entries into the effective vector of each pay entry (draft-00 section 11): notes funded through a card or another reversible rail are reversible and linkable to the payer.
15. Wallet requirements
A notes wallet MUST:
- enforce spending caps per origin and per task in code, never in a model’s prompt;
- make a fresh NUT-20 key for every quote, run the checks of 8.3 before paying, and never pay more than it asked for in
BNAP-Fund; - save the quote, its key and the offer before paying (8.3);
- keep per-site balances small, because credit is the operator’s IOU; 5,000 sat per site is the RECOMMENDED default cap;
- use each note key once, at one origin, and never reuse a counter value (12.1);
- send outputs that form a complete ladder covering the value (6.3);
- check the selected outputs and every DLEQ proof before accepting change (7.3);
- record its counters in its backup as 12.2 requires;
- keep every Response it paid for (draft-00 8.1);
- replay a credential that has no final answer, or whose receipt went missing, at once, at each
retry_afterand as soon aspending_timeouthas passed, and in any case before its offer’s deadline (7.5).
A notes wallet SHOULD send the canonical ladder (6.3), send offer_id instead of the offer where it can (draft-00 5.1), and swap notes before their keyset’s final expiry (section 10). It SHOULD include a binding whenever it can read its connection’s tls-exporter value. A BINDING error means the gate cannot check the binding or the connection is intercepted; the wallet MAY retry without a binding only if it accepts that the response is then only as confidential as the credential (draft-00 6.4). v0.4 said agents and native clients MUST bind. Draft-00 5.3 makes a gate behind a TLS-terminating front reject every bound credential, so a wallet that always binds could never pay such a gate, and draft-00 does not say how a client learns whether a gate can check a binding (provisional).
16. Withdrawal
Withdrawal (v0.4’s BNAP-W) is not part of this document. An issuer that offers no withdrawal extension MUST refuse NUT-05 melts of BNAP notes (4.2). An operator that enables one MUST, as v0.4 required, write spend records synchronously to durable, replicated storage before answering, cap withdrawals per epoch and per request, pay out only through a NUT-05 melt that carries a witness by every input’s K in the key-secret melt context (4.2), list melt in its key-secret contexts, and keep the issuer key in a separate signer process. Paying out to arbitrary invoices moves an operator from merchant toward money transmitter, so withdrawal needs legal review in each jurisdiction.
17. Security and privacy considerations
The socket’s considerations (draft-00 section 12) apply. These are the scheme’s own:
| Threat | Closed by |
|---|---|
| Double spend | Each Y committed as a single UseId, atomically and durably, before the action runs (draft-00 6.3) |
| Note theft from logs, proxies or CDNs | A witness by K over the Digest |
| Change or refund stolen by a proxy | The Digest covers the body, outputs and quote included; the outputs are blinded |
| A fund quote redeemed twice | One RailId per payment, single, shared by credentials and NUT-04 minting (8.5) |
| A quote swapped by a proxy | offer_sig covers the quote; the wallet checks its key and amount (8.3) |
| Theft with a summed key | One signature per K, no aggregation |
| Redeeming a note through a plain Cashu swap or melt | Key isolation (4.2) |
| Mixed units or overflow | sat only, checked arithmetic (6.3) |
| Bloating the store with free swaps | Per-keyset input fees (section 9) |
| Admission minted by someone else | The gate accepts only its own keysets (6.2, step 2) |
| The issuer links a spend to a purchase | Blinding; no DLEQ proof or blinding factor is ever presented (4.2) |
| Keys made to tag one user | Keysets published 30 days ahead and compared from a second vantage point (section 10) |
| Wallet fingerprinting | Powers of two, the canonical ladder, one K per note |
| Linking two actions through a reused output | Counters are never reused (12.1) |
| Lost device | Seed recovery through NUT-13, NUT-09 and NUT-07 (section 12) |
| A linkable funded credential admitted on a route that requires unlinkable payment | No quote and no max_fund in that route’s offers (5.4), and POLICY for every credential that redeems a quote there, whichever offer the quote came from (8.4) |
| Invoice minting as denial of service | Funding only on BNAP-Fund, capped, one quote per offer nonce, rate-limited per address (8.2) |
| Change withheld or miscounted | The deterministic selection (7.2), checked by the client against the signed Response |
| A timeout refund or missed change lost because the offer expired | The admission cutoff (5.4) and prompt replays (7.5) |
| A replay of a funded credential failing because the quote record or the rail’s record of its payment was pruned | Both kept at least 60 seconds past the deadline of the entry that committed the quote (8.6) |
| A replay or crash paying change twice | The socket’s single settle write and stored scheme_result (draft-00 6.4); deterministic signing (7.4) |
Output front-running. Someone who sees a credential in transit or in a log can copy its B' values into a credential of its own, for example a price-0 swap (section 11) whose change is one of the victim’s outputs, and have it settle first, even while the victim’s action is still running. This gains it nothing and costs the victim nothing. The front-runner cannot unblind what it gets back, so the output it spent its own value on is lost to it. The victim’s credential is unaffected at every stage, because verification never consults the signed-output table (6.2): before commit it is admitted as usual; after commit, a replay still reaches the socket’s replay path, so a PENDING answer, the stored response, a missed receipt and a timeout refund all stay available (draft-00 6.4, 7.5); and its settlement signs each of its B' under its own keyset and amount (7.4), which only the victim can unblind. Had verification rejected outputs signed before, as v0.4 did, the same copy would have turned every replay of a committed credential into BAD_CREDENTIAL, and the victim would have lost its change or its whole refund.
Accepted residual risks:
- Linkable funding. A credential that redeems a fund quote is linkable to its payment, and the first action after any purchase can be correlated by timing.
- Small operators can still link visitors by address, TLS fingerprint and timing. Notes are unlinkable; their holders are not anonymous.
- Operator credit. An operator that disappears takes unspent credit with it. Wallets keep balances small.
- Store loss. Without withdrawal, notes spent since the last durable copy of the use store can be spent again until their keyset expires, which costs free actions, never bitcoin.
- Key compromise. A stolen issuer key forges notes, so the keyset is revoked and replaced at once (section 10), and honest notes on it are lost. Keys live in a separate signer process.
- Not post-quantum. A quantum attacker could forge notes; blinding keeps past spends private.
- Late replays. Under draft-00 as written, a replay more than 60 seconds after its offer’s deadline is STALE (5.4), so a client that does not replay in time loses its refund or change. The admission cutoff gives every client at least 60 seconds after the timeout.
- Restore leaks a wallet. NUT-09 and NUT-07 requests show the issuer a set of outputs and notes that belong together, as in any Cashu wallet.
18. Test vector requirements
The bnap:notes vectors MUST be published, generated by a reference encoder and reproducible from the prose, before the registry entry is listed (section 14). Like the socket vectors (draft-00 section 14), they derive secret keys as SHA-256 of fixed strings, use 32 zero bytes of BIP-340 auxiliary randomness, and give every intermediate value.
18.1 The notes route offer
The route offer of draft-00 section 14 cannot carry a notes credential. Its bnap:notes pay entry is scheme, amount, unit and rate only, with no params record, so it names no issuer and no keysets, which 5.1 requires. No input can pass step 2 of 6.2 (UNKNOWN_KEYSET), so no valid notes credential exists for that offer. Its expires is also 300 seconds after the vectors’ reference time of 1791700000 (the manifest’s issued), which leaves it no admission window under 5.4 with the default pending_timeout of 300 seconds. The socket vectors in vectors/vectors.json use that offer with a placeholder notes body and a bare 64-byte witness: they test the socket’s hashing, not this scheme, and are not valid notes credentials under this document.
The notes vectors therefore use their own offer, the notes route offer. It is the draft-00 route offer, signed with the same offer key and MAC key, with the same nonce, attest entry, GREASE record and every other record unchanged, except that:
expiresis 1791703600, one hour after the reference time, so its admission cutoff (5.4) is 1791703240;- the
bnap:notespay entry carries aparamsrecord (5.1) of 106 bytes:issuerhttps://api.example/cashuand the keyset entries of keysets A and B below, in that order, with no limits, nomax_fundand nofund_quote.
Both keysets have unit sat and one key for each amount m = 2^i, 0 ≤ i ≤ 15. The private key a for amount m of keyset L is SHA-256 of the ASCII string bnap notes test keyset L m, with m in decimal, for example bnap notes test keyset A 1024, read as a big-endian integer, and its public key is A = a·G. A keyset ID is NUT-02’s version-01 ID: the byte 01 followed by SHA-256 of a UTF-8 string made of m:hex(A), with A as a Point, for each amount in ascending order, joined by ,, then |unit:sat, then |input_fee_ppk: and the fee unless the fee is 0, then |final_expiry: and the final expiry, numbers in decimal.
| Keyset | input_fee_ppk | final_expiry | active | Keyset ID |
|---|---|---|---|---|
| A | 100 | 1830000000 | yes | 011decbc482233faf1b58395ddb55c465e3bf6d8fa1c5a5b17219f7d5883b112a7 |
| B | 0 | 1800000000 | no | 014423cf221427bb65863007387bbc9b74963e47b24c3ec3a25ddd673e9ed6e898 |
| Vector | Value |
|---|---|
| Offer | 413 bytes, below |
| OfferID | 666b28f7f1d68d92e9c180f0d11fe743730d3932ab5bc566d68f53b17b0cbd95 |
offer_sig | 108b3bf0…eb9f5fec |
mac | e77e4e3ef6ce609745c194c160d1814295ad7e4d6d5a56178d68cf40b4a5dd4c |
Context: the action scope of draft-00 section 14, bnap:notes, httpsig:web-bot-auth, empty ext | 29c131cbf11af082d603f2246516d288565da10bc3703cc556b87575f43cbdf3 |
0001010220862d641e67813f6e78b20a81aa929519e66a978e69e8a4d3e27280990697f3a0040008000a046acb3a30
0c2468747470733a2f2f6170692e6578616d706c652f2e77656c6c2d6b6e6f776e2f626e61700e20b28dad5f717c51
44bdb0aa6db94bb6c79afa9b8bd9bc84825a2c1baafe64add91010000102030405060708090a0b0c0d0e0f12868500
0a626e61703a6e6f746573020208340403736174086a001968747470733a2f2f6170692e6578616d706c652f636173
6875024d280021011decbc482233faf1b58395ddb55c465e3bf6d8fa1c5a5b17219f7d5883b112a702016404002300
21014423cf221427bb65863007387bbc9b74963e47b24c3ec3a25ddd673e9ed6e8980a0201f4141e1d001468747470
7369673a7765622d626f742d61757468020102040207d0f040108b3bf0b2354e7363ba171b73ad57dfb594f6281ee8
5067dbb6d74eb1bbe558fd03765abf702d22562f8399ee60fdb4d54bcdf1c05bf3f26776cd79eb9f5fecf220e77e4e
3ef6ce609745c194c160d1814295ad7e4d6d5a56178d68cf40b4a5dd4cfeb2d05e01020a0a
The fee offer. One negative vector of 18.2 item 9, fees above the inputs, cannot be built on the notes route offer. On keysets A and B each input adds at most 100 to the fee sum, so k inputs pay at most (100k + 999) div 1000 ≤ k sat, and every input is worth at least 1 sat, so the fee never exceeds I, let alone I + F. That vector uses the fee offer instead: the notes route offer with a third entry in keysets, keyset C, and every other record unchanged. Keyset C is built by the rule above with the label C, input_fee_ppk 2000 and final_expiry 1830000000, and is not active, so outputs stay on keyset A. Its entry sorts after those of A and B and adds 40 bytes, so the fee offer’s params record is 146 bytes and the offer 453 bytes.
| Vector | Value |
|---|---|
| Keyset C ID | 01fd899e9c7a7f4940273d43ec1dcd3bafc9fe4fe80ba292b9f021ab09b5aecf46 |
| Fee offer OfferID | b958c89e56ef83bb05dd98e88b0a4746f79ebac53c09c53f306e74635be1f2aa |
| Fee offer Context, as for the notes route offer | a6f07b0ed275845d2d088e724cd9a7fc9cd7093df6e6456c613f40ca788bab11 |
nuts/tools/notes_offer_vector.py prints these values, the bytes of both offers and every keyset key from the reference encoder. Before it prints anything it checks that the same records, without params and with the draft-00 expires, give the draft-00 OfferID, that the notes route offer has the OfferID above, and that its keyset ID code reproduces a published version-01 ID.
18.2 Required vectors
The vectors MUST include:
-
Note math: a seed, keyset ID and counter giving
k_n(type0x02),K, the secret,Y,r(type0x01) andB'; a keyset keya;C'with its deterministic NUT-12 DLEQ proof; andC. Thehash_to_curveand DLEQ steps MUST agree with the NUT-00 and NUT-12 vectors. -
Offer parameters: the
paramsbytes of the notes route offer (18.1), a route-scope pay entry with two keysets, one active and with a fee; and of an exact-scope pay entry with afund_quote. -
Two notes credentials for the notes route offer (18.1), each with the action scope, Web Bot Auth attest body and binding of draft-00 section 14. That attest body is a placeholder, not a Web Bot Auth signature (draft-00 section 14: “Scheme bodies are placeholders”), and a real
httpsig:web-bot-authverifier would not verify it. The notes vectors therefore run with a stub attestation verifier in its place (provisional): it reports exactly that body as verified, with the local ID SHA-256(bot key thumbprint), so that its UseId is the one of draft-00 section 14, and reports any other body as not verified. The body’s bytes still enter the Context, the Digest and the ReplayKey as draft-00 5.2 and 6.3 say. Under the stub the discount applies, andprice(amount)= 2,100 − ⌊2,100 × 2,000 / 10,000⌋ = 2,100 − 420 = 1,680 sat. Without it the attestation has no effect (draft-00 7),price(amount)is 2,100, and both credentials below, worth 2,047 and 1,680 sat, are UNDERPAID. No document defines a realhttpsig:web-bot-authbody yet; once one does, the vectors can carry a real signature by a test key derived from a fixed string, which changes the bytes, the Digest and the ReplayKey but none of the amounts. For each: the body and witness bytes, the Digest, the UseIds, the ReplayKey and the whole Credential. Both send the canonical ladder (6.3) on keyset A, the only active keyset.- Credential 3a has two inputs: a 1,024-sat note on keyset A and a 1,024-sat note on keyset B. I = 2,048, fee = (100 + 0 + 999) div 1000 = 1, value = 2,047 ≥ 1,680. The value’s bit length is 11, so the outputs are 1, 2, 4, …, 1,024 sat at indices 0 to 10, 2,047 sat in total.
- Credential 3b has a value equal to
price(amount): four notes on keyset B, 1,024 + 512 + 128 + 16 = 1,680 sat, fee 0, value 1,680, with the same 11 outputs (bit length 11, total 2,047). No two-input credential can have this value: on keyset B alone the two notes would have to sum to 1,680, and with any input on keyset A the fee is 1 and they would have to sum to 1,681, but a sum of two powers of two has at most two bits set, and 1,680 (11010010000) has four, 1,681 five.
-
A funded credential for the notes route offer: a body with one input and a
fund, thefund_sig, the RailId of the quote’s payment hash, and the value. -
Value and fees: inputs on keysets with nonzero
input_fee_ppk, showing the rounding of 6.3. -
Settlement: for each of these outcomes, the
chargedandmetered_units, the selected indices, thescheme_resultbytes, and a Response carrying them, naming the notes route offer’s OfferID. All three run under the stub verifier of item 3: the charges of 340 and 1,680 sat below are discounted prices, and a gate whose verifier rejects the placeholder body never reaches settlement, because it answers both credentials UNDERPAID at admission.- Credential 3a, OK, metered: 850 tokens. ⌈850 × 500 / 1000⌉ = 425, price 425 − ⌊425 × 2,000 / 10,000⌋ = 425 − 85 = 340,
charged= min(340, 1,680) = 340. Change 2,047 − 340 = 1,707 = 1,024 + 512 + 128 + 32 + 8 + 2 + 1, soselect(7.2) signs indices 0, 1, 3, 5, 7, 9 and 10. Thescheme_resultis 752 bytes: one 106-byte entry for index 0 and six 107-byte entries, behind a 4-byte record header (7.3). - Credential 3a, FAILED:
charged0 andmetered_units0, as 7.1 sets for a FAILED outcome (P35); draft-00 8.1 requires the record in a metered offer but does not say what a FAILED action counts. Change 2,047, so all 11 indices, 0 to 10, are signed. Thescheme_resultis 1,180 bytes: 106 + 10 × 107 = 1,176 bytes of entries behind a 4-byte header. - Credential 3b, OK with charge equal to the value: 4,200 tokens. ⌈4,200 × 500 / 1000⌉ = 2,100, price 2,100 − 420 = 1,680,
charged= min(1,680, 1,680) = 1,680 = value. Change 0, so no output is signed,selectreturns the empty set, and the Response has no record 16 (7.3).
Both signed sets contain index 0, so the vectors fix its encoding (7.3).
- Credential 3a, OK, metered: 850 tokens. ⌈850 × 500 / 1000⌉ = 425, price 425 − ⌊425 × 2,000 / 10,000⌋ = 425 − 85 = 340,
-
Minting:
rk_mintfor a quote and two outputs (8.5). -
A reused output: a credential one of whose
B'already has a signed-output record under another source. It verifies, and its settlement signs thatB'again, with the sameC'and DLEQ proof when keyset and amount match, and adds a second record (6.2, 7.4). -
Negative vectors, each with the expected
scheme_code: inputs out of order; a repeatedK; two outputs with oneB'; an unknown odd record in the body; a record with an empty value; an input entry without itsCrecord and an output entry that is the empty stream00, eachNON_CANONICAL(3.5); a short keyset ID; a non-power-of-two amount; an incomplete ladder; a ladder whose total is below the value; fees above the inputs, under the fee offer of 18.1: one 1-sat input on keyset C and no outputs, so I = 1, F = 0 and fee = (2,000 + 999) div 1000 = 2 > I + F = 1 (BAD_AMOUNT, step 5 of 6.2);input_sigsof the wrong length;fundwithoutfund_sig; a witness signature over another Digest; a note whoseCdoes not verify; an output keyset that is not active. Every vector of this item but the fee vector uses the notes route offer.
19. Differences from the sources
19.1 Changes from the v0.4 scheme
Draft-00 wins over the v0.4 text and the overview in each of these places:
| v0.4 or overview | This document | Why |
|---|---|---|
| SPEND is a CBOR object | The notes body and witness are TLV streams inside the socket Credential (section 3) | Draft-00 5.1; DECISIONS row 31 |
Own Digest, H_"bnap/spend"(SigningBody), and SigningBody table | The socket Digest, which covers H(body) (draft-00 5.2) | The socket owns the Digest |
| Witness inside each input | All signatures in the witness record, apart from the body | Draft-00 5.1: the Digest covers bodies, never witnesses |
fund = {quote, signature} | fund in the body, fund_sig in the witness | Same reason |
CHALLENGE TLV with amount, unit, keysets, info, op_key_id, fund_quote | The socket Offer; keysets, issuer, limits and fund_quote are notes pay entry parameters (section 5) | Draft-00 section 4; the overview moves fund_quote into the notes offer |
| Self-issued challenges built by the client | Gone. Draft-00 4.4 accepts offers without mac only when published in advance under a key the gate holds | Draft-00 4.4 |
Own ack over Digest ‖ status ‖ amount ‖ signatures | The socket Response and its ack (draft-00 8.1); the signatures travel in scheme_result (7.3) | The socket owns receipts |
| Replay lookup by Y with 16-byte Digest tags | The socket’s ReplayKey and outcome record (draft-00 6.3, 6.4) | The socket owns replay |
| Amount rule: inputs + quote - fees - outputs = amount, exactly | value ≥ price (checked by the socket) and change of exactly value - charged at settlement | Draft-00 7: the charge is known only at settlement in metered offers |
Separate outputs (change) and refund_outputs | One list of outputs forming a complete ladder that covers the value; a deterministic subset is signed (6.3, 7.2) (provisional) | A charge fixed at settlement needs change of any size |
A blinded output signed before is rejected with OUTPUT_REUSED | Not checked by verification; a B' signed before is signed again at settlement, and the code is reserved (6.2, 7.4, 13.1) (provisional) | Draft-00 6.4: a replay of a committed credential must reach the replay path, which a use check in verify would block |
| Short keyset ID allowed on the wire | Full 33-byte keyset ID only (provisional) | One encoding; no ambiguous short IDs |
| A suite tag on every input; suites 1 to 3 reserved | No suite tag. A different construction gets its own scheme ID, as ACT does in the overview’s catalogue (provisional) | Draft-00 identifies schemes by ID |
Authorization: BNAP or BNAP-Authorization; Authentication-Info | BNAP-Credential and BNAP-Receipt (draft-00 10.2) | Draft-00 10.2 |
| HTTP scope over a normalized URI and Content-Digest | The HTTP carrier’s action scope (draft-00 10.1) | Draft-00 10.1 |
| Agents and native clients MUST bind to the TLS exporter | A wallet SHOULD bind whenever it can read its connection’s tls-exporter value, and MAY retry without a binding after a BINDING error (section 15) (provisional) | Draft-00 5.3: a gate behind a TLS-terminating front MUST reject bound credentials, so a client that always binds could not pay it |
Swap scope H_"bnap/scope/swap"(origin) at the info endpoint’s swap URL | A price-0 manifest route (section 11) (provisional) | Draft-00 has no scheme-defined scopes |
A bnap section in the NUT-06 info endpoint with prices, limits, max_fund, replay_window_s, op_keys, self_issued, swap | The manifest (draft-00 9.1) for keys, limits and routes; offer parameters for the notes limits and max_fund | Draft-00 section 9 |
| Error codes of the SPEND, error responses SHOULD be signed | Socket codes plus bnap:notes/… scheme codes; errors are unsigned (section 13) | Draft-00 8.2 |
| FAILED returns HTTP 502 | Not defined here: draft-00 does not give an HTTP status for a FAILED outcome, and the carrier, not the scheme, would | Draft-00 10.2 |
| Spend store: 16-byte prefix of Y with a 16-byte Digest tag | The socket’s use store with full UseIds, plus the scheme’s signed-output table (7.4) | Draft-00 6.3 |
Restore stops after 3 × max_outputs unsigned counters | NUT-13’s three empty batches of 100, plus a recorded counter in the backup (12.2) (provisional) | NUT-13; ladders can leave long gaps |
| DLEQ nonce must be deterministic (BNAP’s own rule) | NUT-12’s deterministic nonce, made a MUST (7.4) | NUT-12 now defines one |
19.2 Readings of draft-00’s own text
In these places draft-00, read literally, contradicts itself or leaves its own encoding rules open, and this document follows the rule named in the last column. Each is proposed as a draft-00 erratum.
| Draft-00 says | This document | Why |
|---|---|---|
Section 2: tu64 writes 0 as the empty string, and an absent record and a present one with an empty value MUST mean the same. The canonical rule of 5.1 covers bodies, not a scheme’s scheme_result | The index of output 0 is a present record with an empty value, 00 00; a client reads an entry without index as index 0 (7.3) (provisional) | Without one form, two gates send different bytes for the same settlement, although the ack signs them (draft-00 8.1), settlement must be deterministic (draft-00 6.4) and the vectors of 18.2 item 6 are byte-exact. Draft-00’s own vectors write the Required status 0 as a present empty record |
Section 2: a non-minimal BigSize or tu64 is MALFORMED (2, HTTP 400) | Inside the notes body and witness it is BAD_CREDENTIAL (10, HTTP 402) with bnap:notes/NON_CANONICAL; MALFORMED stays for the socket streams (2.1, 3.5, 13.1) (provisional) | The body and witness are opaque to the socket (draft-00 section 2), only the scheme parses them, and draft-00 5.1 makes rejecting alternative encodings of a body the scheme’s job. BAD_CREDENTIAL is the code for a scheme’s rejection (draft-00 8.2), and its scheme_code tells the client which encoder to fix |
Section 11, the bnap:notes registry example: rail_refs = "none at spend; the funding payment at minting, confirmed by the rail", witness = "one BIP-340 signature per input, over the Digest" | rail_refs names kind ln, the invoice payment hash, at spend for a redeemed fund quote as well as at minting; witness adds the fund quote’s NUT-20 signature (section 14) | Draft-00 6.1: a scheme MUST return a rail reference for every payment its credential relies on, and a funded credential relies on the quote’s payment. Draft-00 5.1: signatures over the Digest go in the witness. The example predates funding inside a credential |
Section 9.1: a route listed with "scope": "route" “gets route-scope offers for its class S and A schemes, and exact-scope offers for the rest”; 10.3 gives the chat route route-scope notes offers | On such a route, a gate MAY also send a per-request exact-scope bnap:notes offer that carries a fund_quote, next to the route-scope offer (5.4, 8.2, Appendix A) (provisional) | Draft-00 4.3 allows class S schemes in exact-scope offers. A fund quote is made for one client, so it cannot sit in a route-scope offer, which is the same for every client (draft-00 5.1). Read as the default offer set, 9.1 keeps the route-scope offer for every request; read as a ban, it would rule out funding inside the 402 on every route-scope route, the chat route of 10.3 included |
Appendix A. HTTP example (informative)
A first-time agent funds and calls the metered route of draft-00 10.3 in one round trip after its first request. Byte strings are elided.
POST /v1/chat/completions HTTP/1.1
Host: api.example
Content-Type: application/json
BNAP-Fund: amount=2100, pubkey="02c4…"
{"model":"m-1","messages":[{"role":"user","content":"hi"}],"max_tokens":256}
HTTP/1.1 402 Payment Required
WWW-Authenticate: BNAP offer="…" route scope: bnap:notes up to 2,100 sat, 500 sat per 1,000 tokens
WWW-Authenticate: BNAP offer="…" exact scope, metered, expires in 10 minutes: bnap:notes up to
2,100 sat at 500 sat per 1,000 tokens, with fund_quote {quote,
request = "lnbc21u1…", amount 2100, expiry, pubkey 02c4…}
BNAP-Error: AAEBAgEQ (REQUIRED)
(the agent checks the quote's key and amount, saves quote, key and offer, and pays through NWC)
POST /v1/chat/completions HTTP/1.1
Host: api.example
Content-Type: application/json
BNAP-Credential: … exact offer inline; pay = bnap:notes, body {fund = quote ID,
outputs = ladder 1 … 2048}, witness {fund_sig}; binding
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: private, no-store
data: {"choices":[{"delta":{"content":"Hello"}}]}
…
event: bnap-receipt
data: … OK, 850 tokens; charged 425 sat; scheme_result signs 1,675 sat of change
The manifest lists chat with "scope": "route", so the route-scope offer is the route’s default; the exact offer is the per-request offer that 5.4 lets such a route add to carry the quote (provisional reading of draft-00 9.1, 19.2). The exact offer lives 10 minutes. With the default pending_timeout of 300 s, it admits new credentials for its first 4 minutes (5.4), and the quote’s invoice expires at that cutoff (8.2); a client that sends later gets STALE, consumes nothing, and redeems the paid quote with the route offer. With no attestation, the 850 tokens cost ⌈850 × 500 / 1000⌉ = 425 sat. The value is the quote’s 2,100 sat, so the change is 1,675 sat, signed as the ladder outputs of 1,024, 512, 128, 8, 2 and 1 sat. Every later call spends those notes against the cached route offer, sending offer_id.
Appendix B. Provisional decisions
Each of these, except the withdrawn P8, is marked “(provisional)” where it is made, and each is open to owner review. P1 to P31 are numbered in the order of the sections that make them. From P32 on, a number is never reassigned: each later decision takes the next free number, wherever it is made, so that references to earlier numbers stay valid. The rows of 19.1 that say “(provisional)” restate P4, P11, P12, P26, P28 and P30, except the one for P31, which 19.1 makes; the rows of 19.2 that say “(provisional)” restate P32, P33 and P34.
| # | Where | Decision |
|---|---|---|
| P1 | 3.3 | Clients SHOULD list outputs in ascending amount order |
| P2 | 3.5 | Unknown records inside a notes body or witness are rejected even when odd; GREASE belongs in socket streams |
| P3 | 3.6, 5.1 | max_inputs and max_outputs default to 16 |
| P4 | 4.2 | Full 33-byte keyset IDs only, never the short ID |
| P5 | 4.2 | Every BNAP keyset publishes a final_expiry |
| P6 | 5.1 | Notes parameters follow the even/odd rule |
| P7 | 5.4 | fund_quote only in exact-scope offers, and never on routes requiring link-payment = unlinkable |
| P8 | 5.4 | Withdrawn: it let gates cache exact-scope offers that carry a fund_quote, which draft-00 5.1 rules out. 5.4 now follows draft-00, and the number stays reserved |
| P9 | 5.4, 7.5, 8.2, 15 | Admission cutoff: no new notes commit later than the deadline minus pending_timeout minus 60 s, checked after the replay lookup; offers that list notes admit for at least 60 s; fund quotes expire by the cutoff; wallets replay before the deadline |
| P10 | 6.2 | Verification does not check whether a fund quote has issued; the RailId commit does, so replays of funded credentials work |
| P11 | 6.2, 13.1 | Verification reads no use state; v0.4’s check that no output was signed before is dropped, and bnap:notes/OUTPUT_REUSED is reserved |
| P12 | 6.3, 7.2 | One output list, a complete power-of-two ladder covering the value, replaces v0.4’s separate change and refund lists; settlement signs a greedy subset worth exactly value - charged |
| P13 | 6.3 | Wallets SHOULD send the canonical ladder 1, 2, …, 2^(b-1) |
| P14 | 6.4 | A rail that does not confirm a fund payment gives BAD_CREDENTIAL with QUOTE_UNPAID |
| P15 | 6.4 | Outputs and quote IDs yield no local ID; the RailId guards the quote |
| P16 | 7.1 | A FAILED outcome has charged 0 and refunds the whole value |
| P17 | 7.3 | No scheme_result when the change is 0 |
| P18 | 7.4 | The signed-output table is keyed by (B', source), the source being H(body) or rk_mint, and keeps every record |
| P19 | 7.4 | Settlement signs a B' that was signed before instead of failing, and adds its own record |
| P20 | 7.5 | The refund of a timed-out outcome is computed when the client replays the credential |
| P21 | 8 | A body’s fund redeems BOLT11 (NUT-23) quotes only; BOLT12 credit is minted through NUT-25 |
| P22 | 8.1 | The BNAP-Fund request field, an RFC 9651 Dictionary, needs registering |
| P23 | 8.2 | One quote per offer nonce and a per-address rate limit are MUST, as DECISIONS row 24 sets for natives |
| P24 | 8.5 | BNAP quotes require a NUT-20 key; NUT-04 minting commits the RailId under rk_mint in one transaction |
| P25 | 8.6 | A quote record lives as long as its RailId record, and both, with the rail’s confirmation of the payment, last at least until 60 s after the deadline of the entry under which a credential committed the RailId, so replays of funded credentials still verify |
| P26 | 11 | Swaps use a price-0 manifest route with id notes-swap, answered by the gate |
| P27 | 12.1 | Derivation type 0x02 for note keys; the counter’s top 16 bits name the slot |
| P28 | 12.2 | Wallets record their counters in the backup; restore scans at least that far |
| P29 | 13.2 | UNIT_MISMATCH folds into UNKNOWN_KEYSET |
| P30 | 15 | Wallets SHOULD bind whenever they can read tls-exporter, where v0.4 said agents and native clients MUST; a wallet may retry without a binding after a BINDING error |
| P31 | 19.1 | Suites 1 to 3 become their own scheme IDs |
| P32 | 7.3 | Required records of scheme_result are written even when empty, so the index of output 0 is a present empty record; optional empty records are left out; a client reads an absent index as 0 |
| P33 | 2.1 | A non-minimal BigSize or tu64 inside a notes body or witness is BAD_CREDENTIAL with bnap:notes/NON_CANONICAL, not the socket MALFORMED that draft-00 section 2 names |
| P34 | 5.4, 8.2 | On a route listed with "scope": "route", a gate MAY also send a per-request exact-scope notes offer that carries a fund_quote; draft-00 9.1 is read as the route’s default offer set |
| P35 | 7.1 | A FAILED Response to a metered offer reports metered_units 0, which draft-00 8.1 leaves open; the Response vectors of 18.2 item 6 use it |
| P36 | 5.4, 8.4 | On a route whose require sets link-payment = unlinkable, the notes pay entry carries no max_fund, and the gate rejects a credential that redeems a fund quote with POLICY after verification and before commit |
| P37 | 18.2 | The notes vectors replace the httpsig:web-bot-auth verifier with a stub that reports the placeholder attest body of draft-00 section 14 verified, with the local ID SHA-256(bot key thumbprint), so that the 20% discount applies; a real verifier rejects that body, and both credentials of item 3 are then UNDERPAID |
| P38 | 4.2, 16 | A withdrawal extension pays out only through NUT-05 melts in the melt context of the key-secret proofs NUT, witnessed by every input’s K; the issuer lists melt in its key-secret contexts only then, and otherwise leaves the list empty |
BNAP: an admission layer for priced actions — Draft Specification v0.4
2026-10-10 · Dominic
Since v0.5 this tab is the detailed specification of one credential scheme, bnap:notes, the default scheme. Where it differs from the v0.5 socket, v0.5 wins: its CHALLENGE is the socket’s Offer, its witnesses sign the socket’s Digest, and commit, replay and receipts are owned by the socket.
Abstract and status
BNAP lets an online service put a small price, in sats, on an action such as an API call, a post or a signup, and check payment offline without learning who paid. It is a Cashu profile: the service runs its own Cashu-style issuer, sells prepaid credit over Lightning, and accepts that credit back as Notes, each bound to one specific request. BNAP aims to be a small, stable admission layer that many carriers and value rails can share without changes to the core.
Terminology. BNAP (“bee-nap”, Blind-Note Admission Protocol) is the protocol; Caparra is its Rust reference implementation. A slash means “funded over”, with the lower layer on the right; a plus means “carried in”.
| Written | Read as |
|---|---|
| BNAP/LN | BNAP credit funded over Lightning |
| BNAP/Ark | BNAP credit funded over Ark (experimental) |
| HTTP+BNAP | HTTP carrying BNAP in its authentication slot |
| HTTP+BNAP/LN | HTTP with BNAP, funded over Lightning |
| SASL+BNAP | Mail, chat or MQTT carrying BNAP through SASL |
| BNAP-W, BNAP-B | The withdrawal and deposit extensions |
Status. Pre-draft, version 0.4. A third expert round checked v0.3 against what Cashu, Lightning and the IETF have shipped by October 2026: an ecash cryptographer, an anonymous-credentials expert, a Lightning engineer and a Bitcoin protocol reviewer. Earlier versions stay in the archive tabs. Key words follow BCP 14 (RFC 2119, RFC 8174).
| Owner decision | Choice |
|---|---|
| Name | BNAP |
| Framing | A layered spec with modest framing; the TCP/IP analogies live in Appendix A |
| Ecosystem | A Cashu profile: Notes are valid Cashu proofs, and buying credit is Cashu’s Lightning mint flow |
| Withdrawal | An optional extension, BNAP-W, off by default |
| First deployment | AI agents and paid APIs |
What round 3 changed.
- Notes reuse Cashu verbatim: the secret is the hex of the note key, keyset IDs are Cashu’s version-2 IDs, and DLEQ proofs follow NUT-12.
- Wallets recover from a seed (NUT-13 derivation); in v0.3 a lost device meant lost funds.
- Credit is bought through Cashu’s Lightning mint flow (NUT-23, NUT-04, NUT-20), and a first-time agent can fund and act in one round trip.
- Refunds by operator signature (ack-refund) are gone: a failed action returns refund outputs the client supplied, signed in the same response.
- A spend costs about 300 bytes on disk, including the signed outputs Cashu restore needs, and replies to retries are recomputed rather than stored.
- Agents with no BNAP code can still pay per action through L402 or MPP challenges in the same 402 response.
Why BNAP exists
BNAP removes actions that are only profitable at a price of zero: agents hammering free APIs, scraping, slop posts, spam and bulk signups. An automated action stops paying the moment its price exceeds its value to the actor:
p > v_{action}
The price is per action, not per actor, so splitting into many identities saves nothing. BNAP does not tell humans from machines; it makes each action cost something, and only low-value actions disappear.
In plain terms, BNAP is prepaid site credit, priced in sats, issued and redeemed by one site, never transferable between sites, and withdrawable only where the operator opts in. Each spend is bound to one request and cannot be linked to the purchase that funded it.
| Existing scheme | What it does | What BNAP adds |
|---|---|---|
| L402 (Lightning Labs) | One Lightning payment per request, proved by preimage and macaroon | Pay once, spend offline many times; spends unlinkable to the payment |
| x402, MPP | Stablecoin or card payment per request | Bitcoin-native credit with no facilitator, unlinkable spends |
| Cashu NUT-22 blind auth | Single-use ecash tokens for access | A price, change, receipts, and theft resistance by binding each note to its request |
| Cashu NUT-24 | HTTP 402 paid with ecash | A self-issued mint, request binding and receipts; NUT-24 uses an X-Cashu header with NUT-18 payment requests and lists no implementations yet |
| Privacy Pass (RFC 9576–9578) | Unlinkable tokens from an attester | Value and change, funded by payment instead of attestation |
Non-goals: proving personhood; stopping well-funded attackers or volumetric floods; replacing the operator’s abuse classifier; being a token or a payment network.
Design principles
Eight principles decide every trade-off; the first is new in v0.4.
- Reuse before inventing. Every object Cashu, Lightning or the IETF already defines is used verbatim. BNAP adds only the request binding, the price rules and the receipt.
- One small core. Many value rails below, many carriers above, one object in the middle: the Note.
- End to end. Abuse is judged by the application; BNAP only makes a price verifiable.
- No per-client state. The server stores records about notes, never about people.
- Pay rarely, spend offline. Money moves when credit is bought; each action is a local check.
- The relying party is its own issuer. A service accepts only notes it signed, so there are no allowlists and no fake mints.
- Sign everything once. Each note key signs a hash of the whole spend, outputs included.
- It’s OK to be odd. An unknown even extension is rejected, an unknown odd one is ignored, and clients send GREASE so the rule never rusts.
Architecture (informative)
BNAP admits one action at one origin when the request carries unspent notes issued by that origin, worth the posted price and signed over the whole request, checked offline and without learning who paid.
Figure: HTTP+BNAP/LN agent flow · tier 1. The source tab shows this as an interactive diagram (widget
node/83a7fbbc-e1ed); it is not part of this export. See the source doc.
Only the first request touches Lightning, and only it is linkable to the payment; every later request is a local check at the gateway.
BNAP lives in the authentication slot of existing protocols, as SASL mechanisms and HTTP authentication schemes do, because it must know which request is an action and what it costs. It needs no new URI scheme and no TLS change.
| Layer | Contents | Responsible for |
|---|---|---|
| Policy | Prices, abuse judgement, sessions, deposits | Everything that needs application meaning |
| Carrier | HTTP authentication scheme (first), SASL, Nostr | Putting the bytes into a protocol slot |
| Admission | BNAP: Notes and three verbs | Proving issuer-scoped value was spent once, for this request |
| Value | Lightning through Cashu mint quotes; Ark and Cashu tokens experimental | Moving value |
| Settlement | Bitcoin | Final settlement |
What BNAP does not do:
- A valid spend is necessary, not sufficient; the operator may still queue or refuse.
- It moves no money; value rails do that.
- It sets no prices; it carries the operator’s quote.
- It identifies nobody and judges no abuse.
- It provides no confidentiality; TLS does.
- It does not stop volumetric floods or attackers who earn more per action than they pay.
Notes: the Cashu profile
A Note is a Cashu proof whose secret is the hex encoding of a public key K, so only the holder of K’s private key can spend it. Everything else about the Note is Cashu as the NUTs specify it.
k_n, r = NUT-13 version-2 derivation (seed, full keyset ID, counter, type 0x02)
K = x-only public key of k_n (BIP-340)
secret = lowercase hex of K, 64 characters
Y = hash_to_curve(UTF-8 secret) NUT-00
B' = Y + r·G sent at purchase or as change
C' = k_a·B' with a NUT-12 DLEQ proof returned by the issuer
C = C' − r·A unblinded signature
Proof = {id, amount, secret, C} a valid Cashu proof
witness = BIP-340 signature by k_n over the spend Digest
Cashu pieces reused verbatim
| Piece | NUT | Use in BNAP |
|---|---|---|
| Blind signatures and hash_to_curve | 00 | Note math and test vectors |
| Keysets, version-2 IDs, final expiry, input fees | 01, 02 | Keyset identity, rotation and fees |
| DLEQ proofs with deterministic nonce | 12 | Purchase and change proofs; the deterministic nonce is a MUST in BNAP |
| Deterministic secrets | 13 | Note keys, blinding factors, recovery |
| Restore and state check | 09, 07 | Recovery and multi-device checks |
| Lightning mint quotes with signed locks | 04, 20, 23, 25 | Buying credit |
| Info endpoint | 06 | Discovery and prices |
| V4 token (cashuB) | 00 | Wallet storage format; notes are not transferable, since spending also needs K’s private key |
Rules beyond Cashu
- Spends need the key. A BNAP issuer MUST accept its notes only through a BNAP SPEND with a valid witness, never through a plain Cashu swap or melt, because hex(K) would otherwise read as an unlocked secret.
- Key isolation. BNAP keysets MUST NOT be served by a mint that allows plain swaps or melts: NUT-03 swap and NUT-05 melt are disabled for them (BNAP-W melts carry witnesses), and BNAP keys never appear in any other Cashu keyset.
- Keyset IDs. The wire may carry the 8-byte short ID; the Digest and all derivation use the full version-2 ID, and an ambiguous short ID is rejected.
- Outputs. Every B’ must be a valid point other than infinity, and a B’ signed before is rejected unless a retry reproduces the original response.
- No signature aggregation. One BIP-340 signature per K; batch verification is fine. Purchase never proves knowledge of K’s private key, so a summed key could steal someone else’s note.
- Consistent keys. Operators publish a keyset at least 30 days before issuing from it, and clients compare full key lists from a second vantage point to catch per-user tagging keys. A standby keyset is always pre-published, so a compromised keyset can be replaced at once.
- Denominations. Amounts are powers of two; each K is used at one origin, once.
Recovery
A wallet holds one BIP-39 seed. Note keys and blinding factors come from NUT-13 version-2 derivation with derivation type 0x02 (to be requested from the Cashu maintainers), and the high 16 bits of the counter name a device or agent slot, so two devices never collide. A wallet restores by asking the issuer to re-sign its blinded outputs (NUT-09) and checking which notes are spent (NUT-07); because every priced spend leaves unsigned refund outputs, restore scans each device slot recorded in the backup until 3 × max_outputs consecutive counters come back unsigned. Issuers MUST keep every signed blinded output with its signature until the keyset’s final expiry, as NUT-09 requires. The wallet’s list of sites is backed up encrypted, as Cashu wallets back up their mint lists.
Suites
Inputs carry a suite tag. Suite 0 is this Cashu profile and is mandatory. Three suites are reserved, each defining its own value rules:
| Suite | Construction | Why |
|---|---|---|
| 1 | RFC 9497 VOPRF with RFC 9380 hash-to-curve | A form the IETF can standardize |
| 2 | Anonymous Credit Tokens (ACT) | One credential holds a hidden balance, removing power-of-two change |
| 3 | Cashu KVAC research | Amount-hiding ecash |
The protocol: three verbs
CHALLENGE and SPEND run on every priced action; ISSUE runs rarely and is Cashu’s mint flow.
Encoding
Objects travel as CBOR, the encoding of Cashu’s V4 tokens, and as base64url on text carriers. The bytes that get signed are a deterministic binary SigningBody built from those objects: big-endian integers, BigSize lengths and counts, and TLV streams in strictly ascending type order with no duplicates (Lightning BOLT 1). Hashes are BIP-340 tagged hashes: H_tag(x) = SHA-256(SHA-256(tag) ‖ SHA-256(tag) ‖ x).
CHALLENGE (server to client)
A challenge is a TLV stream that the server MACs, so it keeps no state between CHALLENGE and SPEND.
| Type | Field | Meaning |
|---|---|---|
| 0 | version | Protocol version, 1 |
| 2 | scope | 32-byte hash of the action; each carrier defines it to cover the action’s content and its recipient |
| 4 | amount | The price, in the keyset unit |
| 6 | unit | Unit string, normally sat |
| 8 | keysets | Accepted keyset IDs |
| 10 | expires | Unix time after which the quote lapses |
| 12 | info | URI of the issuer’s info endpoint |
| 14 | op_key_id | Which operator key signs acknowledgements |
| 16 | nonce | 16 random bytes |
| 18 | mac | Server MAC over every other record |
| 20 | issued_at | Self-issued challenges only: creation time |
| 22 | fund_quote | Optional: a NUT-23 mint quote id the client asked for |
Self-issued challenges. Carriers without a round trip, such as Nostr events, let a client build a challenge itself with version, scope, amount, unit, keysets, its own nonce and issued_at, and no mac, where the manifest allows it. The operator accepts it if amount is at least its published price and the age is within that carrier’s max_age_s.
SPEND (client to server)
SPEND = {v, challenge, binding, inputs, outputs, refund_outputs, fund?, ext} (CBOR)
input = {suite, s_id, amount, secret, C, witness} a Note with its witness
output = {s_id, amount, B'} a Cashu blinded message
fund = {quote, signature} a paid NUT-23 quote
s_id = first 8 bytes of the version-2 keyset ID, as in Cashu V4 tokens
Digest = H_"bnap/spend"(SigningBody) byte layout below
witness = BIP-340 signature by each K over Digest
fund.signature = BIP-340 signature over Digest by the quote's NUT-20 key (a BNAP use of that key)
Response = {status, replayed, signatures, ack}
status = OK or FAILED; replayed = true when answering a retry
signatures = C' and NUT-12 DLEQ proof for each signed output, in order
ack = BIP-340 signature by the operator key over
H_"bnap/ack"(Digest ‖ status ‖ amount ‖ signatures)
| SigningBody field | Bytes |
|---|---|
| version | 1 |
| challenge | BigSize length, then the challenge TLV bytes as received |
| binding | BigSize length (0 or 32), then the bytes |
| inputs | BigSize count; per input: suite (1) ‖ full keyset ID (33) ‖ amount (8, big-endian) ‖ K (32) ‖ C (33, compressed); sorted by K |
| outputs | BigSize count; per output: full keyset ID (33) ‖ amount (8) ‖ B’ (33); in client order |
| refund_outputs | Same layout as outputs |
| fund | BigSize length, then the UTF-8 quote id; length 0 when absent |
| ext | TLV stream built from the ext map, in ascending type order |
Amount rule. Inputs + paid quote − input fees − outputs = amount, exactly. Input fees follow the keysets’ input_fee_ppk (NUT-02); a fund quote pays no input fee. Whenever amount > 0, refund_outputs MUST be present and sum to exactly amount, so its presence never fingerprints a wallet. A SPEND MUST contain at least one input or a fund, and max_outputs counts outputs and refund outputs together.
The server checks a SPEND cheapest first:
- Parse. Version, total size,
max_inputsandmax_outputs; at least one input or a fund. - Replay lookup. If every input’s Y, and the quote if any, is recorded with this Digest’s tag and the record is inside the manifest’s
replay_window_s: re-verify every witness over the full Digest, then answer with the recorded status andreplayed = true, or withPENDINGif the action is still running. The carrier MUST NOT perform the action again. Replays are rate-limited per client network. If any Y is recorded with a different tag, or the window has passed, fail withSPENT. - Challenge. The MAC is valid and unexpired, or the self-issued fields are acceptable; the scope and the binding match this request.
- Keysets and outputs. Every keyset is known, inside its final expiry and in the challenge’s unit; every amount exists in its keyset; no K or B’ repeats; no B’ was signed before (
OUTPUT_REUSED). Outputs and refund outputs use keysets that are still issuing. - Amount. The amount rule holds, with checked arithmetic.
- Signatures. Every Note satisfies C = k·hash_to_curve(secret); every witness covers the Digest; a fund quote is paid, not yet issued, and its key signed the Digest.
- Commit. In one atomic step, record every Y and the quote with the Digest tag and status PENDING, and mark the quote ISSUED so NUT-04 refuses it. Then run the action.
Outcomes.
- OK: the server moves PENDING to OK and signs
outputs, the change. - FAILED: the action failed after commit because of a server-side fault. The server moves PENDING to FAILED and signs
outputsandrefund_outputs, returning inputs + quote − fees in full. Servers MUST fully validate an action before commit, so FAILED is reserved for their own faults. - Errors before commit spend nothing, as in Cashu NUT-22.
The status changes exactly once, from PENDING to OK or FAILED, so a replay can never flip the outcome. Signing is deterministic: BNAP makes NUT-12’s deterministic DLEQ nonce a MUST, and BIP-340 signatures use zero auxiliary randomness, so replies are recomputed instead of stored. The ack is the receipt: one 64-byte operator signature over what was paid, for which action, and what came back. Binding is empty, or the RFC 9266 tls-exporter value; agents and native clients MUST bind, while browsers cannot.
Linkability. A SPEND that carries fund links this one action to the Lightning payment; every note issued as change is unlinkable. Wallets that need full unlinkability buy credit in a separate ISSUE first.
ISSUE, swap and restore
- ISSUE is Cashu’s mint flow: a NUT-23 BOLT11 quote locked to a client key (NUT-20), paid, then minted with NUT-04; BOLT12 through NUT-25 is optional. A paid quote can instead be redeemed inside a SPEND as
fund; the commit marks it ISSUED, and NUT-04 refuses it afterwards. If that SPEND fails before commit, the quote stays mintable through NUT-04. - Swap. Re-denominating notes is a SPEND with amount 0 sent to the info endpoint’s swap URL, with scope = H_“bnap/scope/swap”(origin). A plain Cashu swap is never accepted, because spends need witnesses.
- Restore uses NUT-09, and state checks use NUT-07, both keyed by Y.
Errors
| Code | Meaning | Client action |
|---|---|---|
VERSION | Unsupported version | Use one from the manifest |
MALFORMED | Bad encoding, an unknown even TLV, or no input and no fund | Fix and resend |
TOO_LARGE | Over a size, input or output limit | Swap into fewer notes first |
UNKNOWN_KEYSET | Keyset unknown, expired or revoked | Refresh keysets, swap notes |
UNIT_MISMATCH | A note or output is in another unit | Use the challenge’s unit |
BAD_CHALLENGE | MAC invalid, or self-issued not allowed | Get a challenge from the server |
STALE | Challenge expired or too old | Get a new challenge |
WRONG_SCOPE | Challenge or binding is for another action | Get the right challenge |
WRONG_AMOUNT | The amount rule fails | Fix change or refund outputs |
BAD_SIGNATURE | A note, witness or quote signature failed | Drop the bad input |
OUTPUT_REUSED | A blinded output was signed before | Derive fresh outputs |
SPENT | A note was spent in another SPEND, or the replay window passed | Drop that note, resend the rest |
QUOTE_UNPAID | The fund quote is not paid, or already issued | Pay it, or drop it |
PENDING | The original spend is still running | Retry after Retry-After |
RATE_LIMITED | Too many replays or open quotes | Retry later |
Error responses SHOULD carry an operator signature over the Digest and the code.
Discovery
The issuer’s Cashu info endpoint (NUT-06) carries a bnap section, and /.well-known/bnap points to it. Keysets come from the standard Cashu keyset endpoints.
"bnap": {
"versions": [1],
"prices": { "table_version": 7,
"POST /v1/chat": { "sat": 21, "ref": "EUR 0.012" },
"POST /v1/search": { "sat": 4 } },
"limits": { "max_inputs": 16, "max_outputs": 16, "max_bytes": 5000 },
"max_fund": 10000,
"replay_window_s": 86400,
"op_keys": { "k1": "02ab…" },
"self_issued": { "allowed": false },
"swap": "/v1/bnap/swap",
"withdrawal": false
}
A 5,000-byte SPEND is about 6.7 KB in base64url, under common 8 KB header limits.
State and storage
BNAP keeps no per-client state. For each spent note the server stores a 16-byte prefix of Y with a 16-byte Digest tag and a status byte, and the same for redeemed quotes. As NUT-09 requires, it also stores every signed blinded output with its signature, which enforces the no-reuse rule as well. A typical spend with two change outputs takes about 300 bytes on disk.
| Deployment | Spends per year | Storage per year |
|---|---|---|
| Mid-size API, 20,000 spends a day | 7.3 million | about 2.2 GB |
| Large service, 5 million spends a day | 1.8 billion | about 550 GB |
Keysets issue for about 3 months and expire about 15 months after they start, so storage is bounded by spend rate times 15 months. A spend record is deleted when its keyset expires, and an expired keyset is never accepted again. If the spend store is lost, notes spent since the last durable copy can be spent again until expiry; without withdrawal that costs only free actions, never bitcoin. The issuer key lives in a separate signer process, derived from a cold seed, and a standby keyset is always pre-published so a compromised keyset can be revoked and replaced at once.
Extensibility
The TLV stream at the end of a SPEND carries extensions. An unknown even type is rejected with MALFORMED; an unknown odd type is ignored. Clients SHOULD send one GREASE record per SPEND, a random type from the reserved odd range with a 4-byte random value; servers MUST ignore it and MUST NOT require it. The version, suite and TLV registries each reserve GREASE values.
HTTP binding and the agent profile
The first deployment is AI agents and paid APIs: a gateway in front of an API, and a client any agent can call. One 402 response offers BNAP next to per-action Lightning schemes, so agents with no BNAP code can still pay.
POST /v1/chat HTTP/1.1
Host: api.example
Content-Digest: sha-256=:X48E9q…:
BNAP-Fund: amount=2100, pubkey=02c4…
HTTP/1.1 402 Payment Required
WWW-Authenticate: BNAP challenge="<base64url TLV>", amount="21", unit="sat",
fund-quote="<base64url NUT-23 quote>", info="/v1/info"
WWW-Authenticate: L402 macaroon="…", invoice="lnbc210n1…"
WWW-Authenticate: Payment id="…", method="lightning", intent="charge", request="…"
(the agent checks the quote's key and amount, saves it, then pays through NWC)
POST /v1/chat HTTP/1.1
Host: api.example
Content-Digest: sha-256=:X48E9q…:
Authorization: BNAP <base64url SPEND with fund, outputs, refund_outputs>
HTTP/1.1 200 OK
Authentication-Info: bnap="<base64url Response>"
Rules
- Scope. scope = H_“bnap/scope/http”(method ‖ 0x00 ‖ absolute URI normalized per RFC 3986 ‖ 0x00 ‖ the raw 32-byte SHA-256 digest from Content-Digest, empty when there is no body). A spend therefore names its origin and its exact content.
- Funding is opt-in and capped. The server embeds a NUT-23 quote only when the request carries
BNAP-Fundwith an amount and a client key, never above the manifest’smax_fund, and rate-limits open quotes per client network. A scraper gets a challenge, never an invoice. - Zero-code agents. An HTTP+BNAP server SHOULD also offer an L402 challenge or an MPP Lightning
chargechallenge for one action at the same price. The macaroon or MPP request MUST carry the scope hash, payment hashes are kept in their own namespace until the invoice expires, and a request carrying more than one payment scheme is rejected. Unlike common L402 use, these tokens admit exactly one action. - Credentials. The SPEND rides as token68 after
BNAP. IfAuthorizationalready carries application auth, the client sendsBNAP-Authorizationinstead.amount,unitandinfoin the challenge are readable copies that MUST match the encoded challenge. - Status codes. OK returns the action’s own status. FAILED returns 502 with all signatures in
Authentication-Info. PENDING returns 409 withRetry-After. A replay returns the stored action result if the gateway cached it, which it SHOULD for the replay window; otherwise 200 with an empty body andAuthentication-Info. - Errors come back as 402 with
BNAP-Errorand a fresh challenge. - Sessions are ordinary cookies a server MAY set after a successful SPEND.
- Binding. Agents and native clients MUST bind to the TLS exporter; browsers cannot and send an empty binding.
Agent tiers
| Tier | The agent has | How it pays | What links to the payment |
|---|---|---|---|
| 0 | Any L402 or MPP client, such as lnget | Lightning per action | Every action |
| 1 | The bnap CLI or MCP server and an NWC wallet connection | Funds once inside the 402, then spends notes offline | Only the first action |
| 2 | A full wallet with its own seed | Buys credit in a separate ISSUE, then spends notes | Nothing |
Agent wallets MUST:
- enforce spending caps per origin and per task in code, not in the model’s prompt;
- use a fresh key per quote, check that the quote’s key and invoice amount match what they sent, and never pay more than their
BNAP-Fundamount; - save the quote and its key before paying, so a failed SPEND can still mint the paid quote through NUT-04;
- keep per-site balances small (default cap 5,000 sat), because credit is the operator’s IOU.
Tools. bnap get <url> works like curl: it answers 402s, funds over NWC within budget, spends notes and retries. The MCP server exposes fetch_with_bnap and budget to any agent framework.
Value layer: buying credit
Buying credit is Cashu’s Lightning mint flow, used verbatim; BNAP defines no payment protocol of its own.
- Quote. The client asks for a NUT-23 quote locked to its key (NUT-20), or receives one inside a 402.
- Pay. Any Lightning wallet pays the BOLT11 invoice, including Cashu, Ark, Spark and Fedimint wallets, which can all pay BOLT11.
- Mint. The client mints notes with NUT-04, or redeems the paid quote inside its next SPEND as
fund.
BOLT11 is mandatory because the issuer is always online and every payer stack accepts it, including NWC, lnget, MPP and Cashu melt. BOLT12 through NUT-25 is optional, because not every Lightning implementation supports offers natively yet.
Operator receive backends
The wire fixes only BOLT11; the backend is the operator’s choice. Caparra defines one interface, InvoiceSource { create, status }.
| Backend | Custody | Effort | Fit |
|---|---|---|---|
| Receive-only NWC connection (Alby Hub or any NWC wallet) | Depends on the wallet | Paste one connection string | Caparra default; it can never be drained |
| LDK Node with an LSP | Self-custodial | Run a node library | The sovereign tier |
| Phoenixd | Self-custodial | One daemon; liquidity fees on the first receives | Small operators |
| Custodial API | Custodial | Easiest | Brings KYC |
Other value-in types. Ark VTXOs (bark has been on mainnet since June 2026) and Cashu tokens from operator-listed mints are experimental. Hashcash is dropped: proof-of-work favours attackers with GPUs.
Units. Notes are in sat. Prices may carry a fiat reference that the operator re-converts at each price-table version; the challenge amount is always in sat. The smallest top-up is max(4 × price, 21 sat), because routing a few hundred sats costs almost nothing while the operator’s inbound liquidity is the real cost.
Withdrawal extension (BNAP-W)
Withdrawal is optional and off by default. Without it, a stolen issuer key or a lost spend store yields only free actions, never bitcoin; with it, both become theft. An operator that enables BNAP-W MUST:
- write spend records synchronously to durable, replicated storage before answering;
- cap withdrawals per epoch and per request;
- pay out through a melt (NUT-05) that carries BNAP witnesses, to a Lightning invoice;
- keep the issuer key in a separate signer process.
Paying out to arbitrary invoices moves an operator from merchant toward money transmitter, so BNAP-W needs legal review in each jurisdiction.
Without BNAP-W, wallets cap each site’s balance, and operators announce shutdown or keyset retirement at least 30 days ahead through the keyset’s final expiry. Refunds at shutdown are a consumer-law matter outside the protocol.
Security and privacy considerations
Each threat found across three debate rounds and two reviews maps to one rule above; what remains is economic, legal or accepted on purpose.
| Threat | Closed by |
|---|---|
| Double spend | Each Y recorded atomically before the action runs |
| One payment replayed for many actions | A known Digest returns the recorded status; the action never runs again |
| Replays as denial of service | A replay window, rate limits, and at least one input or fund per SPEND |
| A spend reused with another body or at another origin | The scope covers content and origin; the Digest covers the scope |
| Note theft from logs, proxies or CDNs | A witness by K is required |
| Change or refund theft by a proxy | The Digest covers outputs, refund outputs and the quote |
| A fund quote redeemed twice | The commit marks it ISSUED; NUT-04 refuses it afterwards |
| A quote swapped by a proxy | Wallets check the quote’s key and amount before paying |
| Forged replay through a tag collision | 16-byte tags, and full re-verification before any replay |
| One payment admitting two actions across schemes | Scope inside the macaroon or MPP request; one scheme per request |
| Theft with a summed key | One signature per K, no aggregation |
| Redeeming a note through a plain Cashu swap | Swap and melt disabled for BNAP keysets |
| Paying a cheap challenge for a costly action | Scope inside the challenge MAC |
| Mixed units or overflow | One unit per spend, checked arithmetic |
| Bloating the store with free swaps | Per-keyset input fees |
| Admission minted by someone else | The operator accepts only its own keysets |
| Issuer linking a spend to a purchase | Blinding; DLEQ proofs are never presented |
| Per-user tagging keys | Keysets published 30 days ahead and cross-checked |
| Wallet fingerprinting | Powers of two and mandatory refund outputs |
| Lost device | Seed recovery through NUT-13, NUT-09 and NUT-07 |
| Invoice-minting denial of service | Funding only on BNAP-Fund, capped and rate-limited |
| Ossification | Even/odd TLV rule and GREASE |
Accepted residual risks:
- Linkable first action. A SPEND carrying
fund, and every tier-0 Lightning payment, is linkable to its payment. - Small operators can still link visitors by IP address, TLS fingerprint and timing; BNAP promises unlinkable notes, not anonymity.
- Operator credit. An operator that disappears takes unspent credit with it; wallets keep balances small.
- Store loss. Without BNAP-W, notes spent since the last durable copy can be spent again until keyset expiry, costing free actions only.
- Key compromise. A stolen issuer key forges notes, so the keyset is revoked and replaced at once; keys live in a separate signer process.
- Suite 0 is not post-quantum for forgery; blinding keeps past spends private.
- Unflagged abuse pays only the price; BNAP-B adds deposits where an operator can detect abuse.
- Regulation. Non-withdrawable prepaid credit for one’s own service reads closer to a merchant voucher than to custody; BNAP-W changes that. Both readings need EU counsel.
Extensions and roadmap
The core is Suite 0 notes, three verbs and the HTTP binding; everything else is a separate, optional document.
| Document | What it adds | Status |
|---|---|---|
| BNAP core | Suite 0 notes, CHALLENGE, SPEND, refund outputs | Mandatory |
| HTTP binding and agent profile | 402 challenge, content-covering scope, BNAP-Fund | Mandatory for HTTP servers |
| Buying credit | Cashu NUT-23, NUT-04 and NUT-20, referenced verbatim | Mandatory for issuers |
| L402 and MPP fallback | Per-action Lightning for zero-code agents | Recommended for HTTP |
| BNAP-W | Withdrawal | Optional, off by default |
| BNAP-B | Deposits with release or forfeit, forfeit log, claim rates | Experimental |
| Nostr NIP | Priced relay writes with self-issued challenges | Second deployment |
| SASL+BNAP, ESMTP | Mail, chat and MQTT carriers | Optional |
| Ark and Cashu-token value-in | Funding without Lightning | Experimental |
| Suites 1 to 3 | VOPRF, ACT, KVAC | Reserved |
| x402 scheme | Interop with x402 clients | Optional |
| Pricing and wallet guidance | Load-based prices, ceilings, budgets | Informational |
| TLS/QUIC admission | Pricing handshake floods | Deferred |
| Hashcash, ack-refund, sessions, linkable mode | — | Removed |
Reference implementation: Caparra
Caparra is built on CDK, the Cashu Development Kit, so the cryptography, keysets and mint flows are existing, reviewed code; Caparra adds only the BNAP profile and the agent tooling.
| Crate | Responsibility |
|---|---|
bnap | Profile types: SigningBody, Digest, witnesses, challenge TLVs, scope functions, test vectors |
caparra-issuer | A CDK mint configured as a BNAP issuer: spend store, deterministic replies, separate signer process |
caparra-gate | Reverse proxy and Tower middleware for paid APIs; emits BNAP, L402 and MPP challenges |
caparra-wallet | Agent wallet with seed recovery, budgets and NWC funding; the bnap CLI and MCP server |
caparra-ln | The InvoiceSource interface; receive-only NWC by default, LDK Node optional |
caparra-w, caparra-nostr, … | One crate per extension |
Build order: bnap with test vectors, then caparra-issuer, then caparra-gate in front of one real paid API, then the CLI and MCP server, then the NUT pull requests, then the Nostr plugin.
Targets to measure: p95 verification under 2 ms per spend at the default limits, excluding the durable commit, and about 300 bytes of storage per spend.
Standardization and adoption
No part of BNAP is a BIP or a bLIP: nothing touches Bitcoin consensus or the Lightning protocol. It ships as Cashu NUTs first, with running code, and goes to the IETF only after two independent implementations.
| Order | Document | Venue |
|---|---|---|
| 1 | NUT-XX “Key-secret proofs with context signatures” (~3 pages): secret = hex(K), witness over a tagged Digest, the new NUT-13 derivation type | Cashu NUTs; pitched as making NUT-22 blind-auth tokens theft-proof |
| 2 | NUT-XX “HTTP 402 with a self-issued mint”, extending NUT-24 and aligned with its X-Cashu header and NUT-18 payment requests: scope, spend, refund outputs, ack, funding in the 402 | Cashu NUTs |
| 3 | Agent interop note: BNAP, L402 and MPP in one 402 | Agent tooling communities |
| 4 | NIP-XX “Priced relay writes” | Nostr NIPs |
| 5 | Internet-Draft with Suite 1, after two implementations | IETF Privacy Pass or HTTP authentication |
Socialize in this order: the Cashu developer chat and call with a CDK branch and cashu-ts test vectors; the NUT pull requests; a Delving Bitcoin post asking for a privacy critique of request-bound blind spends; agent tooling builders; the IETF last.
Minimum lovable deployment. caparra-gate in front of one real paid API, such as a search or inference endpoint, with the bnap CLI and MCP server so agents fund over NWC.
| After 90 days, it worked if | Target |
|---|---|
| Operators other than the author | At least one |
| Funded agents that top up a second time | At least 30% |
| Abusive free-tier traffic | Down at least 80%, without losing paying users |
| p95 verification time | Under 2 ms |
| Independent implementations passing the test vectors | At least one, not in Rust |
Allies: CDK and Nutshell maintainers, Alby (NWC), OpenSats for grants, and agent-tooling builders. Lightning Labs and the x402 camp are bridge partners, not targets.
Licensing and governance. Code is MIT/Apache-2.0; NUT text follows the Cashu repository. A single maintainer runs public proposals until two independent implementations exist; no maintainer ever holds funds or runs a default issuer.
Appendix A: Rationale (informative)
BNAP is shaped like the protocols that lasted: one small object in the middle, many things below and above it. This appendix explains the shape; it makes no claim that BNAP has earned the comparison, which only deployments can do.
Figure: The BNAP hourglass · informative. The source tab shows this as an interactive diagram (widget
node/dd7aab73-de90); it is not part of this export. See the source doc.
Money crosses the waist only through ISSUE, rarely; every priced action crosses it through SPEND, with no payment.
- Narrow waist. IP succeeded because it assumes almost nothing, so anything can carry it and anything can run on it. BNAP’s waist is the Note: value rails below, carriers above.
- Pay rarely, check cheaply. TLS separates an expensive handshake from a cheap record layer. BNAP separates buying credit on a value rail from spending it with a local check.
- Protocol and implementation. HTTP is a protocol; Apache is one program that speaks it. BNAP is the protocol; Caparra is one implementation, and others must be able to replace it.
- End to end. Saltzer, Reed and Clark (1984) argued that functions needing application knowledge belong at the ends. Judging abuse is such a function, so BNAP leaves it to the application.
Appendix B: Debate record
Round 3, 10 October: Bitcoin development state of the art
An ecash cryptographer, an anonymous-credentials expert, a Lightning engineer and a Bitcoin protocol reviewer each reviewed v0.3, then answered each other’s strongest points. Where they still disagreed, the owner decided.
| Question | Positions | Verdict |
|---|---|---|
| Note secret | Raw K bytes (v0.3) or hex(K) as a Cashu secret | hex(K), with a key-isolation rule |
| Keyset ID | Custom 8 bytes or Cashu version-2 | Version-2, short ID allowed on the wire |
| Recovery | None (v0.3) or NUT-13 | NUT-13 seed recovery; rated critical |
| Wire format | Custom binary or Cashu CBOR | CBOR objects; binary only for the signed bytes |
| Refund after failure | ack-refund, delete it, or check the database | Client-supplied refund outputs, signed only on FAILED |
| Replay storage | Full responses or recomputation | Recompute; 16-byte tags; re-verify first |
| First funding | Four steps, fund token in the 402, or a NUT-23 quote in the 402 | NUT-23 quote, embedded on opt-in |
| Zero-code agents | Bridge extension or a SHOULD in core | SHOULD offer L402 or MPP |
| Credential type | ACT by default, or notes | Notes mandatory; ACT a reserved suite |
| Mandatory rail | BOLT11 or BOLT12 | BOLT11; BOLT12 optional |
| Fees | None or input_fee_ppk | Per-keyset input fees |
| Hashcash | Keep or drop | Dropped |
| Withdrawal | Experts: never in v1 | Owner: optional BNAP-W, off by default |
| Framing | Experts: drop “foundational” | Owner: layered spec, modest framing, rationale in Appendix A |
| First deployment | Experts: a Nostr relay | Owner: AI agents and paid APIs; Nostr second |
Earlier rounds
- Round 1, 9 October: the operator issues its own notes, so no global mint allowlist exists; per-action prices beat refundable bonds; application carriers come before transport.
- Round 2, 10 October: one primitive and three verbs; the spend signature covers the whole message; bonds, the forfeit log and the TLS binding leave the core.
- Independent reviews of v0.1 and v0.3 found eight blockers between them, all fixed, including the DLEQ linking leak, bearer session ids and replayable retries.
Independent review of the first v0.4 draft found three blockers: the commit order contradicted the FAILED status, a paid fund quote could be redeemed twice, and the signed bytes were not defined to the byte. It also found eight major gaps, including a spend store that Cashu restore could not use, a MAC that missed two fields, uncapped funding, ambiguous L402 single use, restore gaps, a key-rotation contradiction, unbounded replays and HTTP header limits. All are fixed in this version through the PENDING state, the ISSUED quote rule, the SigningBody table and the rules around them.
Open questions
Most open items now need running code or other people, not more design.
- First API. Pick the real paid API that
caparra-gatefronts first. - Cashu maintainers. Request NUT numbers and NUT-13 derivation type 0x02; agree how BNAP aligns with NUT-24.
- Header syntax. Check the L402 and MPP Lightning challenge formats against their current specs before shipping the fallback.
- EU legal reading of non-withdrawable credit, and of BNAP-W.
- Suite 1. Confirm the RFC 9380 secp256k1 hash-to-curve suite for the IETF track.
- ACT. Measure verification at a 24-bit range and watch CFRG adoption before promoting Suite 2.
- Wallet partners for NWC funding, such as Alby.
- Name checks: IANA service names, the
caparracrate, EU trademarks. - Test vectors and benchmarks for every encoding, against the 2 ms target.
Sources
Opened during round 3:
- Cashu NUTs index and NUT-00, 02, 11, 12, 13, 19, 22, 24, 28, 29
- Keyset ID collision write-up, March 2026
- cashu-kvac and OpenSats on ecash research
- Multi-verifier KVAC, ePrint 2025/2156
- draft-schlesinger-privacypass-act-01 and draft-schlesinger-cfrg-act-01
- draft-ietf-privacypass-arc-crypto-01
- Lightning Labs: agent tools and lnget
- MPP reference
- Second launches bark on mainnet
- Block adds Lightning to x402
- bLIP-26: L402
- BOLT12 adoption and Lightning in 2026, both published by Spark, a competing protocol
Sources for rounds 1 and 2 are listed in the archive tabs.
BNAP: Blind-Note Admission Protocol — Draft Specification v0.3
2026-10-10 · Dominic
This tab keeps version 0.3 of 10 October 2026 unchanged. The first tab, version 0.4, supersedes it.
Abstract and status
BNAP (Blind-Note Admission Protocol) is a minimal open protocol that lets any service attach a small Bitcoin-denominated price to an action and verify payment offline, without learning who paid. Its single primitive is the Note: a 74-byte, blind-signed, single-use public key that the service issued itself. Funding, deposits, pricing policy and carriers are separate layers or extensions, the way TCP/IP keeps IP small and builds everything else on top.
Name and notation. BNAP, said “bee-nap”, is the protocol; Caparra is its Rust reference implementation, as Apache implements HTTP. The name says what it does: a blind-signed note buys admission to an action. Notation follows TCP/IP: a slash means “over”, with the lower layer on the right, and a plus means “carried in”.
| Written | Read as |
|---|---|
| BNAP/LN | BNAP notes funded over Lightning |
| BNAP/Ark, BNAP/Cashu | BNAP notes funded over Ark or Cashu ecash |
| HTTP+BNAP | HTTP carrying BNAP in its authentication slot |
| HTTP+BNAP/LN | The full suite: HTTP with BNAP, funded over Lightning |
| SASL+BNAP | SMTP, IMAP or XMPP carrying BNAP through SASL |
| BNAP-B | The deposit extension (refundable bonds) |
Never write HTTP/BNAP: it claims HTTP runs over BNAP and reads like a version number. BNAP was chosen on 10 October over CPR (Cost Per Request), because it was the only finalist that collided with nothing in the IETF, Bitcoin or package registries.
Status. Pre-draft, version 0.3. A second debate round asked one question: what is the smallest core that could become foundational, like IP or TLS? Version 0.2, with its full detail on bonds, forfeit logs, pricing and the TLS binding, stays unchanged in the “v0.2 archive” tab as source material for the extension documents. The key words MUST, SHOULD, RECOMMENDED and MAY are to be read as in BCP 14 (RFC 2119, RFC 8174).
What round 2 changed.
- One primitive replaces six proof types, three instruments and a session state machine.
- Three verbs: CHALLENGE, SPEND and ISSUE. A session becomes an application cookie set after a SPEND.
- The server keeps no per-client state: only records about notes, deleted when their keyset expires.
- Refundable bonds, the forfeit log, pricing formulas, the TLS/QUIC binding and five of six funding rails leave the core for extensions or operator policy.
- The core gained three things the debate found it cannot do without: the spend signature covers the whole message, every SPEND and ISSUE response carries a signed acknowledgement that serves as the receipt, and a retried SPEND returns the stored answer without repeating the action.
Owner decisions carried forward: Bitcoin-denominated and rail-agnostic; unlinkable by default; agents welcome if they pay; pluggable funding (now value-in types); refundable bonds with an operator-chosen forfeit destination and public claim rates (now the BNAP-B extension); Rust; MIT/Apache-2.0; Cashu NUT first, IETF later.
Why BNAP exists
BNAP removes actions that are only profitable at a price of zero: slop posts, spam mail, bulk fake signups, Sybil swarms. An automated action stops paying the moment its price exceeds its value to the actor:
p > v_{action}
The price is charged per action, not per actor, so splitting into many identities saves nothing. That is the defensible core of the original thesis. BNAP does not tell humans from machines; it makes each action cost something, and only low-value actions disappear.
| Actor | Outcome under BNAP |
|---|---|
| Slop and spam farms | Priced out once the price exceeds revenue per action |
| Sybil swarms | No gain from splitting; the cost is per action |
| Paying agents | Admitted at the same price as anyone |
| Capital-rich or state actors | Not deterred; out of scope |
| Volumetric floods | Out of scope |
| The issuing service | Cannot link a spend to the purchase that funded it |
Money beats proof-of-work because it costs everyone the same, while hashcash favours GPUs over phones (Laurie and Clayton, 2004). Paying sats is a transfer, not proof of energy, so this spec argues from spam economics only. The BNAP-B extension adds a forfeitable deposit, which raises the left side by the deposit times the chance of being flagged, wherever an operator can detect abuse.
Design principles
Seven principles decide every trade-off; each is borrowed from a protocol that became foundational.
- One narrow waist. Many value rails below, many carriers above, one object in the middle: the Note. As with IP, the less the waist assumes, the more it can run on (Beck’s hourglass theorem).
- End to end. Judging abuse needs application knowledge, so it stays in the application (Saltzer, Reed and Clark, 1984). BNAP only makes a price verifiable.
- No per-client state. The server stores records about notes, never about people, as Bitcoin stores only the UTXO set. Challenges are MAC’d cookies, like TCP SYN cookies and QUIC Retry tokens.
- Pay rarely, admit offline. Money moves on a value rail when notes are bought; admission is a local check on every action. TLS draws the same line between its costly handshake and its cheap record layer.
- The relying party is its own issuer. A service accepts only notes it signed, so nobody else can mint admission and no allowlist or gatekeeper exists.
- Sign everything once. Each note key signs a hash of the entire spend, as TLS Finished covers the whole transcript.
- It’s OK to be odd. Extensions are TLV records: an unknown even type is critical and rejected, an unknown odd type is ignored (Lightning BOLT 1). Clients send random odd GREASE types so the mechanism never rusts (RFC 9170).
Non-goals: proving personhood; stopping well-funded attackers or volumetric DDoS; replacing the operator’s abuse classifier; being a payment network or a token. BNAP has no coin of its own.
Where BNAP sits in the stack
BNAP has one responsibility: admit one action at one origin if and only if the request carries unspent notes, issued by that origin, worth the posted price and signed over the whole request, checked offline and without learning who paid.
Figure: The BNAP hourglass · five layers, one waist. The source tab shows this as an interactive diagram (widget
node/abfa0928-4b42); it is not part of this export. See the source doc.
Money crosses the waist only through ISSUE, rarely; every priced action crosses it through SPEND, as a local check with no payment.
BNAP cannot be HTTPS-shaped. TLS is transparent to HTTP; BNAP cannot be, because it must know which request is an action and what that action costs. It therefore lives in the authentication slot of existing protocols, as SASL mechanisms and HTTP authentication schemes do. It needs no new URI scheme, which would split web origins (RFC 6454), and no ALPN identifier. The layer names avoid Bitcoin’s L1/L2 vocabulary:
| Layer | Contents | Responsible for |
|---|---|---|
| Policy | Prices, abuse judgement, deposits, sessions | Everything that needs application meaning |
| Carrier | HTTP authentication scheme, SASL, Nostr, ESMTP | Putting the bytes into a protocol slot |
| Admission | BNAP: the Note and three verbs | Proving issuer-scoped value was spent once, for this request |
| Value | Lightning, Ark, Cashu ecash, plus work and attested grants | Moving value, escrow, refunds |
| Settlement | Bitcoin | Final settlement |
What BNAP does not do, as IP does not guarantee delivery:
- A valid spend is necessary, not sufficient; the operator may still queue or refuse.
- It moves no money and holds no custody; value rails do that.
- It sets no prices; it carries the operator’s quote.
- It identifies nobody and judges no abuse.
- It provides no confidentiality; TLS does.
- It does not stop volumetric floods or attackers who earn more per action than they pay.
The Note
A Note is a blind-signed, single-use public key worth a power of two units. It is the only object every BNAP implementation must agree on. It has no version, kind, session or action field: a field that does not exist cannot ossify.
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Keyset ID (64 bits) |
| byte 0 = suite; the keyset fixes unit, keys, expiry |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Exponent | |
+-+-+-+-+-+-+-+-+ Issuer signature C (33 bytes, compressed) ~
~ ~
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Note key K (32 bytes, x-only) = the nullifier ~
~ ~
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
Suite 0 Note = 8 + 1 + 33 + 32 = 74 bytes. Value = 2^Exponent units.
Suite 0 (mandatory to implement): secp256k1, the blind Diffie-Hellman key exchange (BDHKE) used by Cashu (NUT-00), BIP-340 Schnorr signatures and SHA-256. The suite byte sets every field length after it, so a post-quantum suite can use larger keys and signatures without a new Note format.
Making and spending a Note (Suite 0)
- The client generates a fresh key pair (k_n, K) and computes Y = hash_to_curve(K).
- It blinds Y as B’ = Y + r·G and sends B’ in an ISSUE or as a change output.
- The issuer returns C’ = k_e·B’ with a DLEQ proof that it used the published key for exponent e. The client MUST verify the DLEQ, then unblinds: C = C’ − r·(k_e·G).
- To spend, the client presents (Keyset ID, e, C, K) and signs the spend digest with k_n.
- The issuer checks C = k_e·hash_to_curve(K) with its private key, checks the BIP-340 signature by K, and test-and-sets K in its spent-set.
The math is Cashu’s; the only change is that the secret is the Note key K itself, which removes the need for a separate P2PK lock (NUT-10, NUT-11).
What this guarantees:
- Unlinkable. The issuer never sees K before the spend and never sees r, so it cannot link a spend to the ISSUE that funded it.
- No DLEQ leak. The DLEQ proof is checked at issuance and never presented, so it cannot reveal the blinding factor.
- Theft-proof. K must sign, so a copied Note is useless without k_n.
- One nullifier rule. K is the nullifier for every Note.
Rules:
- Keyset ID = suite byte ‖ first 7 bytes of SHA-256(suite ‖ unit ‖ expiry ‖ keys in exponent order). Clients MUST compare full key lists, not IDs, since a short ID can be ground to collide.
- Consistent keys. Keysets are few, long-lived and identical for every client. Operators SHOULD publish a keyset at least 30 days before issuing from it, and clients SHOULD compare keysets from a second vantage point, so per-user tagging keys are caught.
- Canonical encodings. K is a valid x-only key; C is a canonical compressed point and never infinity. Because K is x-only, K and −K are one nullifier.
- hash_to_curve is Cashu’s function (NUT-00) applied to the 32 bytes of K, so BNAP shares test vectors and libraries with Cashu.
- Powers of two. A spend of 13 units uses Notes of 8, 4 and 1.
- One origin, once. A key K is used at exactly one origin and never twice.
The protocol: three verbs
BNAP has three verbs. ISSUE runs rarely and moves value; CHALLENGE and SPEND run on every priced action and need no network call beyond the request itself.
Figure: HTTP+BNAP/LN message flow · funding once, then per action. The source tab shows this as an interactive diagram (widget
node/f507def9-5228); it is not part of this export. See the source doc.
Solid arrows are requests and dashed arrows are responses. Only the top band touches Lightning; the bottom band repeats for every priced action and never leaves the operator.
Encoding
BNAP messages are binary and identical on every carrier, which moves them as base64url.
- Integers are big-endian; lengths and counts use BigSize, as Lightning BOLT 1 does.
- A TLV stream is a run of (BigSize type, BigSize length, value) records in strictly ascending type order, with no duplicates.
- Hashes are BIP-340 tagged hashes: H_tag(x) = SHA-256(SHA-256(tag) ‖ SHA-256(tag) ‖ x).
CHALLENGE (server to client)
A challenge is a TLV stream. The server MACs it so it keeps no state between CHALLENGE and SPEND.
| Type | Field | Meaning |
|---|---|---|
| 0 | version | Protocol version, 1 |
| 2 | scope | 32-byte hash of the action; each carrier defines it to cover the action’s content and its recipient |
| 4 | amount | The price |
| 6 | unit | Unit string, such as sat |
| 8 | keysets | Accepted keyset IDs, 8 bytes each |
| 10 | expires | Unix time after which the quote lapses |
| 12 | issue | URI of the ISSUE endpoint |
| 14 | op_key_id | Which operator key signs acknowledgements |
| 16 | nonce | 16 random bytes |
| 18 | mac | Server MAC over every record above |
| 20 | issued_at | Self-issued challenges only: creation time |
Self-issued challenges. Store-and-forward carriers such as Nostr events and relayed mail cannot do a round trip. Where the manifest allows it, a client MAY build a challenge with version, scope, amount, unit, keysets, its own nonce and issued_at, and no mac. The operator accepts it if amount is at least the price in its published price table and the age is within that carrier’s max_age_s. Replay is impossible, because each K is recorded with its Digest.
SPEND (client to server)
Note = keyset_id(8) ‖ exponent(1) ‖ C(33) ‖ K(32)
Output = keyset_id(8) ‖ exponent(1) ‖ B'(33)
Body = version(1) ‖ Challenge ‖ Binding ‖ Notes ‖ Outputs ‖ TLV stream
Challenge and Binding are length-prefixed; Notes are sorted by K and
Outputs keep client order, both count-prefixed
Digest = H_"bnap/spend"(Body)
SPEND = Body ‖ one BIP-340 signature by each K over Digest, in Note order
Response = status(1) ‖ ChangeSigs ‖ Ack
ChangeSig = C'(33) ‖ DLEQ(64), one per Output, in Output order
Ack = BIP-340 signature by the op key over H_"bnap/ack"(Digest ‖ status ‖ amount ‖ ChangeSigs)
status = 0 OK, 1 REPLAYED, 2 FAILED
The Digest covers the challenge, so it covers the scope, the price, the MAC and the recipient. The server checks a SPEND cheapest first:
- Parse. Check the version, the total size and the manifest’s
max_notesandmax_outputs. - Replay lookup. If every K is already recorded with this Digest, return the stored Response with status REPLAYED, whatever the expiry or binding; the carrier MUST NOT perform the action again. If any K is recorded with a different Digest, fail with
SPENTnaming that K. - Challenge. The MAC is valid and unexpired, or the self-issued fields are acceptable; the scope equals this request’s scope; the binding matches.
- Keysets. Every keyset is accepted, unexpired and not revoked, uses the challenge’s unit and holds each exponent used; no K or B’ appears twice.
- Amount. With checked arithmetic, the Notes minus the Outputs equal the amount exactly.
- Signatures. Every Note verifies (C = k_e·hash_to_curve(K)) and every signature by K covers the Digest.
- Commit. Record every K with the Digest and the Response in one atomic step, or record none.
Binding is empty, or the RFC 9266 tls-exporter value where the client can read it. Native clients and agents SHOULD bind; browsers cannot. A replayed SPEND is served from step 2 even on a new connection, so a lost response loses nothing.
The Ack is the receipt: one 64-byte operator signature that commits to what was paid, for which action, and what came back. Change is signed in the same response, so a deposit returned at once is simply change. A SPEND with amount 0 is a swap: it re-denominates notes or moves them to a new keyset.
Failure after payment. Operators SHOULD commit a spend together with its action. If the action fails after commit, the response carries status FAILED. The client redeems that SPEND body and response through ISSUE with value-in type 2, ack-refund, for new notes worth the amount. The issuer records every refunded Digest until the keyset expires, so each refund is paid once. Because FAILED is in the core, so is ack-refund.
ISSUE (client to issuer)
ISSUE = version(1) ‖ value_in_type(BigSize) ‖ payload (length-prefixed) ‖ Outputs ‖ TLV stream
Response = status(1) ‖ ChangeSigs ‖ Ack over H_"bnap/issue"(ISSUE ‖ status ‖ ChangeSigs)
ISSUE is idempotent per payload: the same paid quote or refund claim always returns the same signatures. The value-in type is a registry number, like IP’s protocol field: the core names it and never parses it. Type 1, Lightning, is mandatory to implement; type 2, ack-refund, is part of the core; every other type is an extension.
Errors
A SPEND is atomic, so every error leaves every Note in the request unspent. Error responses SHOULD carry an operator signature over the Digest and the code, so a client can prove what the server claimed.
| Code | Meaning | Client action |
|---|---|---|
VERSION | Unsupported version | Use one from the manifest |
MALFORMED | Bad encoding, or an unknown even TLV | Fix and resend |
TOO_LARGE | Over the size, note or output limit | Swap into fewer notes first |
UNKNOWN_KEYSET | Keyset not accepted, expired or revoked | Refresh the manifest, swap notes |
UNIT_MISMATCH | A Note or Output is in another unit | Use the challenge’s unit |
BAD_CHALLENGE | MAC invalid, or self-issued challenges not allowed | Get a challenge from the server |
STALE | Challenge expired, or self-issued challenge too old | Get a new challenge |
WRONG_SCOPE | Challenge or binding is for a different action | Get the right challenge |
WRONG_AMOUNT | Notes minus Outputs differ from the price | Adjust the change outputs |
BAD_SIGNATURE | A Note or a K signature failed | Drop the bad Note |
SPENT | A K was spent in a different SPEND | Drop that Note, resend the rest |
Manifest
Published at /.well-known/bnap:
{
"versions": [1],
"keysets": [{ "id": "00ad…", "unit": "sat", "expiry": 1830297600, "revoked": false,
"keys": { "0": "02…", "1": "03…" } }],
"prices": { "table_version": 7, "POST /t/*/reply": 8, "POST /signup": 64 },
"limits": { "max_notes": 64, "max_outputs": 64, "max_bytes": 16384 },
"issue": "https://forum.example/bnap/issue",
"value_in": [1, 2],
"op_keys": { "k1": "02ab…" },
"self_issued": { "allowed": true, "max_age_s": { "http": 60, "nostr": 86400 } }
}
State
BNAP keeps no per-client state. The server stores records about notes, never about people, as Bitcoin stores only the UTXO set:
- a spend record per K (Digest and Response), kept until its keyset expires;
- the set of refunded Digests, kept as long;
- pending Lightning quotes;
- its keys: issuer keysets, the MAC key and the operator keys.
Keysets. Keys rotate by publishing a new keyset; notes in an expiring keyset are swapped into the new one before expiry, and notes left unswapped are lost, so expiry SHOULD be at least a year away. An expired keyset ID MUST never be accepted again, because its spend records are gone. A compromised keyset is marked revoked and replaced at once. An operator with several points of presence either gives each its own keyset, keeping verification fully offline, or shares one atomic store of spend records.
Extensibility
The TLV stream at the end of a SPEND or ISSUE carries extensions. An unknown even type is rejected with MALFORMED; an unknown odd type is ignored. Every SPEND MUST carry exactly one GREASE record: a random type from the reserved odd range with a 4-byte random value, so no wallet can be told apart by it. The version, suite, value-in and TLV registries each reserve GREASE values.
HTTP binding (mandatory for HTTP servers)
HTTP+BNAP is one 402 response and one retry: the challenge rides in WWW-Authenticate next to any other payment scheme, and the spend rides in Authorization.
POST /t/123/reply HTTP/1.1
Host: forum.example
Content-Digest: sha-256=:X48E9q…:
HTTP/1.1 402 Payment Required
WWW-Authenticate: BNAP challenge="<base64url TLV>", amount="8", unit="sat", issue="/bnap/issue"
WWW-Authenticate: Payment id="…", method="lightning", intent="charge", request="…"
POST /t/123/reply HTTP/1.1
Host: forum.example
Content-Digest: sha-256=:X48E9q…:
Authorization: BNAP spend="<base64url SPEND>"
HTTP/1.1 201 Created
Authentication-Info: bnap="<base64url Response with Ack>"
- Scope. scope = H_“bnap/scope/http”(method ‖ 0x00 ‖ absolute URI with scheme and authority ‖ 0x00 ‖ the Content-Digest value from RFC 9530, empty when there is no body). It binds the spend to the origin and to the exact content, so nobody on the path can reuse it for a different body.
- Signed bytes. The
challengeparameter carries the encoded challenge;amount,unitandissueare readable copies that MUST match it. - If
Authorizationalready carries application auth, the client sendsBNAP-Authorizationinstead. - Errors come back as 402 with
BNAP-Error: STALE(or another code), its signature and a fresh challenge. - A client that cached the manifest MAY send a self-issued challenge on the first request and skip the 402, where the manifest allows it.
- Sessions are ordinary cookies: a server MAY set one after a successful SPEND and decide when to charge again. The core has no session object.
- Browser wallets send an empty binding; native clients and agents send the TLS exporter value, and a terminating CDN forwards it to the origin as RFC 9729 does.
Lightning issuance (mandatory to implement)
Value-in type 1 buys notes with one Lightning payment, in the quote, pay, issue pattern Cashu uses for minting (NUT-04). Every BNAP issuer MUST support it, so any two conforming implementations share at least one way to move value; this is how TLS mandates one cipher suite while allowing many. In IETF form it is a separate document the core references.
- Quote. The client POSTs
{amount, unit, client_key}to the issue URI and receives a BOLT11 invoice and a quote id. The quote is locked toclient_key, so a leaked quote id is worthless (as Cashu NUT-20 does). - Pay. The client pays the invoice from any Lightning wallet, including a Cashu wallet melting ecash.
- Issue. The client sends ISSUE with
ValueIn{1, quote_id}, its blinded outputs and a signature byclient_key. The issuer checks that the invoice is paid and returns blind signatures with DLEQ proofs. Repeating the ISSUE returns the same signatures.
BOLT12 offers are RECOMMENDED wherever both sides support them. Withdrawal, turning notes back into a Lightning payment, is a separate extension, because some operators sell credit that is not refundable.
A typical human wallet buys a few thousand sats of notes per site in one payment and then spends them silently under a budget, so BNAP feels like a flat rate rather than a meter. Thirty years of failed micropayment schemes say this is the make-or-break interface rule.
Security and privacy considerations
Every threat the two debate rounds and the two independent reviews found is closed by a core rule; the residual risks are economic, legal or accepted on purpose.
| Threat | Closed by |
|---|---|
| Double spend | Spend record per K, committed atomically for a whole spend |
| One payment replayed for many actions | A known Digest returns the stored answer and the action MUST NOT run again |
| A spend reused with another body or at another origin | The scope covers content and recipient, and the Digest covers the scope |
| Note theft from logs, proxies or CDNs | K must sign; a copied Note is useless without k_n |
| Change theft by a proxy | The signature covers the outputs and the whole body |
| Paying a cheap challenge for a costly action | Scope inside the challenge MAC |
| Value created by mixed units or overflow | One unit per spend; exponents checked before checked arithmetic |
| Admission minted by someone else | The operator accepts only its own keysets |
| Issuer linking a spend to a purchase | Blinding; the DLEQ is never presented |
| Per-user tagging keys | Full key lists cross-checked; keysets published 30 days ahead |
| Wallet fingerprinting | Power-of-two notes; exactly one GREASE record in every spend |
| A refund claimed twice | Refunded Digests are recorded |
| A leaked Lightning quote id | Quotes are locked to a client key |
| Lost responses | Replays served from the stored record, even after expiry |
| Junk spends as denial of service | Size, limit and lookup checks before any elliptic-curve work |
| Tracking across sites | Each K is used at one origin, once |
| Ossification | Even/odd TLV rule plus GREASE |
Accepted residual risks:
- Key compromise. A stolen issuer key forges notes that cannot be told apart, so the keyset is revoked and replaced at once. A stolen operator key can mint value through forged FAILED acknowledgements, so operator keys stay offline-capable and refunds are capped per epoch. A stolen MAC key forges cheap quotes, so MAC keys rotate often.
- Small operators can still link visitors by IP address, TLS fingerprint and timing, including the gap between a Lightning purchase and the first spend and which notes are spent together. BNAP promises unlinkable notes, not anonymity.
- Notes are operator credit. An operator that disappears takes unspent notes with it, so wallets keep per-site balances small, and notes left unswapped at keyset expiry are lost.
- Unsigned errors. A server that skips the optional error signature can claim SPENT without proof.
- Suite 0 is not post-quantum; the suite byte exists for that migration.
- Unflagged abuse pays only the price; BNAP-B adds deposits where an operator can detect abuse.
Regulatory note. Each operator issues notes only for its own service, which reads closer to a merchant selling prepaid credit than to a custodian. From July 2027 the EU AMLR bars licensed crypto-asset providers from anonymous accounts. That reading needs EU counsel before anyone relies on it.
Extensions and roadmap
Nothing from v0.2 is lost; it moves to where it belongs. The core holds only what every implementation must share, and each other feature becomes its own short document or the operator’s own policy. The v0.2 archive tab holds the detailed drafts these documents start from.
Why bonds left the core. A deposit returned immediately is just change, which the core already does. A real deposit is change the server holds back and signs later unless it forfeits. That needs per-client state, a release step, reason codes and a transparency log: four mechanisms for an instrument that only bites when the operator can detect abuse. It composes cleanly on top as BNAP-B, with no new cryptography: a SPEND whose outputs are held, plus a release that returns their signatures.
| Document | What it adds | Status |
|---|---|---|
| BNAP core | The Note, three verbs, ack-refund (value-in type 2) | Mandatory |
| HTTP binding | 402 challenge, Authorization spend, content-covering scope | Mandatory for HTTP servers |
| Value-in 1: Lightning | Quote locked to a client key, pay, issue | Mandatory for issuers |
| SASL+BNAP | BNAP and BNAP-PLUS mechanisms for SMTP submission, IMAP, XMPP and MQTT 5 | First extension |
| BNAP-B: deposits | Held change with release or forfeit; operator-chosen forfeit destination, reason codes, forfeit log, claim rates; Ark and Cashu escrow | Experimental |
| More value-in types | BOLT12, Ark, Cashu, hashcash, Privacy Pass attestation, operator grants | Optional, one registry entry each |
| Withdrawal | Notes back to Lightning | Optional |
| Nostr NIP, ESMTP parameter | Store-and-forward carriers using self-issued challenges | Optional |
DNS SVCB bnap hint | Skip the first 402 | Optional |
| x402 scheme, MPP method, L402 bridge | Interoperability with other 402 schemes | Optional |
| Pricing guidance | Load-based prices, ceilings, fair queuing, a reserve for humans | Informational |
| Wallet guidance | Budgets, top-ups, agent SDK, people without wallets | Informational |
| TLS/QUIC admission | Pricing handshake floods before any application runs | Deferred until deployments exist |
| Sessions, linkable mode, per-request K-signatures | Replaced by cookies after a SPEND | Removed |
Reference implementation: Caparra
Caparra is the Rust reference implementation of BNAP. Its core crate is small enough to audit in a day, and every extension lives in its own crate, so a solo maintainer never carries optional code on the main path.
| Crate | Responsibility | Builds on |
|---|---|---|
bnap | Note, CHALLENGE, SPEND, ISSUE, Ack, TLV codec, test vectors; no_std | secp256k1, sha2 |
caparra-issuer | Keysets, blind signing, DLEQ, spent-set backends | CDK (Cashu Dev Kit) primitives |
caparra-http | Tower middleware and the caparra-gate reverse proxy | tower, hyper, rustls |
caparra-ln | Value-in type 1 | LDK |
caparra-wallet | Client wallet, budgets, the bnap CLI, an MCP server for agents | All of the above |
caparra-sasl, caparra-b, … | One crate per extension | bnap |
Build order: bnap with test vectors, then caparra-issuer, then caparra-http and the gate, then caparra-ln, then the wallet and CLI, then extensions.
Targets to benchmark: one spend verified in under 1 ms per Note on one core, with no network call. These are design targets, not measurements.
Names checked: bnap is free on crates.io and npm; caparra is free on npm and still needs a manual check on crates.io.
Standardization
The first wave is three short documents, about 24 pages in total; everything else waits until two independent implementations interoperate. For scale, UDP (RFC 768) is 3 pages and IPv4 (RFC 791) is 45.
| Document | Length | Venue | Target |
|---|---|---|---|
| BNAP core | ~12 pages | Cashu NUT, then IETF individual draft | Standards Track candidate |
| HTTP binding | ~8 pages | IETF HTTP area | Standards Track candidate |
| Value-in 1: Lightning | ~4 pages | Cashu NUT | Accepted NUT |
| SASL+BNAP | ~6 pages | IETF, IANA SASL registry | Standards Track candidate |
| BNAP-B deposits | — | Individual draft | Experimental |
Core document outline: terminology and model; Suite 0; the Note; CHALLENGE, SPEND and ISSUE; verification order and the spent-set; keysets and rotation; errors; extensibility and GREASE; security and privacy considerations; IANA considerations; test vectors.
IANA registrations: the BNAP HTTP authentication scheme; the BNAP-Error and BNAP-Authorization header fields; the bnap well-known URI; the SASL mechanisms; registries for suites, value-in types and TLV types, each with GREASE values reserved.
Licensing and governance. Code is MIT/Apache-2.0; drafts follow the IETF Trust terms. A single maintainer runs public proposals until two independent implementations exist, then an editors’ group takes over the spec. No maintainer ever holds funds, runs a default issuer or ships an allowlist.
Appendix A: Debate record
Round 2, 10 October: how small can the core be?
Four agents argued in an opening round and a rebuttal round: a minimalist (one primitive, IP-style), a core defender (what must stay or the system breaks), a stack architect (where BNAP sits) and a naming agent. The minimalist set the shape; the defender added the safety rules; both signed the result.
| Question | Minimalist | Core defender | Verdict |
|---|---|---|---|
| The primitive | A blind-signed bearer note | Must be key-locked and extensible | Note = blind-signed public key K; K is the nullifier |
| What the spend signs | Challenge and binding | The entire spend, outputs included | The entire body, like TLS Finished |
| Versioning | Version, suite and reserved bits in the Note | Extension map and GREASE | No version field in the Note, one version byte per message; suite in the keyset ID; even/odd TLVs and GREASE |
| Receipts | The change is the receipt | A signed receipt is mandatory | One operator signature, the Ack |
| Failure and retries | Application concern | Core rule | Atomic, idempotent spends in core; refunds through ack-refund |
| Spends without a round trip | Not allowed | Needed for Nostr and mail | Self-issued challenges, operator opt-in |
| Mandatory rail | None | Lightning | Lightning BOLT11 |
| Bonds | Deferred change, an extension | Out of the core | BNAP-B, Experimental |
| TLS/QUIC binding | Delete | Delete | Deferred until deployments exist |
| Privacy Pass | Ship as a token type? | Fixed digest, no change channel | Not a token type; usable as a value-in attestation |
Stack architect. BNAP is not HTTPS-shaped, because it must see actions and prices; it belongs in authentication slots. The Bitcoin view, with payment on the rail and admission at the waist, is the framing that makes it foundational. Slash means over, plus means carried in.
Naming. About 50 candidates were checked against IETF protocols, Bitcoin projects and package registries. Eliminated for collisions: SATP (an IETF working group), BAP (Bitcoin Attestation Protocol), TAP (Taproot Assets), PCP, SAP, PAT (Privacy Pass), CNAP (3GPP caller ID), P402 (too close to L402 and x402). The finalists were BNAP and CPR (Cost Per Request); the owner chose BNAP.
Round 1, 9 October: custody, layer and economics
| Decision | Verdict |
|---|---|
| Custody | The operator issues its own notes; Bitcoin rails only fund them; no global mint allowlist |
| Economic core | Per-action prices are primary; refundable bonds cannot deter abuse nobody flags |
| Layer | Application carriers first; transport admission later |
| Forfeits | Operator chooses the destination and must declare it; objective reason codes; public log |
| Relationship to x402 | Bridge, don’t compete |
| Framing | Spam economics, not superintelligence |
Independent review of v0.1 found five blockers, including the DLEQ linking leak and session ids usable as bearer tokens; v0.2 fixed all of them, and v0.3 removes the session objects entirely.
Independent review of the first v0.3 draft found three blockers: a retried SPEND could buy the action again, the HTTP scope covered neither the request body nor the origin, and the wire encoding was undefined. It also found seven major gaps: double-claimable refunds, unchecked units and overflow, a self-issued challenge with no published price, a grindable keyset ID, fingerprintable GREASE, missing key-compromise rules, and an overstated claim that the server keeps only one set. All are fixed in this version. The reviewer confirmed that using the Note key K as the blind-signature secret is sound.
Open questions
The core is small enough that the open questions are mostly about naming, law and adoption, not cryptography.
- Name. BNAP is chosen; check the IANA service-name registry, the
caparracrate and EU trademarks before the first public repository. - Bonds. Confirm BNAP-B as an Experimental extension rather than part of the core, which departs from the original choice of a refundable bond.
- Carrier ages. Set
max_age_sfor self-issued challenges on Nostr and relayed mail. - Normative reference. Decide how the IETF core references the Lightning value-in document without a downward reference to a Cashu NUT.
- Post-quantum suite. Pick a blind-signature scheme for a future suite.
- Review venues. Take the HTTP binding to the IETF Privacy Pass or HTTP authentication communities for early review.
- EU legal reading of operator-issued prepaid notes under MiCA and AMLR.
- Wallets. Choose wallet partners for the browser extension; Lightning public capacity is about 4,900 BTC and Cashu has little consumer reach.
- Test vectors and benchmarks. Publish vectors for every encoding and measure verification against the 1 ms target.
Sources
Opened during the debates:
- RFC 9577: Privacy Pass HTTP authentication scheme
- Cashu NUT-24: HTTP 402
- Cashu NUT specifications
- IETF SATP working group
- RFC 9729: Concealed HTTP authentication
- draft-httpauth-payment-01
- IACR ePrint 2026/2174: Cashu system-level analysis
- Laurie and Clayton: Proof-of-work proves not to work
- rustls feature manual
Found by search but not opened:
- RFC 9170: Long-term viability of protocol extension mechanisms
- RFC 3439: Some Internet architectural guidelines
- Beck: On the hourglass model
- Akhshabi and Dovrolis: EvoArch
- RFC 9576: Privacy Pass architecture
Cited from the agents’ own knowledge and not verified in this session: Saltzer, Reed and Clark (1984); Clark (1988); Lightning BOLT 1; RFC 768; RFC 791; RFC 6454; RFC 9266. The v0.2 archive tab lists the round 1 sources.
v0.2 archive: Caparra Protocol draft (superseded)
2026-10-09 · Dominic
This tab keeps version 0.2 of 9 October 2026 unchanged, as source material for the CPR extension documents (deposits, pricing guidance, wallet guidance, SASL, TLS/QUIC). The first tab, version 0.3, supersedes it. Here the protocol still carries its old working name, Caparra; read CPR.
Abstract and status
Caparra is an open protocol that makes every costly network action carry a small Bitcoin-denominated price, and lets operators hold refundable deposits that are returned by default and kept only on published evidence of abuse. It is a layered stack: one core token format, normative bindings for HTTP and SASL, an Experimental TLS 1.3/QUIC binding, and pluggable funding rails with Bitcoin (ecash and Lightning) as the reference.
What the debate changed. Three owner choices survive in modified form:
- Refundable bond, demoted. A refundable bond costs an attacker almost nothing unless the operator catches them, so non-refundable per-action stamps are the primary price. Bonds remain as a first-class but optional, capped penalty instrument.
- Custody. Operators issue their own blind tokens (the operator is its own mint). Bitcoin rails only fund those tokens. This removes global mint allowlists, the fake-bond attack and the shared-mint surveillance point.
- Layer. The application binding (HTTP, SASL) is normative. The TLS/QUIC binding is a separate Experimental document, because forfeit is an application judgement and the reference TLS library will not merge a custom extension.
Status: pre-draft, version 0.2. Contested design choices were argued by opposing agents before being fixed here; the debate record is in Appendix A. An independent review of version 0.1 found five blockers and seven major issues; all are fixed in this revision. The key words MUST, SHOULD, RECOMMENDED and MAY are to be read as in BCP 14 (RFC 2119, RFC 8174).
Fixed inputs from the project owner:
- Scope: protocol level (TLS/QUIC/HTTP), full vision, no MVP cut in this document
- Rail: spec is rail-agnostic; reference implementation is Bitcoin-only
- Mechanism: refundable bond; on forfeit the operator chooses where funds go, and claim rates are public
- Unit: one session bond per connection (now a session credential with a small fee; see Lifecycle), plus optional per-action stamps for costly actions
- Privacy: unlinkable by default; operators may opt into linkable tokens
- Fallbacks: pluggable proof types (ecash, Lightning, hashcash, operator-issued tokens)
- Agents: welcome if they pay; the protocol prices actions, not actors
- Implementation: Rust, solo maintainer at start, MIT/Apache-2.0 dual license
- Standards path: Cashu NUT first, IETF Internet-Draft later
Name. “Stamp” collides with the well-known Bitcoin Stamps (SRC-20) project, and the stamp crate is taken. The debate recommends Caparra, the Italian word for a refundable earnest-money deposit: exactly the semantics, and the caparra crate was free on crates.io. Alternatives with free crate names: Arra, Vadium, Handsel. GitHub collisions are not yet checked.
| Term | Meaning in this spec |
|---|---|
| Operator | The service that admits traffic and judges abuse |
| Stamp | Non-refundable payment consumed by one action |
| Session credential | Prepaid token consumed at admission, re-issued on clean close |
| Escrowed bond | Timelocked deposit with a forfeit path and a refund path |
| Funding rail | How value enters: Lightning, Cashu, Ark, or no money (hashcash, free grants) |
| Carrier | The protocol slot that transports a token: HTTP, SASL, SMTP, Nostr, TLS/QUIC |
Motivation and threat model
Caparra prices out actions whose value to the actor is lower than their price: slop posts, spam mail, bulk fake signups, Sybil swarms. It does not stop attackers who earn more per action than they pay, and it does not touch packet floods.
The economic test is one inequality. An automated action is deterred when the stamp price plus the expected forfeit exceeds the value of the action:
p_{stamp} + q \cdot B > v_{action}
Here q is the probability the operator flags the action and B is the bond at risk. With no reliable abuse classifier, q is near zero and only the stamp does any work. That is why stamps, not bonds, carry the price.
| Actor | Goal | Outcome under Caparra |
|---|---|---|
| Slop and spam farms | Mass low-value posts, mail, comments | Priced out once the stamp exceeds revenue per action |
| Sybil swarms | Evade per-account rate limits | Splitting identities does not help: cost is per action |
| Abusive crawlers | Bulk reads | Priced only where the operator charges for reads |
| Paying agents | Legitimate automated work | Admitted; they pay the same price as anyone |
| Capital-rich or state actors | High-value influence | Not deterred; out of scope |
| Volumetric floods (L3/L4) | Exhaust bandwidth | Out of scope; the Experimental QUIC binding only limits handshake floods |
| Greedy operators | Profit from false forfeits | Constrained by objective reason codes and a public forfeit log |
| Mints and payment intermediaries | Theft, censorship, surveillance | Kept off the admission path by operator-issued tokens |
Corrections to the original thesis:
- A price is charged per action, not per actor. “Negligible at human speed, prohibitive at machine speed” only holds if each actor’s volume is capped, and a cap is an identity system. Caparra instead targets the value per action.
- Paying sats is a transfer of money, not proof of work. Its advantage over hashcash is fairness: money costs everyone the same, while hashcash favours attackers with GPUs over humans with phones (Laurie and Clayton, 2004).
- Superintelligence framing is unfalsifiable and deters serious operators. The spec argues from spam economics only.
First principles, goals and non-goals
Eight principles decide every trade-off in this spec; each one came out of a debate the losing side conceded.
- Price actions, not actors. Per-action stamps are the primary instrument; bonds are an optional penalty layer.
- The relying party is its own issuer. An operator accepts credentials it signed itself. No global mint allowlist exists, so no third party can mint fake admission and no new gatekeeper appears.
- Admit at transport, judge at application. Admission can happen in any carrier; abuse is judged where the semantics live, and all carriers share one session state machine.
- Refund by default, forfeit by evidence. Silence or operator failure returns value to the client. Every forfeit carries a signed receipt with an objective reason code and lands in a public log.
- Unlinkable by default, linkable only per origin. Blind signatures hide which funding event a session came from. Linkable mode never spans origins.
- One token format, many carriers. HTTP, SASL, SMTP, Nostr and TLS/QUIC carry identical bytes bound to their channel.
- Rail-agnostic spec, Bitcoin reference. Funding rails are plugins; the reference implementation ships Lightning, Cashu and Ark.
- No protocol party holds funds it does not already own. The project and its foundation never custody anything.
Goals:
- Make low-value automated actions unprofitable at a price a human barely notices
- Work for anonymous visitors with no account and no identity provider
- Verify admission offline in under 1 ms with no network call on the hot path
- Deploy today behind a reverse proxy, with no browser or TLS library changes
- Interoperate with x402, L402, MPP and Privacy Pass in the same 402 response
Non-goals:
- Proving that a client is human (personhood credentials are a separate layer)
- Stopping well-funded attackers or volumetric DDoS
- Replacing the operator’s abuse classifier; Caparra makes its verdicts costly to the abuser
- Being a payment network or a token; Caparra has no coin of its own
Architecture overview
Caparra has four layers. Value enters once through a funding rail, becomes operator-issued blind credentials, travels in a channel-bound envelope, and reaches the operator through whichever carrier the protocol has.
- Funding rails (L0). Lightning BOLT12 offers, Cashu ecash melted to pay the operator’s Lightning invoice, Ark VTXOs for escrowed bonds, or no money at all (hashcash, operator free grants). Rails are only touched when buying credit or settling, never during admission.
- Credentials (L1). Each operator runs its own issuer: a Cashu-compatible blind-signature keyset with an operator-specific unit. Tokens are verified offline with the operator’s own key. Third-party Cashu proofs locked to the operator are an optional extra for portable first-visit stamps.
- Envelope (L2). One CBOR structure carries any proof type, its kind (stamp, session, bond) and a context hash that binds it to the challenge and, where the client can read it, to the TLS exporter of the current connection.
- Carriers (L3). The HTTP
Caparraauth scheme and the SASLCAPARRAmechanism are normative. ESMTP, Nostr and MQTT profiles are optional. The TLS/QUIC binding is Experimental.
A single session state machine with signed receipts and a public forfeit log sits beside the stack. It is the same for every carrier, so a session admitted at a TLS terminator and one admitted by an HTTP header are the same object with the same session id.
Figure: Caparra architecture · 4 layers and a shared state machine. The source tab shows this as an interactive diagram (widget
node/6a453bca-f864); it is not part of this export. See the source doc.
Value flows upward once: a rail funds operator credentials, and every carrier then presents the same channel-bound envelope with no payment on the admission path.
Core objects and wire formats
Four objects make up the protocol: the operator manifest, the operator credential, the token envelope and the receipt. All are encoded deterministically (CBOR on the wire, JSON for the manifest) and signed with secp256k1 BIP-340 Schnorr keys so they reuse Bitcoin and Cashu tooling.
Operator manifest
Published at /.well-known/caparra and summarised in a DNS SVCB/HTTPS caparra SvcParam (proof types plus manifest hash) so informed clients can attach a token on the first request.
{
"v": 1,
"versions": [1],
"origin": "forum.example",
"op_key": "02ab…",
"keysets": [
{ "id": "00ad…", "unit": "cpr", "unit_price_msat": 1000, "denominations": [1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024], "active": true, "final_expiry": 1798761600 }
],
"funding": ["bolt12:lno1…", "ark:…"],
"proof_types": ["op-ecash", "hashcash", "op-grant", "cashu-p2pk"],
"binding_profiles": ["tls-exporter", "nonce"],
"prices": { "session_deposit": 4, "session_fee": 1, "post": 8, "signup": 64 },
"price_ceiling": { "session_deposit": 16, "post": 64, "signup": 256 },
"bond": { "cashu_tiers": [256, 1024], "ark_min": 50000, "detection_window": "P2D" },
"forfeit": { "destination": "donate", "target": "lno1…", "reasons": ["RATE", "QUOTA", "PROTOCOL", "REPLAY", "POLICY"] },
"credit_cap": 10000,
"refund_window": "P7D",
"clock_skew": "PT60S",
"endpoints": { "fund": "/.well-known/caparra/fund", "claim": "/.well-known/caparra/session", "log": "/.well-known/caparra/log" },
"sig": "…"
}
Prices are in units of the operator’s own credential. The manifest pegs a unit to millisatoshis and MAY add a fiat reference that is re-quoted in every challenge.
Operator credential
An operator credential is a Cashu proof (NUT-00) from the operator’s own keyset: amount, keyset id, secret, unblinded signature C and a DLEQ proof (NUT-12). The client builds the secret before blinding as a NUT-10 well-known secret that P2PK-locks the proof to a fresh ephemeral client key K. The operator therefore never sees K at issuance, and a stolen proof is useless without K.
Verification of an op-ecash envelope runs in this order, cheapest checks first:
- Parse the envelope; reject an unknown version with
VERSION. - Check the size (at most 4 KiB) and that the proof type and binding profile are accepted.
- Recompute ctx from the challenge, or from a timestamp within
clock_skewfor a pre-informed client; reject a mismatch withBINDING_MISMATCH. - Check each proof’s keyset is listed and not past
final_expiry, and that amounts cover the price. - Check the signature by K over ctx.
- Check
C = k_amount · hash_to_curve(secret)with the operator’s private key. - Atomically test-and-set each proof’s nullifier in the seen-set; reject
SPENTif it is already there. - Record the stamp or session and sign the receipt.
The client MUST strip the DLEQ proof before presenting to the operator that issued the token. A NUT-12 proof DLEQ carries the blinding factor, which would let the issuer link presentation to issuance. DLEQ is checked only by the client at issuance and by verifiers of third-party cashu-p2pk proofs.
Seen-set. Entries are inserted only after verification and kept until the keyset’s final_expiry. Operators with several points of presence pick one model and state it in the manifest: per-PoP keysets (default; each PoP accepts only credentials it signed, fully offline), a central store with atomic test-and-set (one network call), or eventual sync with a stated maximum double-spend window.
Token envelope
CaparraToken = {
1: uint, ; version = 1
2: kind, ; 0 stamp, 1 session, 2 bond
3: tstr, ; proof type, e.g. "op-ecash"
4: [+ ProofItem], ; one or more proofs
5: bstr .size 32, ; ctx
6: binding_type, ; 0 nonce, 1 tls-exporter, 2 tls-hello
? 7: tstr, ; action label for stamps, e.g. "post"
? 8: bstr .size 32, ; sid, for a stamp spent inside a session
? 9: [* BlindedMessage], ; replacement (session) or change (stamp) outputs
? 10: BondTerms ; locktime, refund key, forfeit script (bonds only)
}
ProofItem = [proof: bstr, sig: bstr / null] ; sig by the proof's client key over ctx, if the type has one
ctx = SHA-256(CBOR(["caparra/v1", origin, nonce_or_ts, kind, action, amount,
proof_type, binding_type, binding]))
binding:
nonce empty; the challenge nonce and quote-exp carry the binding
tls-exporter TLS-Exporter("EXPORTER-Caparra", "", 32)
tls-hello SHA-256(ClientHello.random || cookie_nonce_or_ts) ; Experimental TLS binding only
Binding profiles. Native clients, agents and SASL -PLUS use tls-exporter, so a captured token fails on any other connection. Browsers and extensions cannot read the TLS exporter, so they use nonce: the token is valid for one challenge within quote-exp, and an operator MAY price or refuse that weaker profile. A CDN that terminates TLS computes the exporter and forwards it to the origin. A typical envelope is 300 to 450 bytes per proof.
Proof types
| Proof type | Status | Verified against | Nullifier | Use |
|---|---|---|---|---|
op-ecash | Core, MUST | Operator’s own keyset | Y = hash_to_curve(secret) | Sessions, stamps, credit |
hashcash | Core, MUST | SHA-256 over ctx and a counter, d leading zero bits | Hash of the solution | No-money fallback with an operator-set weight |
op-grant | Core, SHOULD | Operator keyset, unit grant | Y | Free tokens issued after an attestation (email, CAPTCHA, invite) |
cashu-p2pk | Optional | Third-party mint keyset on the operator’s own list, with DLEQ | Y | Portable first-visit stamps and small escrowed bonds |
ln-preimage | Optional | SHA-256 of preimage against an operator macaroon (L402) | Payment hash | Interop with L402 clients |
pp-token | Optional | Privacy Pass publicly verifiable token (RFC 9578, blind RSA); a separate token type | Token nonce | Interop with Privacy Pass issuers |
Funding rails
| Rail | How credit is bought | Refund path | Role |
|---|---|---|---|
| Lightning BOLT12 | Client pays the operator’s offer; operator blind-signs units | New payment to the client’s offer | Reference rail |
| Cashu via Lightning | Client melts its ecash at its own mint to pay the operator’s invoice; the preimage proves payment | Lightning payment | Portable for ecash holders; the operator trusts no mint |
| Cashu direct | Operator swaps tokens from mints on its own list | Fresh ecash | Optional; the operator carries that mint’s risk |
| Ark VTXO | Out-of-round transfer into a timelocked script with a presigned forfeit output, at least ark_min | Cooperative release, or unilateral exit after the timelock | The only fully non-custodial escrowed bond |
| Hashcash | Work instead of money | None | Last-resort fallback |
| Operator grant | Attestation instead of money | None | Free tier, onboarding |
Receipts
Every state transition yields a receipt signed by op_key: {sid, transition, reason, amount, epoch, ts}. Each proof type defines a nullifier (table above), and sid = SHA-256("caparra/sid" || proof_type || nullifier of the first proof), so it is identical whichever carrier delivered the token.
Conformance details
- Versioning. Envelope field 1 and the manifest’s
versions; an unknown version getsVERSIONwith the supported list. - Errors.
INVALID,SPENT,KEYSET_EXPIRED,UNDERPAID,QUOTE_EXPIRED,UNSUPPORTED_TYPE,BINDING_MISMATCH,VERSION,RATE_LIMITED. HTTP carries them inCaparra-Error; SASL in the failure message. - Time. All times are UTC. An epoch is one UTC day. Clock skew is bounded by the manifest’s
clock_skew(default 60 seconds). - Keyset rotation. The manifest lists keysets with
final_expiry. A retired keyset keeps signing replacements forrefund_windowand keeps verifying untilfinal_expiry. - Failed actions. If the operator fails (for example HTTP 5xx) after consuming a stamp, it MUST return a replacement for it.
- Manifest authenticity without WebPKI (SMTP, SASL, Nostr): pin
op_keyon first use, or check the manifest hash in a DNSSEC-signed SVCB record.
Lifecycle and settlement
An honest session costs one non-refundable session fee (default 1 unit) and moves no money: the deposit token is consumed, and on release the operator blind-signs a replacement worth the deposit minus the fee. Money only moves when credit is bought, withdrawn, or forfeited.
| Instrument | Refundable | Where the value sits | Forfeit enforced by |
|---|---|---|---|
| Stamp | No | Consumed by the action | Not applicable |
| Session credential | Yes, minus the session fee, by re-issue | Operator credit (capped) | Operator withholds the replacement |
| Escrowed bond, Ark | Yes, by timelock | Client’s VTXO in a shared script | Presigned forfeit output; the operator only triggers it |
| Escrowed bond, Cashu | Yes, by timelock | Third-party mint | Operator swaps before the locktime |
Session states
- PRESENTED. The client sends an envelope of kind
sessionwith deposit proofs and blinded outputs for its replacement. Outputs total at most the deposit minussession_fee, MUST name the keyset active at this moment, and duplicate blinded messages are rejected. - ADMITTED(sid). Checks pass; the operator stores
sid, the nullifiers and the blinded outputs, and returns an ADMITTED receipt. Every later request in the session carries a signature by K over(sid, method, target, ts), so a publishedsidalone is useless. - RELEASED. On clean close or on expiry without a flag, the client claims its replacement at the manifest’s claim endpoint with a K-signature. The claim is idempotent and repeatable until
refund_windowends, so a lost response loses nothing. SASL logout and QUIC close need no in-band release for the same reason. The operator MAY defer release up to itsdetection_window, and a session can be flagged until it is released. - FLAGGED → FORFEITED. The operator withholds the replacement and signs a FORFEITED receipt with a reason code and an evidence hash. The receipt enters the forfeit log.
What the session credential is for. It does not price an unflagged abuser beyond the session fee. Its jobs are a concurrency limit (holding 1M concurrent sessions locks up 4M units, about $2,700 at 1 unit per sat) and a forfeitable deposit until release. The per-action stamp remains the main price.
Expiry without a release counts as RELEASED: if the operator goes silent, the client can claim its replacement for the whole refund_window.
Figure: Session state machine · shared by every carrier. The source tab shows this as an interactive diagram (widget
node/a5d34f30-c6a2); it is not part of this export. See the source doc.
The default path ends in a fresh credential; reaching a forfeit takes an explicit flag with an objective reason code and a signed receipt.
Escrowed bonds
Bonds are for higher-risk actions (first post on a new account, bulk mail, relay writes) and come in fixed tiers. Bond amounts never scale with load, so a capital-rich attacker cannot raise them to price out humans.
- Ark bond. The client transfers a VTXO into a script with three paths: operator plus Ark server spending to a destination presigned by the client before time T (forfeit); client plus operator spending anywhere (release); client alone after T (refund, with unilateral on-chain exit). The operator cannot redirect a forfeit to itself. This script is the debate’s construction, not a shipped Ark feature. Ark bonds MUST be at least the manifest’s ark_min (RECOMMENDED 50,000 sat) so a unilateral exit stays economical, and T MUST fall before the VTXO’s batch expiry.
- Cashu bond. A
cashu-p2pkproof locked to the operator’s NUT-28-blinded key, with locktime T and a client refund key (NUT-11). Release is an operator signature that lets the client swap at once; forfeit is an operator swap before T. The destination cannot be enforced, so the log is the only check. RECOMMENDED cap: 10,000 sat.
The locktime MUST satisfy:
T \geq t_{session} + w_{detection} + g
where w is the operator’s declared detection window and g is a grace period of at least 24 hours for mint or Ark-server outages. Operators SHOULD forfeit early rather than near T.
Forfeit destination and reasons
The owner’s rule stands: the operator chooses the destination. The manifest MUST declare it, wallets MUST show it with the claim rate before paying, and the spec RECOMMENDS a destination other than the operator.
| Destination | Meaning |
|---|---|
self | Operator keeps it; wallets flag this |
donate | Paid to a published BOLT12 offer or address |
burn | Settled to the operator’s own node, then destroyed in a batched on-chain OP_RETURN transaction with a published txid; never by abandoning ecash, because unspent ecash is a windfall for the mint |
arbiter | Held in 2-of-2 with a named arbiter for disputed reasons |
| Reason code | Objective test |
|---|---|
RATE | Actions per interval above the published limit |
QUOTA | Bytes or actions per session above the published cap |
PROTOCOL | Malformed or abusive protocol use |
REPLAY | Token reuse detected |
POLICY | Content-policy breach; requires the policy hash and an evidence hash |
Forfeit log
The forfeit log is an append-only Merkle log in the style of Certificate Transparency (RFC 9162). Each entry holds (sid, reason, amount, evidence_hash). Every epoch the operator publishes a signed tree head and a signed admission count. Clients verify that their own receipts are included and that tree heads are consistent, and MAY publish receipts so third parties compute value-weighted claim rates. Admission counts can still be padded, so a claim rate is a signal, not a guarantee.
Credit and withdrawal
Operator credit is called capped prepaid credit, never escrow. Funding that would push a balance above credit_cap is refused, unused credit can be redeemed through the funding rail within refund_window, and idle credit is refunded automatically.
Transport binding (Experimental)
The TLS 1.3/QUIC binding is a separate Experimental document that admits sessions before application code runs; it never judges abuse and is never required by the core. Its one capability the application bindings lack is pricing handshake floods on unauthenticated endpoints such as SMTP MX servers and Nostr relays.
Mechanisms, in build order
- DNS discovery (shared with all carriers). The SVCB/HTTPS
caparraSvcParam lists proof types and the manifest hash, next to the existingechparameter. - QUIC handshake priority with NEW_TOKEN (first prototype). After a session admitted by any carrier, the server issues a one-time NEW_TOKEN containing an encrypted
{sid, standing, expiry}, and a fresh one on every connection, because the token is visible on the path and RFC 9000 tells clients not to reuse tokens. Presenting it gives the next connection handshake priority at the cost of one AEAD decryption; it never grants application admission. It needs only a small patch to quinn’s token payload, and it is linkable, so it is available only in linkable mode. - TLS extension, ECH only. The client advertises the
caparraextension in its ClientHello. The server replies with a HelloRetryRequest carrying the challenge under that same codepoint (a server may only answer with extensions the client offered) plus a cookie, so it stays stateless. The second ClientHello carries the token inside the encrypted inner ClientHello, bound with thetls-helloprofile to its own ClientHello.random and the cookie nonce, because the exporter of an unfinished handshake does not exist yet; Finished then authenticates the token through the transcript. The HelloRetryRequest travels in clear and reveals that the server runs Caparra. A pre-informed client (from DNS) skips it and binds to a timestamp instead. Without ECH no token goes in the handshake and the client falls back to an application binding.
| Path | Extra round trips | Linkable |
|---|---|---|
| Pre-informed client with ECH | 0 | No |
| Uninformed client, HelloRetryRequest challenge | 1 | No |
| QUIC NEW_TOKEN handshake priority | 0 | Yes |
| No ECH | Falls back to HTTP or SASL | No |
Rules
- Check order. Address validation (QUIC Retry or HRR cookie) first, then size and keyset id, then the seen-set, and only then elliptic-curve verification. Each source address gets a verification budget.
- No new tokens in 0-RTT. A resumed PSK session inherits its
sid; 0-RTT data is accepted only for idempotent, cheap operations. - Terminators. A CDN or load balancer that terminates TLS acts for the operator. It passes the admission verdict to the origin as a PROXY v2 TLV
{sid, verdict}and receives forfeits back keyed bysid. - No transport forfeit signal. Forfeits use the core receipt on the application carrier; there is no TLS alert or QUIC error code for them.
- Codepoints. The extension aligns codepoint-for-codepoint with any revived Privacy Pass-in-TLS draft, so Caparra becomes a token type in that slot rather than a rival extension. The draft found by the debate (draft-pauly-privacypass-for-tls-00) has expired.
Advancement criteria. The binding leaves Experimental only after at least two independent HTTP or SASL deployments and an unforked rustls path, since rustls refuses arbitrary extension APIs and points implementers to standardisation.
Application bindings: HTTP, SASL and others
The HTTP Caparra authentication scheme and the SASL CAPARRA mechanism are the normative carriers; they deploy today behind a reverse proxy and reach every protocol that has an auth slot.
HTTP challenge
One 402 response offers Caparra next to other payment schemes, so clients use whichever they support. Privacy Pass PrivateToken challenges are defined for 401 responses (RFC 9577), so an operator that also accepts Privacy Pass sends that challenge on a 401.
HTTP/1.1 402 Payment Required
WWW-Authenticate: Caparra realm="forum", kind="stamp", action="post", amount="8", unit="cpr",
proof-types="op-ecash hashcash cashu-p2pk", bindings="tls-exporter nonce", hc-bits="22",
nonce="q8Zr…", quote-exp="60", manifest="/.well-known/caparra"
WWW-Authenticate: Caparra realm="forum", kind="session", amount="4", fee="1", unit="cpr", ttl="3600", nonce="…"
WWW-Authenticate: Payment id="…", method="lightning", intent="charge", request="…"
PAYMENT-REQUIRED: eyJ4NDAy…
HTTP credential and session
Authorization: Caparra token="<base64url CBOR envelope>"
- If
Authorizationalready carries application auth, the client sendsCaparra-Authorizationinstead. - On admission the server answers
Authentication-Info: sid="s_9f…", exp=3600, receipt="…". Later requests sendAuthorization: Caparra sid="s_9f…", ts="…", sig="…", where sig is the K-signature over(sid, method, target, ts). - Native clients and agents use the
tls-exporterbinding profile; a terminating CDN computes the exporter and forwards it to the origin, as RFC 9729 does. Browsers and extensions, which cannot read the exporter, use thenonceprofile. - The default session scope is the application session, not the connection, because HTTP/2 and HTTP/3 pool, coalesce and migrate connections.
Release, forfeit and credit
| Action | Wire form |
|---|---|
| Clean close | POST {claim}/{sid}/close with a K-signature, or expiry |
| Claim replacement | POST {claim}/{sid}/release with a K-signature; idempotent until refund_window ends; answers Caparra-Status: released; sigs="…"; receipt="…" |
| Forfeit | Caparra-Status: forfeited; reason="RATE"; receipt="…" on the 403 or 429 response |
| Buy credit | POST {fund} with the rail payload; answers with blind signatures |
| Withdraw credit | POST {fund}/withdraw with tokens and a refund destination |
| Forfeit log | GET {log}/{epoch} |
| Errors | Caparra-Error: SPENT (codes listed under Conformance details) |
Other carriers
| Carrier | Form | Status |
|---|---|---|
| SASL | CAPARRA and CAPARRA-PLUS (RFC 9266 tls-exporter channel binding, as SCRAM-PLUS), both starting with a server-first nonce, for SMTP submission, IMAP, XMPP, LDAP and MQTT 5 AUTH | Normative |
| ESMTP (MX to MX) | MAIL FROM:<a@b> CAPARRA=<token> extension; inbound MX has no SASL | Optional |
| Nostr | A NIP adding a ["caparra", token] event tag and a NIP-42 AUTH variant | Optional |
| ActivityPub | Plain HTTP binding on the inbox POST | Covered by HTTP |
| gRPC, WebSocket | HTTP binding on the upgrade or call | Covered by HTTP |
Interop with existing schemes
- x402: publish
caparraas an x402 payment scheme so x402 clients can pay Caparra stamps; privacy and refundability are the differentiators. Agents may pay in x402 while humans use Caparra credit. - MPP / draft-httpauth-payment: register
caparraas a payment method andbondas the first entry in the payment-intent registry. - L402: accept
ln-preimageas an optional proof type. - Privacy Pass: a Caparra payment can serve as the attestation step in the RFC 9576 architecture, after which a separate Privacy Pass issuer issues tokens; pp-token is accepted as a proof type.
Pricing and admission control
Only stamps follow load; bonds come in fixed tiers and every price has a published ceiling. At the ceiling the operator queues instead of raising the price, so nobody can buy an advantage over humans with capital they get back.
Load-based stamp price
p(u) = \min\left(p_{ceiling},\; p_{base} \cdot \max\left(1, (u / u_{target})^{k}\right)\right)
u is the utilisation of the priced resource (posts per minute, open sessions, CPU), u_target the comfortable level and k the steepness. A quote is valid for quote-exp seconds. An attacker who drives the price up pays it in non-refundable stamps, so surge pricing costs the attacker real money.
Admission under saturation
- Requests at the ceiling are served by fair queuing per credential class, never by highest bid.
- Operators SHOULD reserve a share of capacity for a human tier (grant tokens and low-volume credit) at or below the ceiling.
- The core never escalates price per identity, because identities are free to split. Per-origin escalation is possible only in linkable mode.
Proof-type weights
Each proof type counts as a number of units set by the operator. Hashcash difficulty SHOULD be set so a phone solves it in 1 to 3 seconds; GPUs and botnets solve it far faster, so its weight stays low and it is a last resort.
Calibration example
At about $67,000 per BTC (June 2026), 1 sat is about $0.00067. These figures are illustrative.
| Action | Assumed value to a spammer | Stamp | Cost to a human |
|---|---|---|---|
| Forum post | $0.002 | 8 sat ($0.005) | 20 posts a day = 160 sat ($0.11) |
| Signup | $0.01 | 64 sat ($0.04) | Once |
| Inbound mail | $0.0005 | 2 sat ($0.0013) | 50 mails a day = 100 sat ($0.07) |
Prices in sats move with BTC. The manifest MAY peg the unit to a fiat reference, re-quoted in every challenge.
Privacy
Blind signatures make sessions unlinkable to each other and to the funding event, but the spec does not promise unlinkability against a small operator, who can already link visitors by IP address, TLS fingerprint and timing.
What the protocol guarantees
- Session to session. Each replacement credential is blind-signed, so the operator cannot connect two sessions through their tokens.
- Funding to session. The operator sees a payment at funding time but cannot tell which tokens it produced.
- Theft resistance. Tokens are P2PK-locked to an ephemeral client key and bound to the challenge nonce or the TLS exporter, so an observer cannot reuse them.
Rules that protect it
- Fixed denominations. Amounts are powers of two units, because odd amounts are fingerprints. Clients also strip DLEQ proofs before presenting to the issuing operator, because a DLEQ reveals the blinding factor.
- Per-origin keys. Clients MUST use fresh ephemeral keys per token and MUST NOT reuse refund keys across origins.
- Linkable mode is per origin only. An operator may require linkable credentials (for example to give returning users standing, or for QUIC NEW_TOKEN handshake priority). The spec forbids credentials that are linkable across origins, so Caparra cannot become a global payment identity.
- Delayed issuance. Operators SHOULD batch and delay blind signing after funding, and clients SHOULD pay through BOLT12 blinded paths.
- Third-party mints. For
cashu-p2pk, the operator key is blinded with NUT-28 so the mint cannot see which operator a proof is locked to. Clients accept only aged keysets used by many wallets, and operators sweep in batches through Tor or Oblivious HTTP. Swap-on-receive inside the handshake is not allowed because it leaks per-session timing to the mint.
Regulatory context
From July 2027 the EU AMLR bars licensed crypto-asset service providers from anonymous accounts, and the Transfer of Funds Regulation (2023/1113) has required checks on self-hosted-wallet transfers above €1,000 since December 2024. Caparra’s model is that each operator runs a closed-loop issuer for its own service only, which looks closer to a merchant taking a deposit than to a custodian. That reading needs EU counsel before anyone relies on it.
Security analysis
With the review fixes in this revision, the residual risks are mainly economic and legal. The red team’s three potentially fatal attacks are each answered by a structural change above, and the table records what remains.
Fatal attack 1: unflagged abuse is free. A refundable bond costs an attacker only interest on locked capital: 1M sessions a day at 100 sat with a 1-hour lock tie up about 4.2M sat (about $2,800), roughly $0.77 a day at 10% cost of capital. Answer: stamps are the primary price, and bonds are a penalty layer.
Fatal attack 2: buying surge prices with refundable capital. If bonds scaled with load, a funded attacker could lock out humans for the cost of interest. Answer: only non-refundable stamps follow load, every price has a ceiling, and saturation is handled by fair queuing.
Fatal attack 3: trust collapse. Unprovable forfeits, a gameable claim rate and a few dominant mints would make bonds look like confiscation. Answer: operator-issued credentials remove mints from admission, forfeits need objective reason codes and signed receipts in a Merkle log, and destinations are declared up front.
| Attack | Severity | Mitigation in this spec | Residual |
|---|---|---|---|
| Abuse that never gets flagged | High | Stamps priced above value per action | Medium: calibration is the operator’s job |
| Surge-price manipulation | High | Load pricing on stamps only, ceilings, fair queuing | Low |
| Operator greed, false forfeits | High | Reason codes, receipts, forfeit log, declared destination, Ark presigned forfeits | Medium: the self destination stays allowed |
| Gamed claim rates | Medium | Signed admission counts, client-published receipts | Medium: denominators can be padded |
| Fake bonds from attacker mints | High | Operator-issued credentials; Cashu holders fund by paying the operator’s Lightning invoice, so no mint is trusted | Low |
| Mint rug, censorship, surveillance | Medium | Mints off the admission path; NUT-28 blinding; batched Tor sweeps; caps | Low to medium for cashu-p2pk users |
| Forfeit race against the locktime | Medium | Locktime at least session plus detection window plus 24 h; forfeit early | Low; NUT-11 post-locktime semantics to confirm |
| Replay across anycast points of presence | Medium | Per-PoP keysets or a central atomic seen-set; ephemeral key signature; nonce or exporter binding | Low |
| 0-RTT and resumption replay | Medium | No new tokens in 0-RTT; resumption inherits sid | Low |
| Verifier DoS with junk tokens | Medium | Address validation, size and seen-set checks before elliptic-curve work, per-source budgets | Low |
| Session stuffing (many actions, one session) | Medium | Per-action stamps; QUOTA caps per session | Low |
| Amount, keyset and timing fingerprints | Medium | Power-of-two amounts, aged keysets, delayed issuance | Medium |
| Free-tier farming by AI CAPTCHA solvers | Medium | Grants bound to origin and rate-limited; low weight | Medium |
| Hashcash hardware asymmetry | Medium | Lowest weight, last resort only | High, accepted |
| Linkable mode becoming a global identity | High | Linkability per origin only; cross-origin key reuse forbidden | Medium |
| Exclusion of people without wallets | High | Grant tier and hashcash; reserved human capacity | Medium |
| Legal exposure of operators and mints | High | Closed-loop operator issuers; foundation holds no funds | High until counsel confirms |
| Incumbent x402 wins by default | High | Bridge as an x402 scheme rather than compete | High |
Added after independent review: session hijack through a published sid (every request and release now carries a K-signature); issuer linking through the DLEQ blinding factor (DLEQ is stripped before presenting to the issuer); replay of QUIC NEW_TOKEN copied from the path (one-time tokens that grant handshake priority only); free unflagged sessions (a non-refundable session fee and release deferral up to the detection window).
Wallets, humans and agents
A human should never see a per-action payment prompt: credit is bought rarely and spent silently under a budget, which makes Caparra feel like a flat rate. Thirty years of failed micropayment schemes (Szabo’s mental transaction costs, Odlyzko’s flat-rate preference) say this is the make-or-break UX rule.
Human wallet (browser extension and mobile)
- Budget policy. Auto-approve stamps up to N units per action and M units per month per origin; ask only above that.
- Top-up, not pay-per-click. One Lightning or Cashu top-up per origin funds weeks of activity; the wallet tops up quietly within the budget. Extensions cannot read the TLS exporter, so the wallet uses the nonce binding profile.
- Trust display. Before the first payment to an origin, show its forfeit destination, its claim rate from the log, and whether deposits are escrowed (Ark) or prepaid credit.
- Free path first. If the origin offers grants, use them before spending money.
- Receipts. Store every receipt; verify forfeit-log inclusion; optionally publish receipts for independent claim-rate audits.
Agent SDK
- A Rust library with TypeScript and Python bindings that handles the 402 challenge, picks a proof type, funds and caches credit, and retries, using the tls-exporter binding profile.
- A command-line client,
cpr get <url>, in the style of Lightning Labs’lnget, so any agent with a shell can pay. - An MCP server exposing “fetch with Caparra” and “check budget” tools.
- Hard spending caps per task and per origin, enforced in the library rather than trusted to the model.
People without wallets
Operators SHOULD offer an op-grant tier funded by attestation (an email, an invite, a Privacy Pass token from an existing attester) and accept hashcash at a low weight. Reserved human capacity under load keeps this tier usable. The design accepts that the free tier is weaker against farming; its job is inclusion, not defence.
Rust reference implementation
The reference implementation is a Cargo workspace that reuses existing Bitcoin and networking crates and keeps every experimental piece behind its own crate, so a solo maintainer never carries a fork on the main path.
| Crate | Responsibility | Builds on |
|---|---|---|
caparra-core | Envelope, manifest, receipts, ctx hashing, session state machine; no_std-friendly | ciborium, secp256k1 |
caparra-issuer | Operator keyset, blind signing, funding and withdrawal flows | CDK (Cashu Dev Kit) |
caparra-verify | Offline verification, seen-set (memory, Redis, sled), check ordering, per-source budgets | caparra-core |
caparra-log | Forfeit log epochs, Merkle roots, inclusion proofs | rs_merkle or equivalent |
caparra-http | Tower middleware for axum and hyper, plus the caparra-gate reverse proxy binary | tower, hyper, rustls exporter API |
caparra-sasl | CAPARRA and CAPARRA-PLUS mechanisms | caparra-core |
caparra-fund-lightning | BOLT12 offers, refunds | LDK |
caparra-fund-cashu | Online swap of incoming Cashu, cashu-p2pk proofs | CDK |
caparra-fund-ark | Escrowed bond scripts | Rust Ark client (beta) |
caparra-wallet | Client wallet, budgets, receipts, cpr CLI, MCP server | All of the above |
caparra-quic | NEW_TOKEN handshake priority (Experimental) | quinn, small upstream patch |
caparra-tls | ECH-only TLS extension (Experimental) | rustls fork behind a feature flag |
Build order
caparra-core,caparra-verify,caparra-issuerand the test vectors that the NUT will cite.caparra-httpandcaparra-gate, so any site can deploy behind a proxy.caparra-walletwith thecprCLI and MCP server, so agents can pay from day one.caparra-fund-lightningandcaparra-fund-cashu; thencaparra-log.caparra-sasland the Nostr relay plugin.caparra-fund-ark,caparra-quic, thencaparra-tls.
Performance targets: offline verification under 1 ms per token on one core; seen-set lookups under 50 µs; no network call on the admission path. These are targets to benchmark, not measured results.
Standardization and governance
The spec ships first as Cashu NUTs, where the cryptography already lives and reviewers already exist, then as IETF documents split so the HTTP scheme never waits on the Experimental TLS work.
Documents
| Document | Venue | Status target |
|---|---|---|
| Caparra envelope, operator credentials, receipts | Cashu NUT | Accepted NUT |
| NUT-24 extension: kinds, ctx binding, release and forfeit headers | Cashu NUT | Accepted NUT |
HTTP Caparra authentication scheme | IETF individual draft (HTTP area) | Standards Track candidate |
SASL CAPARRA and CAPARRA-PLUS | IETF individual draft, IANA SASL registry | Standards Track candidate |
SVCB caparra SvcParam | IANA registration | Registered |
caparra method and bond intent | draft-httpauth-payment registries | Registered |
caparra x402 scheme | x402 Foundation | Accepted scheme |
| Nostr NIP, ESMTP extension, MQTT profile | NIPs repo, IETF | Optional |
| TLS 1.3/QUIC binding | IETF individual draft | Experimental |
IANA considerations and test vectors. Registrations needed: the Caparra HTTP authentication scheme, the Caparra-Status, Caparra-Error and Caparra-Authorization header fields, the caparra well-known URI, the SASL mechanisms, the SVCB SvcParam, and an Experimental TLS extension codepoint. Every document ships test vectors (envelopes, ctx hashes, receipts, log entries) generated by caparra-core.
Licensing
Code is dual-licensed MIT and Apache-2.0. NUT text follows the Cashu repository’s terms. Internet-Drafts are published under the IETF Trust’s legal provisions, which license code components in them under the Revised BSD License, so test vectors and examples stay reusable.
Governance
- Now: single maintainer, design changes through public proposal issues in the repository with a two-week comment window.
- After two independent implementations: move the spec to a small editors’ group, keep the code with its maintainers.
- Always: no foundation, company or maintainer holds user funds, runs a default mint, or ships a default allowlist.
Appendix A: Debate record
Six agents argued opposing positions in an opening round and a rebuttal round. The table records each contested decision, the two strongest positions and the verdict this spec adopts.
| Decision | Position A | Position B | Verdict |
|---|---|---|---|
| Bond custody | Cashu ecash: P2PK-locked proofs, offline-verifiable, native locktime refund | Non-custodial: operator-issued blind tokens, funded by Lightning or Ark | Operator-issued credentials in Cashu format; Cashu, Lightning and Ark are funding rails; third-party Cashu proofs optional for portable stamps |
| Economic core | Refundable bond (owner’s choice) | Red team: bonds are nearly free for unflagged attackers | Stamps are primary; bonds are an optional, capped penalty layer |
| Mint allowlists | Mandatory, to stop attacker mints | A new gatekeeper; avoid entirely | No global or default allowlist; Cashu holders fund by melting to the operator’s own Lightning invoice, so no mint is trusted |
| Protocol layer | Transport: admit in the TLS/QUIC handshake, universal across protocols | HTTP and SASL: abuse is judged where the semantics live | HTTP and SASL normative; TLS/QUIC a separate Experimental document |
| Transport forfeit signal | TLS alert and QUIC error code | Application receipt only | Application receipt only; transport admits, application judges |
| TLS path without ECH | Bond as a client certificate type | Collides with mTLS; no handshake carriage without ECH | Dropped; clients without ECH use the application binding |
| Forfeit destination | Operator keeps it (owner’s choice) | Red team: default to burn or donate | Operator chooses, must declare it; spec recommends non-operator destinations |
| Unlinkability promise | Unlinkable by construction | Small operators link by IP and timing anyway | Guaranteed between sessions and funding; not promised against small operators |
| Relationship to x402 | Compete with a Bitcoin-native standard | Skeptic: bridge, don’t compete | Bridge: publish caparra as an x402 scheme |
| Thesis framing | Superintelligence and the human high ground | Skeptic: unfalsifiable, deters operators | Spam economics only |
| Name | Stamp | Collides with Bitcoin Stamps and the taken crate | Caparra, pending a GitHub check |
The skeptic’s narrowest surviving version, a “bonded Privacy Pass” for Nostr relays, fediverse signups, Tor onion services and self-hosted API free tiers, is the recommended first deployment target even though this document specifies the full vision.
Independent review of version 0.1. A reviewer who had not seen the debate found five blockers: DLEQ proofs letting the issuer link sessions, browsers being unable to read the TLS exporter, a circular TLS handshake binding, session ids usable as bearer tokens, and a session credential that cost an abuser nothing. It also found seven major issues, including a false claim that fake Cashu mints fail an online swap. All are fixed in version 0.2.
Open questions and risks
The biggest unknowns are legal and adoption risks, not technical ones; each item below needs an answer before the spec leaves draft.
- EU legal reading. Does a closed-loop operator issuer count as a merchant deposit or as crypto custody under MiCA and AMLR (from July 2027)? Needs EU counsel; Consob required existing Italian providers to apply for MiCA authorisation by 30 December 2025.
- NUT-11 after the locktime. Confirm whether both the operator key and the refund key can spend after T, which would create a race; the spec currently tells operators to forfeit early.
- Ark escrow script. The presigned-forfeit bond is the debate’s construction, not a shipped feature, and Ark implementations are beta. Prototype it and get review from an Ark team.
- Privacy Pass in TLS. The draft the transport binding wants to align with has expired. Track whether it, or the TLS client-puzzles draft, revives.
- Classifier dependence. Bonds only bite when the operator flags abuse. Publish guidance on objective reason codes so small operators can use bonds without a classifier.
- Claim-rate integrity. Find a way to commit admission counts that resists padding, or drop claim rates as a trust signal.
- Wallet adoption. Lightning public capacity is about 4,900 BTC and Cashu has little consumer reach. Decide which wallets to partner with for the browser extension.
- Unit volatility. Decide whether the reference manifest pegs units to sats or to a fiat reference by default.
- Name. Check GitHub, domains and EU trademarks for Caparra before the first public repository.
- Benchmarks. Measure verification and seen-set performance against the targets in the implementation section.
Sources
- Lightning Labs: AI agent tools and lnget
- The Block: Lightning agent tools and Aperture
- Forkast: x402 infrastructure adoption
- Cashu NUT specifications
- IACR ePrint 2026/2174: Cashu system-level analysis
- Laurie and Clayton: Proof-of-work proves not to work
- draft-pauly-privacypass-for-tls-00
- draft-venhoek-tls-client-puzzles-00
- RFC 9729: Concealed HTTP authentication
- draft-httpauth-payment-01
- rustls feature manual (custom extensions)
- quinn-proto token source
- Cloudflare: TLS 1.3 middlebox failures
- Arkade hashlock contracts
- Consob press release on MiCA deadlines
- EU AMLR crypto rules
- Spark: Lightning network state 2026 (published by a competing protocol)