Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

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

FileSource tabRevStatus
socket-draft-00.mdSocket spec draft-0039Normative: the socket every deployment shares (byte layouts, algorithms, test vectors)
overview-v0.5.mdBNAP v0.5 (current)33Design overview, v0.5.1; informative where draft-00 covers the same ground
schemes/bnap-notes.mdbnap:notes scheme (from v0.4)23The default credential scheme, still in its v0.4 form
history/v0.3.mdv0.3 archive35Superseded; kept unchanged
history/v0.2.mdv0.2 archive53Superseded; 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:

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” means socket-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.

NotationMeaning
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
BigSizeThe variable-length unsigned integer of Lightning BOLT 1. Non-minimal encodings are MALFORMED
tu64An 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 streamBOLT 1 records: BigSize(type) ‖ BigSize(length) ‖ value, types strictly increasing, no duplicates
Entry listlp(stream₁) ‖ lp(stream₂) ‖ …, each stream a nested TLV stream. Used wherever a record holds several items
FlagA record with an empty value; present means true
KeyA BIP-340 x-only public key, 32 bytes
SignatureA 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)
emptyThe 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.

RangeUse
0–239Socket fields defined by this document
240–1000Signature records, never covered by any signature or MAC, as in BOLT 12
1001–999,999,999Reserved for future socket fields
1,000,000,000–1,999,999,999Experimental; never registered
3,000,000,001–3,000,000,999, odd onlyGREASE
Every other valueReserved

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

TypeNameValuePresence
0versiontu64, 1 for this documentRequired
2scope32 bytes: an action scope or a route scope (4.3)Required
4route_scopeFlag: scope names a route, not one actionOptional
6headersUTF-8, comma-separated lower-case field names the carrier hashes into the action scope, as the route lists themWhen the route lists any
8meteredFlag: the charge is set at settlement, up to the entry’s amount (7)Optional
10expirestu64Required
12infoUTF-8 URI of the manifest, on the same originOptional
14offer_keyKey that signed this offerRequired
16nonce16 random bytesRequired
18payEntry list of pay entries, in preference orderRequired unless attest holds a waive
20attestEntry list of attest entriesOptional
24issued_attu64Required when mac is absent
26nativeEntry list of native entriesExact-scope offers only
240offer_sigSignatureRequired
242mac32 bytes, HMAC-SHA256Required, except in offers published in advance (4.4)

A pay entry is a nested stream:

TypeNameValue
0schemeUTF-8 scheme ID, unique within pay. Required
2amounttu64, in the unit’s base denomination; the ceiling in a metered offer. Required
4unitUTF-8: sat, msat, an ISO 4217 code (minor units) or a CAIP-19 asset ID (smallest indivisible unit). Required
6quote_expirestu64; defaults to expires and MUST NOT exceed it
8paramsBytes defined by the scheme’s registry entry
10ratetu64: 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:

TypeNameValue
0schemeUTF-8 scheme ID, unique within attest. Required
2effecttu64: 0 required, 1 waive, 2 discount, 3 quota. Required
4valuetu64: basis points for a discount, from 1 to 10,000; actions per epoch for a quota, at least 1
6epochtu64 seconds, for a quota, at least 1
8paramsBytes 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:

TypeNameValue
0pay_indextu64, zero-based index into pay; that entry’s scheme MUST be class O. Required
2kindUTF-8 label from the scheme’s registry entry, such as bolt11 or l402-macaroon. Required
4dataThe artifact’s bytes. Required

4.2 OfferID, signature and MAC

The server builds an offer in this order:

  1. Write every record except the signature range and native. Let S₀ be their serialization in stream order.
  2. Compute OfferID = H_"bnap/offer"(S₀).
  3. 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 the native record.
  4. Let S be the serialization of every record outside the signature range, natives included. Then offer_sig = Sign(offer_key, H_"bnap/offer-sig"(S)) and mac = 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 version is supported, expires is in the future, and issued_at, if present, is not in the future;
  • that offer_key is listed for use offer in the origin’s pinned manifest and has not expired (9.3). Without a pinned manifest, the client is trusting on first use;
  • that offer_sig verifies;
  • that info, if present, is on the origin the offer came from;
  • that scope matches 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:

  1. Size. Reject an oversize object with TOO_LARGE.
  2. Parse and version. Reject a parse failure with MALFORMED, and an unsupported version with VERSION.
  3. Authenticity. An offer with mac passes only if the MAC verifies under a current or retained K_mac. An offer without mac was published in advance. It MUST carry issued_at, and it passes only if offer_key is a key this gate holds for use offer and lists in its current manifest, and offer_sig verifies under it. A gate MUST NOT trust a key because the offer names it. A failure here is BAD_OFFER.
  4. Expiry. Check expires, the chosen entry’s quote_expires, and that issued_at is not in the future. A failure is STALE.
  5. Scope. A mismatch is WRONG_SCOPE.
  6. Offered schemes. Every scheme the credential uses MUST appear in the offer, or the result is NOT_OFFERED.

5. The Credential

5.1 Records

TypeNameValuePresence
0versiontu64, 1Required
2offerThe Offer, byte for byte as receivedExactly one of offer and offer_id
4payNested stream: 0 scheme, 2 body, 4 witnessAt most one
6attestEntry list of nested streams: 0 scheme, 2 body, 4 witness; sorted by scheme ID, bytewiseOptional
8binding32 bytes: the RFC 9266 tls-exporter value of the client’s connectionOnly if a chosen scheme is class S
10extA nested TLV stream of extensionsOptional
12offer_id32 bytes: the OfferID of an offer the gate issued and still cachesExactly 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:

KindCanonical payment bytes
lnPayment hash, 32 bytes
onchaintxid, 32 bytes ‖ output index as 4 bytes big-endian
evmlp(CAIP-2 chain ID) ‖ token contract, 20 bytes ‖ payer, 20 bytes ‖ authorization nonce, 32 bytes
cardlp(processor name) ‖ lp(processor’s payment ID)

6.2 Use models

ModelStore keyRule
singleUseIdOne 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
reusablenoneNothing 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_window gets the stored response, if the carrier kept one, with replayed set; the action never runs twice.
  • A carrier that did not keep the response returns the receipt alone, with the scheme_result recorded 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:

TypeNameValuePresence
0versiontu64, 1Required
2statustu64: 0 OK, 1 FAILEDRequired
4replayedFlagOn replays
6chargedtu64, in unitRequired; 0 when nothing was charged
8unitUTF-8, the chosen entry’s unitRequired when the credential has pay
10offer_id32 bytesRequired
12digest32 bytes, the Digest of the request this Response answersRequired
14metered_unitstu64, the meter’s countRequired in metered offers
16scheme_resultBytes defined by the scheme: change signatures, a settlement referenceOptional
18receipt_keyKeyRequired
20settled_attu64Required
240ackSignature 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.

CodeNameMeaningHTTP
1VERSIONUnsupported socket version400
2MALFORMEDBad encoding, order or framing400
3TOO_LARGEOver a size limit413
4BAD_OFFEROffer MAC or signature fails402, fresh offer
5STALEOffer or quote expired402, fresh offer
6WRONG_SCOPEOffer is for another action or route402, fresh offer
7NOT_OFFEREDA scheme the offer does not list402
8POLICYProperties or a required attestation missing403
9UNDERPAIDValue below the effective price402
10BAD_CREDENTIALThe scheme rejected the credential; see scheme_code402
11BINDINGTLS binding mismatch or not checkable400
12SPENTA use ID is already committed under another ReplayKey409
13PENDINGThe same credential is still in flight409, retry_after
14RATE_LIMITEDA rate counter is full429, retry_after
15UNAVAILABLEStore, signer or verifier unreachable; the gate fails closed503
16REQUIREDThe route needs a credential and none was sent402, 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⁵³.

MemberValue
bnap1
originThe origin it covers, serialized as in RFC 6454: lower case, IDNA A-labels, default port omitted
seqInteger; increases with every change
issuedUnix time
max_ageSeconds; at most 2,592,000 (30 days)
rootHex key that signs this manifest
backupHex key of the offline key that may sign the next root’s manifest
keysOnline keys: [{"kid": hex key, "use": "offer" or "receipt", "not_after": time}]
requiredBoolean, the bnap-required flag (9.3)
limitsreplay_window, pending_timeout (seconds); max_offer, max_credential, max_response (bytes)
carriersCarrier 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]}]
sigHex 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 origin followed 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-version or Accept. 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

FieldDirectionValue
WWW-Authenticate: BNAP offer="…"Response, with 402b64u(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-CredentialRequestb64u(Credential)
BNAP-ContextRequest, optionalb64u(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-ReceiptResponseb64u(Response) on every response to a request that reached commit
BNAP-ErrorResponseb64u(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 trailer BNAP-Receipt.
  • Otherwise, for text/event-stream, it sends a final event bnap-receipt whose data is b64u(Response). The gate MUST strip any bnap-receipt event 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 detail text 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:

RegistryInitial contentsPolicy
Socket TLV typesSections 4, 5 and 8Standards action
Error codesSection 8.2Specification required, codes above 1,000
Rail kindsln, onchain, evm, cardSpecification required
Schemes, rails, verifiersAs section 11Expert review
Carriershttp; mcp by the MCP carrier noteSpecification 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.

VectorInputOutput
Tagged hashH_"bnap/offer"("")4d1dac84920fd0e91e9cdf1788ac22fed3a61f72bab6752010b0b191b05367ed
BigSize0, 252, 253, 65535, 65536, 2³²00, fc, fd00fd, fdffff, fe00010000, ff0000000100000000
tu640, 1, 256, 1791700000empty, 01, 0100, 6acb2c20
HTTP action scopePOST, target https://api.example/v1/chat/completions, the body in 10.3, no listed headers02be38b55551b02bee2daab2ca3d752e9538cc288f57a6dea67ccaf6971b3717
HTTP route scopeorigin https://api.example, route chat862d641e67813f6e78b20a81aa929519e66a978e69e8a4d3e27280990697f3a0
Offer keySHA-256(“bnap test offer key”)b28dad5f717c5144bdb0aa6db94bb6c79afa9b8bd9bc84825a2c1baafe64add9
OfferIDThe route offer below5c8640261f6f1de3206a2e9cd1c2599a2e9256547242061c95f9d2e06cf6696d
ContextOfferID, the action scope above, bnap:notes, httpsig:web-bot-auth, empty exte9f5603656d8e54dd25f9f7cee291c56e01cc8e6a80536185d69b1ed12158e6d
DigestThat Context, binding SHA-256(“tls-exporter placeholder”)7c8f6196ad3adb3cf3199c1ebb79ef8b84ae26d20a01f3a89e336666d530d4c9
Digest, other connectionThe same, binding SHA-256(“other connection”)c9ef748b8fd377c66e6392311fe2e69adcdc30439c6c486444c11dada36a207a
UseIdsnotes Y = SHA-256(“note secret 1”); bot key SHA-256(“bot key thumbprint”)b7ca97d6…232b6456, 381a38d9…4bc2fb5e
ReplayKeyThat Context, both bodies, both UseIds74bd8a797c022ff864706809c6e9b23e9affd1829a093db365178db29b0c2771, the same on both connections
Attestation onlyExact offer 2511ffa4…f3dadfc8 whose only entry is an httpauth:DPoP waive; no pay, ext or bindingContext 04500cf8…1d5231ca, Digest 475c3682…550c3f47, ReplayKey c9248cf0…8961be9c
Native envelopeExact offer 919103b5…6a2e7562, payment:lightning/charge, bytes “MPP credential placeholder”Context 38a036b6…6cbb1b78, Digest 198cd8d6…f0a2a04e
RailIdln, payment hash SHA-256(“preimage”)5f210bd3b07d0ff1fc78e2f6715ddcad5dcb2bc0147b06d0261c5703f1867744
Native invarianceThat exact offer with and without a native recordSame 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 mac signed 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.

ChangeWhy
The Credential is TLV, not CBOROne encoding for every socket object; one parser to harden
Scheme payloads split into body and witnessv0.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 routeOne round trip per call for the LLM proxy; still one request per credential
Signature range 240–1000, as in BOLT 12Room for more signature types later
The credential travels in BNAP-Credential, the receipt in BNAP-ReceiptAuthorization stays free for the API’s own key
Receipts can arrive in a trailer or a final stream eventMetered, streamed completions know their charge only at the end
New error codes UNDERPAID, BAD_CREDENTIAL, BINDING and UNAVAILABLE, all numberedEvery failure path in the algorithms has a code
The ack signs the whole Response streamSame fields as v0.5.1’s formula, and extensible
The ReplayKey covers bodies and every ID, not only committed onesReview of draft-00: two clients with reusable credentials could share a ReplayKey and receive each other’s responses
Rails, not schemes, supply canonical payment bytesSchemes are untrusted; a wrong RailId would let one payment count twice
Metered offers carry a rate, and Responses the metered unitsA client can check the charge, not only its ceiling
Credentials may name their offer by OfferIDKeeps credentials inside common header limits
The HTTP target is hashed exactly as received, plus listed headersNormalization was ambiguous and could pay for one action and run another
The manifest backup changes only through a rotation; the issued time is checkedStops 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.

TermMeaning
SocketThe rules every deployment shares
PlugAny swappable block: a scheme, rail, carrier, store, signer, pricer, wallet or agent adapter
SchemeA way to prove payment or a property, such as blind notes, L402, x402 or Privacy Pass
BadgeA label for plug mixes guaranteed to interoperate, such as Core-1
HTTP+BNAP/LNBNAP 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 decisionChoice
ArchitectureA universal plug: one socket, plugs for everything else
BaselineA badge, not a gate: any mix that keeps the socket rules is BNAP; Core-1 labels the mix guaranteed to interoperate
Non-Bitcoin railsPluggable with guardrails: off by default, own unit, held until final
NameBlind-Note Admission Protocol; blind notes are the default scheme
Earlier decisions keptModest 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 gatewayAn LLM inference proxy: per-call, metered pricing in front of an OpenAI-compatible endpoint
Agent-1No 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.

PlugSideWhat it doesOn the wireExamples
Credential schemeBothProves value, yields use IDs, settles change or refundsYesbnap:notes, L402, MPP Lightning, x402, ACT, Cashu NUT-24
Attestation schemeBothProves a property that waives, discounts or meters a priceYesPrivacy Pass, ARC, Web Bot Auth, OAuth with DPoP, NIP-98, World ID
Funding railServerCreates payment requests and reports their status. Operator-trustedPartlyPhoenixd, LDK Node, NWC, LND, CLN, Ark, Fedimint, Spark, Cashu mints, x402 facilitators, Stripe or Tempo
VerifierServerChecks a credential locally or through a facilitator, in two phases: verify, then settleNoLocal, x402 facilitator, MPP processor
CarrierBothDefines the scope and carries offers, credentials and receiptsYesHTTP, MCP, SASL, Nostr, gRPC, WebSocket, A2A
PricerServerMaps an action to a pricePrice onlyStatic table, load-based, per-token metering, fiat-reference feed
JudgeServerAdmits, queues or refuses before commit; hooks for abuse afterNoRules, WASM policy, classifier sidecar
Use storeServerAtomic records for single-use, rate-limited and reusable credentials and for rail payments. Operator-trustedNoPostgres, SQLite, FoundationDB, Redis with append-only log
SignerServerSigns only messages the socket built, and blind signatures up to the change cap the socket set; keys stay in its process. Operator-trustedKey IDs onlyFile, separate process, HSM, seed wrapped at rest by a KMS, threshold signer
FrontServerPuts the gate in front of an existing APINoTower layer, standalone proxy, Envoy ext_authz, Caddy forward_auth, nginx auth_request
Pay sourceClientPays a payment requestNoNWC, LDK, lnget, Cashu, Ark, Spark or Fedimint wallets, custodial APIs, cards via MPP
Spend policyClientCaps, allowed schemes, required propertiesNoBudget files, NWC budgets, AP2 mandates
WalletClientPicks an offer and builds the credentialNoNotes wallet, L402 client, x402 signer
Agent adapterClientGives any model the same three tools: paid fetch, budget, receiptsNoMCP 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.

TypeFieldMeaning
0versionSocket version, 1
2scope32-byte hash of the action, defined by the carrier to cover content and recipient
8meteredOptional: 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
10expiresUnix time after which the offer lapses
12infoURI of the manifest
14op_key_idWhich online offer key, listed in the manifest, signed this offer
16nonce16 random bytes
18payAccepted 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
20attestAccepted attestations: scheme ID, parameters, and effect (required, waive, discount or quota)
24issued_atSelf-issued offers only
26nativeArtifacts the server made for one pay entry, such as an invoice, a macaroon or x402 requirements. Excluded from the OfferID; each embeds it
odd, reservedGREASEIgnored by every receiver; servers SHOULD include one
240offer_sigBIP-340 signature by the online offer key over every record below 240
242macServer 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.

ClassHow the credential is tiedExamples
SA signature over the Digest: offer, chosen schemes, extensions, payloads and TLS bindingbnap:notes, ACT
AA client-made artifact embeds the Context: offer, chosen schemes and extensionsx402 exact on EVM with nonce = Context, NIP-98 tags, a DPoP claim, a Web Bot Auth signed component, World ID signal, Hashcash
OA server-made artifact embeds the OfferID: one offer and its scope, but not the client’s other choicesL402 macaroon caveat, MPP challenge id, Lightning invoices (payment:lightning/charge, x402 on Lightning), Privacy Pass and ARC redemption contexts
NoneA bearer credentialAllowed 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 modelRuleExamples
singleEach use ID admits one actionBlind notes, per-action Lightning, Privacy Pass
rate(k, epoch)At most k actions per use ID per epochARC, World ID nullifiers
reusable(quota)A metered allowance per use IDOAuth 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. require applies 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.

PropertyValues, weakest to strongest
usereusable → rate-limited → single, as the socket enforces it
bindingnone → O → A → S
theftbearer → key-witnessed
link-paymentlinkable to the payment → unlinkable from it
link-actionslinkable across actions → unlinkable across them
issuer, as the server trusts itthird party → facilitator → self-issued
verifyonline facilitator → offline
finalityreversible → freezable → final at settlement → final on verification
custody, as the client trusts itthird-party custodial → operator IOU → client self-custody
refundnone → out of band → atomic in protocol
pqno → yes
maturityexperimental → 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 price is a map of units.
  • The gate never lists in pay an entry whose effective vector fails the route’s require, and refuses to start if a route can be satisfied by none of its enabled schemes.
  • Experimental schemes need allow_experimental on 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:

  1. Verify offer_sig with an offer key the pinned root signed; on an unknown key, refetch the manifest.
  2. Keep the offers the client can use.
  3. 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.
  4. 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.
  5. 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.

SystemPlugs intoBindingUse modelFit
Blind notes (bnap:notes)CredentialSsingleNative, the default
L402 (bLIP-26)Credential + fundingO, OfferID caveatsingle, enforced by the socketAdapter
MPP (draft-httpauth-payment)Credential envelope + methodsO, OfferID in the HMAC’d idsingleAdapter
x402 on EVM chainsCredential + facilitator verifierA, client nonce = ContextsingleAdapter, guarded rail
x402 on LightningCredential + facilitator verifierO, invoicesinglePending a published spec
Cashu NUT-24, NUT-18, NUT-26Credential + carrierO when locked to the operator’s key, else NonesingleAdapter; unlocked only at price 0
Cashu NUT-22 blind authCredentialNone, bearersingleNative at price 0
ACTCredentialSsingleNative, reserved
Privacy Pass (RFC 9576–9578)AttestationO, redemption contextsingleNative
ARCAttestationO, presentation contextrateNative
Web Bot AuthAttestationA, Context as a signed componentreusableAdapter
OAuth and API keys, with DPoPAttestationA with a DPoP claim, else NonereusableAdapter; without DPoP, never waive or discount
NIP-98Attestation + Nostr carrierA, tagsreusableAdapter
World IDAttestationA, signalrateAdapter
Hashcash, AnubisCredential, unit workAsingleAdapter, weak
NWC (NIP-47)Pay source, funding rail——Native
Fedimint, Ark, SparkPay source, funding rail——Adapter
MCP, A2ACarrier, agent adapterScope from tool and arguments—Adapter
AP2 mandatesClient spend policy——Adapter
Cloudflare pay-per-crawlPricer signal only——Does not fit

Naming and the registry

Scheme IDs reuse existing registries as <registry>:<id>[@<qualifier>], so BNAP never renames anything:

PrefixSource registryExamples
bnap:BNAP’s own schemesbnap: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-2x402:exact@lnbtc, x402:exact@eip155:8453
pp:Privacy Pass token typespp:0x0002
httpauth:HTTP authentication schemeshttpauth:L402, httpauth:DPoP
httpsig:HTTP message signature tagshttpsig:web-bot-auth
cashu:, nip:Cashu NUTs, Nostr NIPscashu:nut24, nip:98
vnd.Unregistered vendor schemesvnd.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.

PropertyValue
use / binding / theftsingle / S / key-witnessed
link-payment / link-actionsunlinkable, except that the first action after funding can be correlated by timing / unlinkable
issuer / verifyself-issued / offline
finality / custodyfinal on verification / operator IOU
refundatomic in protocol
pq / maturityno / 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.

RailFinalityStatus in the reference implementation
Lightning (Phoenixd, LDK Node, NWC, LND, CLN, LSPs)FinalDefault
Ark, Spark, Fedimint, Cashu mintsFinal once settled; custody variesOptional plugs
On-chain BitcoinFinal after confirmationsOptional plug
Stablecoins (x402 on EVM chains, Tempo)Final at settlement, freezable by the issuerGuarded, off by default
Cards (MPP through Stripe)Reversible for monthsGuarded, 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, eur or a CAIP-19 asset) and is never mixed into sat credit.
  • 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.

CarrierScope coversOffer, credential and receipt travel inStatus
HTTPMethod, normalized absolute URI, Content-DigestWWW-Authenticate, Authorization, Authentication-InfoCore-1
MCPServer URI, tool name, RFC 8785 canonical JSON of the argumentsJSON-RPC error data, request _meta, result _metaAgent-1 badge
SASLService, mechanism and server nonceSASL challenge and responseOptional
NostrRelay URL and event IDEvent tag and relay OK message; offers self-issuedOptional
gRPCService, method and message digestMetadataOptional
WebSocketEndpoint and message digestPer-message envelopeOptional
A2AAgent card and task digestTask metadataOptional

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 plugChoices
Agent adapterMCP server, OpenAI, Anthropic or Gemini tool definitions, LangChain, LlamaIndex, the bnap CLI
WalletNotes wallet with seed recovery, L402 client, x402 signer, any third-party wallet that implements a scheme
Pay sourceNWC, LDK, lnget, Cashu, Ark, Spark or Fedimint wallets, custodial APIs, cards through MPP
Spend policyBudget 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_sig with 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.
TierThe client hasExample
0Any single-scheme clientlnget for L402, an x402 or MPP client
1A BNAP wallet plus any pay sourcebnap get with an NWC connection
2A BNAP wallet with its own seed and several schemesPicks 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.

CrateRole
bnap-coreThe socket: Offer, envelope, Digest, use store logic, outcome state machine, receipts, test vectors
bnap-plugThe 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-cliThe 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:

WayForIsolationRelease
Compiled in (Cargo features)First-party plugsSame processv0.5
Sidecar over gRPC or HTTP, same interfaceAny language, heavy dependencies such as an x402 facilitator, LND or a Python classifierSeparate processv0.6
WASM component (WIT world bnap:plug, WASI 0.3)Third-party schemes, pricers and judgesSandbox with fuel and memory limits, no ambient networkv0.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.

BadgeRequires, on server and clientGuarantees
Core-1The socket, bnap:notes, HTTP, Lightning BOLT11 fundingAny 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-1Core-1 plus the MCP carrier and httpauth:L402 or payment:lightning/chargeAgents 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-FundingA separate unit, credit held until finalCards and stablecoins can fund credit without weakening sat credit
Nostr-1, SASL-1The carrier profilePriced 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.

ThreatClosed by
A proxy strips the strong scheme from an offerSigned offers; the chosen schemes inside the Context
A proxy deletes the BNAP header so the client pays nativelybnap-required for clients that saw the manifest. A tier-0 client cannot detect it
A weak scheme satisfies a strong routeRegistry vectors, effective vectors over scheme, rail and verifier, per-property checks, unknown = weakest
One payment admits actions through several schemesCanonical rail IDs committed with the use IDs; one value credential per request
An observer re-sends a credential with altered ext or attestationsThe Context covers attestation IDs and ext; bearer attestations cannot waive or discount; class O is registered as bearer
A reusable token treated as single-useUse model from the registry, enforced by the use store
Stolen cards fund spamReversible rails fund credit only, in their own unit, held until final
A hostile scheme admits unpaid actionsContained 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 notesNo plug reaches a key; the signer signs only bnap-core’s messages and change within the cap
Native challenges used as a DoS amplifierOne invoice per offer nonce, cached or lazy natives, rate limits
A first-contact attacker pins a hostile keyPinning only for HTTPS-fetched manifests, max_age capped at 30 days, backup key, higher seq replaces
A crash leaves uses PENDINGpending_timeout moves them to FAILED
A route that nothing can satisfyThe gate refuses to start
Prompt injection steers an agent’s paymentSpend policy in code; the model never chooses
Clients fingerprinted by their schemesClients never advertise capabilities
Privacy split across many schemesAt 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.

ReleaseAdds
v0.5The 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.6Sidecar 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.7WASM component plugs; Privacy Pass, ARC and ACT schemes

Documents, in order:

  1. 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.
  2. NUT-XX key-secret proofs and NUT-XX HTTP 402 with a self-issued mint: the bnap:notes scheme, for the Cashu NUTs.
  3. HTTP Payment Method registration of cashu, and support for registering lightning, in the empty registries of draft-httpauth-payment.
  4. The MCP carrier note, aligned with the MPP and x402 MCP transports.
  5. 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.

QuestionPositionsVerdict
Where can plugs attach?Architect: anywhere. Skeptic: anywhere except the credential, which only registered suites may fillAnywhere, but credential and attestation schemes need registry entries with property vectors
Who states a scheme’s properties?The plugin, or a registryThe registry, set by conformance results; unknown counts as weakest
Offer authenticationUnsigned (v0.4), or a signed offer set plus pinningSigned offer set plus pinning; a blocker even before v0.5
BaselineNotes mandatory everywhere, or no baselineOwner: a badge, not a gate (Core-1)
Non-Bitcoin railsInto sat credit, a separate unit held until final, or noneOwner: pluggable with guardrails
Credentials per requestOne scheme, or one payment plus attestationsOne value credential plus any attestations
Reusable credentialsExclude them, or model themUse models: single, rate-limited, reusable
Scheme namesA new namespace, or existing registriesExisting registries with prefixes
Plug runtimeNative only, or WASM for third partiesCompiled now, sidecars next, WASM after
NameRename, or keepOwner: keep Blind-Note Admission Protocol

Earlier rounds

RoundSettled
1, 9 OctoberThe operator issues its own notes; per-action prices over refundable bonds; application carriers first
2, 10 OctoberOne primitive, three verbs; the signature covers the whole spend; bonds and transport binding leave the core
3, 10 OctoberNotes 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

FindingFix in v0.5.1
Blocker: one Lightning payment offered under L402, MPP and x402 could admit one action per schemeSchemes 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 choosesOfferID; class O replaces N; class A only for client-made artifacts
Blocker: payments through native headers had no Context, Digest or replayAdapters embed the OfferID; the socket builds a synthetic envelope
Blocker: “a hostile plugin can never cheat” was falseTrusted store, signer and rails; prefixed use IDs; a capped signer for change; the claim restated
Digest malleable by anyone who saw the credentialContext covers attestation IDs and ext; no waive or discount for bearer attestations
Attestation effects undefinedWaive, then multiplied discounts; one transaction for every use ID; rules on FAILED
Replay broken across connections and crashesReplayKey without binding; re-verification; cached responses; pending_timeout
One unit per offerAmount and unit per pay entry; price as a map of units
Metered actions overpaidOnly refundable or up-to schemes; the ack covers the charge
Pinning scheme lists repeats HPKPPin the root key only; bnap-required; tier-0 limit stated
Properties depend on rail and facilitatorEffective vectors computed by the gate
Invoices as a DoS amplifierOne invoice per nonce, cached or lazy natives, rate limits
MinorTwo 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 pay and attest record 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 cashu registration.
  • 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:

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:

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.

#wheredraft-00 saysother source saysresolution (draft-00 wins unless the vector proves otherwise)owner decision needed (yes/no)
1Credential 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_idOverview: Credential = {v, offer, pay?, attest[], binding, ext} in deterministic CBOR, with no offer_id. Ref: the credential vector (581 bytes) is TLVTLV, as draft-00 section 15 lists; the vector agrees. The overview is to be brought into lineno
2Scheme 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 witnessesOverview: 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 DigestBody and witness, as draft-00 section 15 lists (the v0.5.1 Digest was circular)no
3Context: 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…12158e6dDraft-00; the vector proves itno
4Digest: 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…a36a207aDraft-00; the vector proves itno
5ReplayKey: 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 bytewiseOverview: H_"bnap/replay"(Context ‖ sorted socket use IDs). Ref: replay_key() follows draft-00; credential.replay_key = 74bd8a79…9b0c2771, attest_only.replay_key = c9248cf0…8961be9cDraft-00, as section 15 lists; the vector proves itno
6Response 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…bfef6f70f7Draft-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
7Signature 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 rangeOverview: “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 macDraft-00, as section 15 lists; the vector proves that records above 1000 are coveredno
8Offer record 14: draft-00 4.1; overview “Offer”offer_key: the 32-byte BIP-340 key that signed the offerOverview: 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…fe64add9Draft-00; the vector proves itno
9Offer 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 offersOverview: 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 500Draft-00 (section 15 lists route scope and rate); the vector agreesno
10Metering: 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 chargedOverview: “For metered actions, the charge is the amount used”; no rate, no metered_units. Vectors: Response charged 340, metered_units 850Draft-00, as section 15 lists; the vector agreesno
11Discounts 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 2000Draft-00; the vector proves basis points and pay. The overview example and the Caparra config should followno
12Effect in the manifest: draft-00 9.1Routes 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.1no
13Metered route in the manifest: draft-00 9.1, 10.3Routes 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 callVectors: the manifest has "price": {"sat": 2100}, the ceiling; the rate 500 appears only in the offerThe vector shows that price is the ceiling. Whether the manifest should also carry the rate is openyes
14Error 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 16Draft-00’s table. Add REQUIRED to the section 15 rowno
15HTTP 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 12Overview: 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/bnapDraft-00, as section 15 listsno
16HTTP 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 SHOULDOverview: the scope covers “Method, normalized absolute URI, Content-Digest”. Ref: http_scope() follows draft-00; action scope 02be38b5…971b3717Draft-00, as section 15 lists; the vector proves itno
17Native 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 changeOverview: 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…f0a2a04eDraft-00; the vector proves it. Add a section 15 rowno
18UseId 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 nonceOverview: 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…f1867744Draft-00; the vector proves it. Section 15 lists only who supplies the rail bytes; consider a row for the hashing toono
19Who 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 countersOverview: “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 splitDraft-00, as section 15 lists. The Caparra trait sketch should followno
20Use 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_idOverview: reusable(quota), “A metered allowance per use ID”Draft-00no
21Timeout 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 defaultsOverview: “pending_timeout (default 300 s)”; Caparra config replay_window_s = 86400, pending_timeout_s = 300. Vectors: the manifest uses 86400 and 300Draft-00 is silent; the vector is an example, not a default. Choose defaults (300 s and 86,400 s) or make both members requiredyes
22ES256 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 assignedyes
23GREASE 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 CredentialOverview: “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 allowsDraft-00 defines no GREASE scheme IDs or carrier names. Keep or drop that overview featureyes
24Native 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 nonceOverview: “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 reasonyes
25Class 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 0Overview 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 changeyes
26Unit strings: draft-00 4.1; overview “Offer”, “Value rails”sat, msat, an ISO 4217 code (minor units) or a CAIP-19 asset IDOverview: “sat, an ISO 4217 code or a CAIP-19 asset”, no msat; the guardrails write usd, eur in lower case. Vectors: sat onlyDraft-00: msat is allowed, and fiat units are ISO 4217 codes, which are upper case (USD, EUR). Draft-00 could say that units compare exactlyno
27Manifest 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 daysOverview: “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 backupDraft-00, as section 15 lists for the backup and issued-time rulesno
28Tab references: draft-00 1, 12, 15; overview “Abstract and status”, “Security and privacy considerations”Calls the overview “the first tab”, which is still trueOverview: 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.2Texts stay verbatim; README.md maps each tab reference to its fileno
29Stale open question: draft-00 4.1, 14; overview “Open questions”Defines the pay and attest encodings and publishes vectorsOverview, 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 lineno
30Header size, internal to draft-00: draft-00 2, 10.2A 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 bothNo vector settles it. Raise the field minimum to 21,846 bytes, or cap Credentials on HTTP at 9,216 bytesyes
31bnap:notes tab, outside the three-way comparison: draft-00 2, 5.1, 11; schemes/bnap-notes.mdSchemes 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 payloadsThe 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 3Draft-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 choosingyes
32x402 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 actionOverview: 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/x402The 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 specno
33MPP 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-ReceiptOverview: “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-InfoDraft-00: BNAP-Receipt travels next to MPP’s Payment-Receipt. The MPP registry entry should name that fieldno
34L402 (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 PRCite 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 isno
35MPP native entry (end-to-end finding): draft-00 4.1, 4.2 step 3, 5.4; registry payment:lightning/chargeThe 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 artifactRegistry (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 challengeThe 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 revisionno

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 #TopicProvisional decision
13Metered route in the manifestThe manifest carries the price ceiling only. The per-unit rate travels in the pay entry parameters of the offer. No new manifest member.
21Timeout defaultspending_timeout defaults to 300 s and replay_window to 86,400 s when the manifest omits them.
22ES256 co-signatureNot in v0.5. No record type is assigned; BIP-340 stays the only receipt signature. Revisit with an HSM signer.
23GREASE for scheme IDs and carrier namesNot in v0.5. Odd GREASE TLV types stay mandatory-to-ignore and servers keep sending one.
24Native challenge mintingTreated as MUST: at most one invoice per offer nonce, plus a per-address mint rate limit. Stricter than draft-00 SHOULD, safe against amplification.
25Class None value schemesAdded to section 7: a binding-class None scheme is admissible only at price 0 and never with a waive or discount effect.
30Credential size on HTTPThe HTTP carrier caps a Credential at 9,216 bytes (12,288 base64url characters). Larger credentials are refused with TOO_LARGE.
31bnap:notes vs draft-00The 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:

NotationMeaning
PointA 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, nThe 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 IDThe 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, AThe private and public key of one keyset for one amount, A = a·G
NUT-xxThe Cashu specification of that number in the cashubtc/nuts repository

2.2 Terms

TermMeaning
IssuerThe operator’s Cashu-style mint. It signs notes for its own origin only, and is the only party that accepts them
BNAP keysetA Cashu keyset (NUT-01, NUT-02) whose keys sign notes and nothing else
NoteA Cashu proof {id, amount, secret, C} whose secret is hex(K) (section 4)
Note keyThe private key k_n of a note and its x-only public key K
Yhash_to_curve(secret), the note’s single-use ID
ValueWhat verify reports: the sats the credential brings, after input fees (6.3)
Changevalue - charged at settlement, returned as signed outputs (section 7)
Fund quoteA 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

TypeNameValuePresence
0inputsEntry list of input entries (3.2), sorted by key, strictly ascending bytewiseWhen the credential spends notes
2outputsEntry list of output entries (3.3), in the client’s orderWhen value > 0 (6.3)
4fundUTF-8 quote ID of a paid NUT-23 BOLT11 mint quote, 1 to 64 bytesWhen 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:

TypeNameValue
0keyset33 bytes: the full keyset ID. Required
2amounttu64: a power of two, at least 1, in sat. Required
4keyKey: K, 32 bytes. The note’s secret is hex(K). Required
6CPoint: 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:

TypeNameValue
0keyset33 bytes: the full ID of a keyset the offer lists as active (5.2). Required
2amounttu64: a power of two, at least 1, in sat. Required
4blindedPoint: 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

TypeNameValuePresence
0input_sigs64 × m bytes, where m is the number of inputs: the Signature by each input’s K over the Digest, in input orderWhen the body holds inputs
2fund_sigSignature over the Digest by the private key of the fund quote’s NUT-20 pubkeyWhen 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 byte 00 in 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 key that 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 same K twice;
  • two outputs with the same blinded point;
  • a witness whose records do not match the body: input_sigs present without inputs, absent with them, or not exactly 64 bytes per input; fund_sig present without fund, 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 a bnap:notes credential, or in the witnessed melt of a withdrawal extension (section 16), a NUT-05 melt in the key-secret melt context. 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 K in the melt context of the key-secret proofs NUT (nuts/xx-key-secret-proofs.md) (provisional). The issuer lists melt in the contexts of its key-secret NUT-06 info only if it implements such an extension, and otherwise leaves contexts empty, so that its notes are spent only in external contexts: a bnap:notes credential. 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 unit sat.
  • 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 of k_n, so a summed key could steal another holder’s note.
  • Denominations. Amounts are powers of two. A wallet uses each K once, at one origin.
  • Outputs. Every B' is a valid point other than infinity. A credential is never rejected because one of its B' 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 a B' 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:

TypeNameValuePresence
0issuerUTF-8: the absolute https URL of the issuer’s Cashu API base, on the origin the offer came fromRequired
2keysetsEntry list of keyset entries (5.2), sorted by id, strictly ascendingRequired, at least one entry marked active
4max_inputstu64, at least 1; 16 when absentOptional
6max_outputstu64, at least 1; 16 when absentOptional
8max_fundtu64, in sat: the gate accepts funding requests (8.1) on this route up to this amountWhen the route offers funding inside the 402
10fund_quoteNested stream (5.3): a quote made for this clientExact-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

TypeNameValue
0id33 bytes: the keyset ID. Required
2input_fee_ppktu64: the keyset’s NUT-02 input_fee_ppk; 0 when absent
4activeFlag: 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

TypeNameValue
0quoteUTF-8 quote ID, 1 to 64 bytes. Required
2requestUTF-8: the quote’s BOLT11 invoice. Required
4amounttu64, in sat: the quote amount. Required
6expirytu64: the Unix time until which the invoice can be paid. Required
8pubkey33 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:notes is 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_quote MUST 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_quote in an offer for a route whose require sets link-payment to unlinkable, because a credential that redeems it is linkable to the payment (section 14) (provisional). On such a route it also puts no max_fund in 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 its offer_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.” The BNAP-Fund request 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:notes defines 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 expires and the entry’s quote_expires; its admission cutoff is that deadline minus pending_timeout minus 60 seconds. A gate that lists bnap: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:notes in an offer whose cutoff is less than 60 seconds after the offer is issued, and SHOULD leave several minutes. With the default pending_timeout of 300 seconds, an exact-scope offer that lists bnap:notes therefore 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_timeout at 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).

  1. Form. The body and witness pass 3.5 (NON_CANONICAL). The body holds inputs or fund (EMPTY). There are at most max_inputs inputs and at most max_outputs outputs (LIMIT).
  2. Keysets. Every input’s keyset is listed in the offer’s keysets, is known to the issuer, is not revoked, and now is before its final_expiry plus 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 with active and 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.
  3. Amounts. Every amount is a power of two for which its keyset has a key (BAD_AMOUNT).
  4. Fund. If the body holds fund, the quote exists, has method bolt11 and unit sat, carries a NUT-20 pubkey, 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.
  5. Value and outputs. The value of 6.3 is defined, and the outputs form a complete ladder that covers it (BAD_AMOUNT or BAD_OUTPUTS).
  6. Witness. The i-th signature of input_sigs verifies under the i-th input’s K over the Digest, and fund_sig verifies under the quote’s pubkey (BAD_SIGNATURE).
  7. Notes. For every input, C = a·hash_to_curve(hex(K)) for the key a of 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 Y of each input, as a 33-byte compressed point, under the use model single. The gate turns each into UseId = H_"bnap/use"(lp("bnap:notes") ‖ Y) (draft-00 6.1).
  • Rail references: none for notes. A body with fund adds one: rail kind ln and 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 with bnap: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: value of 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:

TypeNameValuePresence
0signaturesEntry list of signature entries, one per signed output, in ascending index orderWhen change > 0

A signature entry:

TypeNameValue
0indextu64: the zero-based index of the output in the body. Required, for index 0 too, whose value is empty
2C_Point: C' = a·B' for that output’s keyset and amount. Required
4e32 bytes: the NUT-12 DLEQ challenge. Required
6s32 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 to pubkey (NUT-20), with a quote ID of at most 64 bytes, and puts it in the fund_quote of 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 expiry no 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_expiry about 15 months after it started signing. Once final_expiry plus 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 keysets of 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 until final_expiry plus 60 seconds, which is how long draft-00 6.2 requires: a record deleted at final_expiry itself would let a note that still verifies be spent a second time. Signed-output records stay until the output keyset’s final_expiry plus 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_expiry is later than its expires plus 60 seconds (5.2). The issuer MUST keep signing on a keyset for as long as an unexpired offer lists it as active, 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_expiry into an active keyset (section 11). An operator announces retirement or shutdown at least 30 days ahead through the keyset’s final_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 Y whose UseId is committed is PENDING while its outcome is PENDING and SPENT otherwise, even after the outcome record is deleted, and every other Y is UNSPENT.

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_codeMeaningClient action
bnap:notes/NON_CANONICALThe 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/EMPTYThe body has neither inputs nor fundAdd notes or a fund quote
bnap:notes/LIMITMore inputs or outputs than max_inputs or max_outputsSwap into fewer notes first
bnap:notes/UNKNOWN_KEYSETAn input keyset is not in the offer, unknown, revoked or past its final expiry, or an output keyset is not activeRefresh the offer and keysets; swap old notes
bnap:notes/BAD_AMOUNTAn amount is not a power of two with a key, the arithmetic overflows, or the fees exceed inputs plus fundFix the amounts
bnap:notes/BAD_OUTPUTSThe outputs are not a complete ladder covering the valueSend the canonical ladder (6.3)
bnap:notes/BAD_SIGNATUREA note, an input signature or the fund signature failedDrop the bad input, or check the wallet’s keys
bnap:notes/QUOTE_UNPAIDThe 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 insteadPay 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 codeNow
VERSIONSocket VERSION (1)
MALFORMEDSocket 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_LARGESocket TOO_LARGE (3) for sizes; bnap:notes/LIMIT for counts
UNKNOWN_KEYSETbnap:notes/UNKNOWN_KEYSET
UNIT_MISMATCHbnap:notes/UNKNOWN_KEYSET: only sat keysets are ever listed (provisional)
BAD_CHALLENGESocket BAD_OFFER (4)
STALESocket STALE (5)
WRONG_SCOPESocket WRONG_SCOPE (6); a binding mismatch is BINDING (11)
WRONG_AMOUNTSocket UNDERPAID (9) when the value is below the price; bnap:notes/BAD_OUTPUTS when the outputs do not fit
BAD_SIGNATUREbnap:notes/BAD_SIGNATURE
OUTPUT_REUSEDNot sent; bnap:notes/OUTPUT_REUSED is reserved (13.1)
SPENTSocket SPENT (12)
QUOTE_UNPAIDbnap:notes/QUOTE_UNPAID
PENDINGSocket PENDING (13)
RATE_LIMITEDSocket 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:

PropertyValueNote
usesingleEach Y admits one action
bindingSThe witness signs the Digest: offer, chosen schemes, extensions, bodies and TLS binding
theftkey-witnessedA note copied from a log or a proxy is useless without k_n
link-paymentunlinkableNotes 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-actionsunlinkableBlinding; one K per note; the canonical ladder (6.3)
issuerself-issuedThe gate accepts only its own keysets
verifyofflineA signer check and signature checks; the rail is asked only for a fund quote
finalityfinal-on-verification
custodyoperator-iouCredit is the operator’s promise; wallets keep balances small (section 15)
refundatomicChange and refunds come back in the Response (section 7)
pqnoForgery is not post-quantum; blinding keeps past spends private
maturityexperimentalUntil 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_after and as soon as pending_timeout has 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:

ThreatClosed by
Double spendEach Y committed as a single UseId, atomically and durably, before the action runs (draft-00 6.3)
Note theft from logs, proxies or CDNsA witness by K over the Digest
Change or refund stolen by a proxyThe Digest covers the body, outputs and quote included; the outputs are blinded
A fund quote redeemed twiceOne RailId per payment, single, shared by credentials and NUT-04 minting (8.5)
A quote swapped by a proxyoffer_sig covers the quote; the wallet checks its key and amount (8.3)
Theft with a summed keyOne signature per K, no aggregation
Redeeming a note through a plain Cashu swap or meltKey isolation (4.2)
Mixed units or overflowsat only, checked arithmetic (6.3)
Bloating the store with free swapsPer-keyset input fees (section 9)
Admission minted by someone elseThe gate accepts only its own keysets (6.2, step 2)
The issuer links a spend to a purchaseBlinding; no DLEQ proof or blinding factor is ever presented (4.2)
Keys made to tag one userKeysets published 30 days ahead and compared from a second vantage point (section 10)
Wallet fingerprintingPowers of two, the canonical ladder, one K per note
Linking two actions through a reused outputCounters are never reused (12.1)
Lost deviceSeed recovery through NUT-13, NUT-09 and NUT-07 (section 12)
A linkable funded credential admitted on a route that requires unlinkable paymentNo 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 serviceFunding only on BNAP-Fund, capped, one quote per offer nonce, rate-limited per address (8.2)
Change withheld or miscountedThe deterministic selection (7.2), checked by the client against the signed Response
A timeout refund or missed change lost because the offer expiredThe 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 prunedBoth kept at least 60 seconds past the deadline of the entry that committed the quote (8.6)
A replay or crash paying change twiceThe 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:

  • expires is 1791703600, one hour after the reference time, so its admission cutoff (5.4) is 1791703240;
  • the bnap:notes pay entry carries a params record (5.1) of 106 bytes: issuer https://api.example/cashu and the keyset entries of keysets A and B below, in that order, with no limits, no max_fund and no fund_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.

Keysetinput_fee_ppkfinal_expiryactiveKeyset ID
A1001830000000yes011decbc482233faf1b58395ddb55c465e3bf6d8fa1c5a5b17219f7d5883b112a7
B01800000000no014423cf221427bb65863007387bbc9b74963e47b24c3ec3a25ddd673e9ed6e898
VectorValue
Offer413 bytes, below
OfferID666b28f7f1d68d92e9c180f0d11fe743730d3932ab5bc566d68f53b17b0cbd95
offer_sig108b3bf0…eb9f5fec
mace77e4e3ef6ce609745c194c160d1814295ad7e4d6d5a56178d68cf40b4a5dd4c
Context: the action scope of draft-00 section 14, bnap:notes, httpsig:web-bot-auth, empty ext29c131cbf11af082d603f2246516d288565da10bc3703cc556b87575f43cbdf3
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.

VectorValue
Keyset C ID01fd899e9c7a7f4940273d43ec1dcd3bafc9fe4fe80ba292b9f021ab09b5aecf46
Fee offer OfferIDb958c89e56ef83bb05dd98e88b0a4746f79ebac53c09c53f306e74635be1f2aa
Fee offer Context, as for the notes route offera6f07b0ed275845d2d088e724cd9a7fc9cd7093df6e6456c613f40ca788bab11

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:

  1. Note math: a seed, keyset ID and counter giving k_n (type 0x02), K, the secret, Y, r (type 0x01) and B'; a keyset key a; C' with its deterministic NUT-12 DLEQ proof; and C. The hash_to_curve and DLEQ steps MUST agree with the NUT-00 and NUT-12 vectors.

  2. Offer parameters: the params bytes 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 a fund_quote.

  3. 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-auth verifier 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, and price(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 real httpsig:web-bot-auth body 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.
  4. A funded credential for the notes route offer: a body with one input and a fund, the fund_sig, the RailId of the quote’s payment hash, and the value.

  5. Value and fees: inputs on keysets with nonzero input_fee_ppk, showing the rounding of 6.3.

  6. Settlement: for each of these outcomes, the charged and metered_units, the selected indices, the scheme_result bytes, 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, so select (7.2) signs indices 0, 1, 3, 5, 7, 9 and 10. The scheme_result is 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: charged 0 and metered_units 0, 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. The scheme_result is 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, select returns 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).

  7. Minting: rk_mint for a quote and two outputs (8.5).

  8. A reused output: a credential one of whose B' already has a signed-output record under another source. It verifies, and its settlement signs that B' again, with the same C' and DLEQ proof when keyset and amount match, and adds a second record (6.2, 7.4).

  9. Negative vectors, each with the expected scheme_code: inputs out of order; a repeated K; two outputs with one B'; an unknown odd record in the body; a record with an empty value; an input entry without its C record and an output entry that is the empty stream 00, each NON_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_sigs of the wrong length; fund without fund_sig; a witness signature over another Digest; a note whose C does 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 overviewThis documentWhy
SPEND is a CBOR objectThe 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 tableThe socket Digest, which covers H(body) (draft-00 5.2)The socket owns the Digest
Witness inside each inputAll signatures in the witness record, apart from the bodyDraft-00 5.1: the Digest covers bodies, never witnesses
fund = {quote, signature}fund in the body, fund_sig in the witnessSame reason
CHALLENGE TLV with amount, unit, keysets, info, op_key_id, fund_quoteThe 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 clientGone. Draft-00 4.4 accepts offers without mac only when published in advance under a key the gate holdsDraft-00 4.4
Own ack over Digest ‖ status ‖ amount ‖ signaturesThe 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 tagsThe socket’s ReplayKey and outcome record (draft-00 6.3, 6.4)The socket owns replay
Amount rule: inputs + quote - fees - outputs = amount, exactlyvalue ≥ price (checked by the socket) and change of exactly value - charged at settlementDraft-00 7: the charge is known only at settlement in metered offers
Separate outputs (change) and refund_outputsOne 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_REUSEDNot 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 wireFull 33-byte keyset ID only (provisional)One encoding; no ambiguous short IDs
A suite tag on every input; suites 1 to 3 reservedNo 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-InfoBNAP-Credential and BNAP-Receipt (draft-00 10.2)Draft-00 10.2
HTTP scope over a normalized URI and Content-DigestThe HTTP carrier’s action scope (draft-00 10.1)Draft-00 10.1
Agents and native clients MUST bind to the TLS exporterA 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 URLA 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, swapThe manifest (draft-00 9.1) for keys, limits and routes; offer parameters for the notes limits and max_fundDraft-00 section 9
Error codes of the SPEND, error responses SHOULD be signedSocket codes plus bnap:notes/… scheme codes; errors are unsigned (section 13)Draft-00 8.2
FAILED returns HTTP 502Not defined here: draft-00 does not give an HTTP status for a FAILED outcome, and the carrier, not the scheme, wouldDraft-00 10.2
Spend store: 16-byte prefix of Y with a 16-byte Digest tagThe 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 countersNUT-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 saysThis documentWhy
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_resultThe 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 offersOn 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.

#WhereDecision
P13.3Clients SHOULD list outputs in ascending amount order
P23.5Unknown records inside a notes body or witness are rejected even when odd; GREASE belongs in socket streams
P33.6, 5.1max_inputs and max_outputs default to 16
P44.2Full 33-byte keyset IDs only, never the short ID
P54.2Every BNAP keyset publishes a final_expiry
P65.1Notes parameters follow the even/odd rule
P75.4fund_quote only in exact-scope offers, and never on routes requiring link-payment = unlinkable
P85.4Withdrawn: 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
P95.4, 7.5, 8.2, 15Admission 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
P106.2Verification does not check whether a fund quote has issued; the RailId commit does, so replays of funded credentials work
P116.2, 13.1Verification reads no use state; v0.4’s check that no output was signed before is dropped, and bnap:notes/OUTPUT_REUSED is reserved
P126.3, 7.2One 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
P136.3Wallets SHOULD send the canonical ladder 1, 2, …, 2^(b-1)
P146.4A rail that does not confirm a fund payment gives BAD_CREDENTIAL with QUOTE_UNPAID
P156.4Outputs and quote IDs yield no local ID; the RailId guards the quote
P167.1A FAILED outcome has charged 0 and refunds the whole value
P177.3No scheme_result when the change is 0
P187.4The signed-output table is keyed by (B', source), the source being H(body) or rk_mint, and keeps every record
P197.4Settlement signs a B' that was signed before instead of failing, and adds its own record
P207.5The refund of a timed-out outcome is computed when the client replays the credential
P218A body’s fund redeems BOLT11 (NUT-23) quotes only; BOLT12 credit is minted through NUT-25
P228.1The BNAP-Fund request field, an RFC 9651 Dictionary, needs registering
P238.2One quote per offer nonce and a per-address rate limit are MUST, as DECISIONS row 24 sets for natives
P248.5BNAP quotes require a NUT-20 key; NUT-04 minting commits the RailId under rk_mint in one transaction
P258.6A 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
P2611Swaps use a price-0 manifest route with id notes-swap, answered by the gate
P2712.1Derivation type 0x02 for note keys; the counter’s top 16 bits name the slot
P2812.2Wallets record their counters in the backup; restore scans at least that far
P2913.2UNIT_MISMATCH folds into UNKNOWN_KEYSET
P3015Wallets 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
P3119.1Suites 1 to 3 become their own scheme IDs
P327.3Required 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
P332.1A 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
P345.4, 8.2On 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
P357.1A 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
P365.4, 8.4On 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
P3718.2The 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
P384.2, 16A 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”.

WrittenRead as
BNAP/LNBNAP credit funded over Lightning
BNAP/ArkBNAP credit funded over Ark (experimental)
HTTP+BNAPHTTP carrying BNAP in its authentication slot
HTTP+BNAP/LNHTTP with BNAP, funded over Lightning
SASL+BNAPMail, chat or MQTT carrying BNAP through SASL
BNAP-W, BNAP-BThe 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 decisionChoice
NameBNAP
FramingA layered spec with modest framing; the TCP/IP analogies live in Appendix A
EcosystemA Cashu profile: Notes are valid Cashu proofs, and buying credit is Cashu’s Lightning mint flow
WithdrawalAn optional extension, BNAP-W, off by default
First deploymentAI 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 schemeWhat it doesWhat BNAP adds
L402 (Lightning Labs)One Lightning payment per request, proved by preimage and macaroonPay once, spend offline many times; spends unlinkable to the payment
x402, MPPStablecoin or card payment per requestBitcoin-native credit with no facilitator, unlinkable spends
Cashu NUT-22 blind authSingle-use ecash tokens for accessA price, change, receipts, and theft resistance by binding each note to its request
Cashu NUT-24HTTP 402 paid with ecashA 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 attesterValue 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.

  1. 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.
  2. One small core. Many value rails below, many carriers above, one object in the middle: the Note.
  3. End to end. Abuse is judged by the application; BNAP only makes a price verifiable.
  4. No per-client state. The server stores records about notes, never about people.
  5. Pay rarely, spend offline. Money moves when credit is bought; each action is a local check.
  6. The relying party is its own issuer. A service accepts only notes it signed, so there are no allowlists and no fake mints.
  7. Sign everything once. Each note key signs a hash of the whole spend, outputs included.
  8. 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.

LayerContentsResponsible for
PolicyPrices, abuse judgement, sessions, depositsEverything that needs application meaning
CarrierHTTP authentication scheme (first), SASL, NostrPutting the bytes into a protocol slot
AdmissionBNAP: Notes and three verbsProving issuer-scoped value was spent once, for this request
ValueLightning through Cashu mint quotes; Ark and Cashu tokens experimentalMoving value
SettlementBitcoinFinal 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

PieceNUTUse in BNAP
Blind signatures and hash_to_curve00Note math and test vectors
Keysets, version-2 IDs, final expiry, input fees01, 02Keyset identity, rotation and fees
DLEQ proofs with deterministic nonce12Purchase and change proofs; the deterministic nonce is a MUST in BNAP
Deterministic secrets13Note keys, blinding factors, recovery
Restore and state check09, 07Recovery and multi-device checks
Lightning mint quotes with signed locks04, 20, 23, 25Buying credit
Info endpoint06Discovery and prices
V4 token (cashuB)00Wallet 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:

SuiteConstructionWhy
1RFC 9497 VOPRF with RFC 9380 hash-to-curveA form the IETF can standardize
2Anonymous Credit Tokens (ACT)One credential holds a hidden balance, removing power-of-two change
3Cashu KVAC researchAmount-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.

TypeFieldMeaning
0versionProtocol version, 1
2scope32-byte hash of the action; each carrier defines it to cover the action’s content and its recipient
4amountThe price, in the keyset unit
6unitUnit string, normally sat
8keysetsAccepted keyset IDs
10expiresUnix time after which the quote lapses
12infoURI of the issuer’s info endpoint
14op_key_idWhich operator key signs acknowledgements
16nonce16 random bytes
18macServer MAC over every other record
20issued_atSelf-issued challenges only: creation time
22fund_quoteOptional: 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 fieldBytes
version1
challengeBigSize length, then the challenge TLV bytes as received
bindingBigSize length (0 or 32), then the bytes
inputsBigSize count; per input: suite (1) ‖ full keyset ID (33) ‖ amount (8, big-endian) ‖ K (32) ‖ C (33, compressed); sorted by K
outputsBigSize count; per output: full keyset ID (33) ‖ amount (8) ‖ B’ (33); in client order
refund_outputsSame layout as outputs
fundBigSize length, then the UTF-8 quote id; length 0 when absent
extTLV 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:

  1. Parse. Version, total size, max_inputs and max_outputs; at least one input or a fund.
  2. 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 and replayed = true, or with PENDING if 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 with SPENT.
  3. Challenge. The MAC is valid and unexpired, or the self-issued fields are acceptable; the scope and the binding match this request.
  4. 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.
  5. Amount. The amount rule holds, with checked arithmetic.
  6. 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.
  7. 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 outputs and refund_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

CodeMeaningClient action
VERSIONUnsupported versionUse one from the manifest
MALFORMEDBad encoding, an unknown even TLV, or no input and no fundFix and resend
TOO_LARGEOver a size, input or output limitSwap into fewer notes first
UNKNOWN_KEYSETKeyset unknown, expired or revokedRefresh keysets, swap notes
UNIT_MISMATCHA note or output is in another unitUse the challenge’s unit
BAD_CHALLENGEMAC invalid, or self-issued not allowedGet a challenge from the server
STALEChallenge expired or too oldGet a new challenge
WRONG_SCOPEChallenge or binding is for another actionGet the right challenge
WRONG_AMOUNTThe amount rule failsFix change or refund outputs
BAD_SIGNATUREA note, witness or quote signature failedDrop the bad input
OUTPUT_REUSEDA blinded output was signed beforeDerive fresh outputs
SPENTA note was spent in another SPEND, or the replay window passedDrop that note, resend the rest
QUOTE_UNPAIDThe fund quote is not paid, or already issuedPay it, or drop it
PENDINGThe original spend is still runningRetry after Retry-After
RATE_LIMITEDToo many replays or open quotesRetry 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.

DeploymentSpends per yearStorage per year
Mid-size API, 20,000 spends a day7.3 millionabout 2.2 GB
Large service, 5 million spends a day1.8 billionabout 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-Fund with an amount and a client key, never above the manifest’s max_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 charge challenge 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. If Authorization already carries application auth, the client sends BNAP-Authorization instead. amount, unit and info in 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 with Retry-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 and Authentication-Info.
  • Errors come back as 402 with BNAP-Error and 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

TierThe agent hasHow it paysWhat links to the payment
0Any L402 or MPP client, such as lngetLightning per actionEvery action
1The bnap CLI or MCP server and an NWC wallet connectionFunds once inside the 402, then spends notes offlineOnly the first action
2A full wallet with its own seedBuys credit in a separate ISSUE, then spends notesNothing

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-Fund amount;
  • 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.

  1. Quote. The client asks for a NUT-23 quote locked to its key (NUT-20), or receives one inside a 402.
  2. Pay. Any Lightning wallet pays the BOLT11 invoice, including Cashu, Ark, Spark and Fedimint wallets, which can all pay BOLT11.
  3. 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 }.

BackendCustodyEffortFit
Receive-only NWC connection (Alby Hub or any NWC wallet)Depends on the walletPaste one connection stringCaparra default; it can never be drained
LDK Node with an LSPSelf-custodialRun a node libraryThe sovereign tier
PhoenixdSelf-custodialOne daemon; liquidity fees on the first receivesSmall operators
Custodial APICustodialEasiestBrings 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.

ThreatClosed by
Double spendEach Y recorded atomically before the action runs
One payment replayed for many actionsA known Digest returns the recorded status; the action never runs again
Replays as denial of serviceA replay window, rate limits, and at least one input or fund per SPEND
A spend reused with another body or at another originThe scope covers content and origin; the Digest covers the scope
Note theft from logs, proxies or CDNsA witness by K is required
Change or refund theft by a proxyThe Digest covers outputs, refund outputs and the quote
A fund quote redeemed twiceThe commit marks it ISSUED; NUT-04 refuses it afterwards
A quote swapped by a proxyWallets check the quote’s key and amount before paying
Forged replay through a tag collision16-byte tags, and full re-verification before any replay
One payment admitting two actions across schemesScope inside the macaroon or MPP request; one scheme per request
Theft with a summed keyOne signature per K, no aggregation
Redeeming a note through a plain Cashu swapSwap and melt disabled for BNAP keysets
Paying a cheap challenge for a costly actionScope inside the challenge MAC
Mixed units or overflowOne unit per spend, checked arithmetic
Bloating the store with free swapsPer-keyset input fees
Admission minted by someone elseThe operator accepts only its own keysets
Issuer linking a spend to a purchaseBlinding; DLEQ proofs are never presented
Per-user tagging keysKeysets published 30 days ahead and cross-checked
Wallet fingerprintingPowers of two and mandatory refund outputs
Lost deviceSeed recovery through NUT-13, NUT-09 and NUT-07
Invoice-minting denial of serviceFunding only on BNAP-Fund, capped and rate-limited
OssificationEven/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.

DocumentWhat it addsStatus
BNAP coreSuite 0 notes, CHALLENGE, SPEND, refund outputsMandatory
HTTP binding and agent profile402 challenge, content-covering scope, BNAP-FundMandatory for HTTP servers
Buying creditCashu NUT-23, NUT-04 and NUT-20, referenced verbatimMandatory for issuers
L402 and MPP fallbackPer-action Lightning for zero-code agentsRecommended for HTTP
BNAP-WWithdrawalOptional, off by default
BNAP-BDeposits with release or forfeit, forfeit log, claim ratesExperimental
Nostr NIPPriced relay writes with self-issued challengesSecond deployment
SASL+BNAP, ESMTPMail, chat and MQTT carriersOptional
Ark and Cashu-token value-inFunding without LightningExperimental
Suites 1 to 3VOPRF, ACT, KVACReserved
x402 schemeInterop with x402 clientsOptional
Pricing and wallet guidanceLoad-based prices, ceilings, budgetsInformational
TLS/QUIC admissionPricing handshake floodsDeferred
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.

CrateResponsibility
bnapProfile types: SigningBody, Digest, witnesses, challenge TLVs, scope functions, test vectors
caparra-issuerA CDK mint configured as a BNAP issuer: spend store, deterministic replies, separate signer process
caparra-gateReverse proxy and Tower middleware for paid APIs; emits BNAP, L402 and MPP challenges
caparra-walletAgent wallet with seed recovery, budgets and NWC funding; the bnap CLI and MCP server
caparra-lnThe 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.

OrderDocumentVenue
1NUT-XX “Key-secret proofs with context signatures” (~3 pages): secret = hex(K), witness over a tagged Digest, the new NUT-13 derivation typeCashu NUTs; pitched as making NUT-22 blind-auth tokens theft-proof
2NUT-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 402Cashu NUTs
3Agent interop note: BNAP, L402 and MPP in one 402Agent tooling communities
4NIP-XX “Priced relay writes”Nostr NIPs
5Internet-Draft with Suite 1, after two implementationsIETF 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 ifTarget
Operators other than the authorAt least one
Funded agents that top up a second timeAt least 30%
Abusive free-tier trafficDown at least 80%, without losing paying users
p95 verification timeUnder 2 ms
Independent implementations passing the test vectorsAt 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.

QuestionPositionsVerdict
Note secretRaw K bytes (v0.3) or hex(K) as a Cashu secrethex(K), with a key-isolation rule
Keyset IDCustom 8 bytes or Cashu version-2Version-2, short ID allowed on the wire
RecoveryNone (v0.3) or NUT-13NUT-13 seed recovery; rated critical
Wire formatCustom binary or Cashu CBORCBOR objects; binary only for the signed bytes
Refund after failureack-refund, delete it, or check the databaseClient-supplied refund outputs, signed only on FAILED
Replay storageFull responses or recomputationRecompute; 16-byte tags; re-verify first
First fundingFour steps, fund token in the 402, or a NUT-23 quote in the 402NUT-23 quote, embedded on opt-in
Zero-code agentsBridge extension or a SHOULD in coreSHOULD offer L402 or MPP
Credential typeACT by default, or notesNotes mandatory; ACT a reserved suite
Mandatory railBOLT11 or BOLT12BOLT11; BOLT12 optional
FeesNone or input_fee_ppkPer-keyset input fees
HashcashKeep or dropDropped
WithdrawalExperts: never in v1Owner: optional BNAP-W, off by default
FramingExperts: drop “foundational”Owner: layered spec, modest framing, rationale in Appendix A
First deploymentExperts: a Nostr relayOwner: 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-gate fronts 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 caparra crate, EU trademarks.
  • Test vectors and benchmarks for every encoding, against the 2 ms target.

Sources

Opened during round 3:

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”.

WrittenRead as
BNAP/LNBNAP notes funded over Lightning
BNAP/Ark, BNAP/CashuBNAP notes funded over Ark or Cashu ecash
HTTP+BNAPHTTP carrying BNAP in its authentication slot
HTTP+BNAP/LNThe full suite: HTTP with BNAP, funded over Lightning
SASL+BNAPSMTP, IMAP or XMPP carrying BNAP through SASL
BNAP-BThe 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.

ActorOutcome under BNAP
Slop and spam farmsPriced out once the price exceeds revenue per action
Sybil swarmsNo gain from splitting; the cost is per action
Paying agentsAdmitted at the same price as anyone
Capital-rich or state actorsNot deterred; out of scope
Volumetric floodsOut of scope
The issuing serviceCannot 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.

  1. 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).
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. Sign everything once. Each note key signs a hash of the entire spend, as TLS Finished covers the whole transcript.
  7. 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:

LayerContentsResponsible for
PolicyPrices, abuse judgement, deposits, sessionsEverything that needs application meaning
CarrierHTTP authentication scheme, SASL, Nostr, ESMTPPutting the bytes into a protocol slot
AdmissionBNAP: the Note and three verbsProving issuer-scoped value was spent once, for this request
ValueLightning, Ark, Cashu ecash, plus work and attested grantsMoving value, escrow, refunds
SettlementBitcoinFinal 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)

  1. The client generates a fresh key pair (k_n, K) and computes Y = hash_to_curve(K).
  2. It blinds Y as B’ = Y + r·G and sends B’ in an ISSUE or as a change output.
  3. 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).
  4. To spend, the client presents (Keyset ID, e, C, K) and signs the spend digest with k_n.
  5. 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.

TypeFieldMeaning
0versionProtocol version, 1
2scope32-byte hash of the action; each carrier defines it to cover the action’s content and its recipient
4amountThe price
6unitUnit string, such as sat
8keysetsAccepted keyset IDs, 8 bytes each
10expiresUnix time after which the quote lapses
12issueURI of the ISSUE endpoint
14op_key_idWhich operator key signs acknowledgements
16nonce16 random bytes
18macServer MAC over every record above
20issued_atSelf-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:

  1. Parse. Check the version, the total size and the manifest’s max_notes and max_outputs.
  2. 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 SPENT naming that K.
  3. Challenge. The MAC is valid and unexpired, or the self-issued fields are acceptable; the scope equals this request’s scope; the binding matches.
  4. 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.
  5. Amount. With checked arithmetic, the Notes minus the Outputs equal the amount exactly.
  6. Signatures. Every Note verifies (C = k_e·hash_to_curve(K)) and every signature by K covers the Digest.
  7. 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.

CodeMeaningClient action
VERSIONUnsupported versionUse one from the manifest
MALFORMEDBad encoding, or an unknown even TLVFix and resend
TOO_LARGEOver the size, note or output limitSwap into fewer notes first
UNKNOWN_KEYSETKeyset not accepted, expired or revokedRefresh the manifest, swap notes
UNIT_MISMATCHA Note or Output is in another unitUse the challenge’s unit
BAD_CHALLENGEMAC invalid, or self-issued challenges not allowedGet a challenge from the server
STALEChallenge expired, or self-issued challenge too oldGet a new challenge
WRONG_SCOPEChallenge or binding is for a different actionGet the right challenge
WRONG_AMOUNTNotes minus Outputs differ from the priceAdjust the change outputs
BAD_SIGNATUREA Note or a K signature failedDrop the bad Note
SPENTA K was spent in a different SPENDDrop 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 challenge parameter carries the encoded challenge; amount, unit and issue are readable copies that MUST match it.
  • If Authorization already carries application auth, the client sends BNAP-Authorization instead.
  • 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.

  1. 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 to client_key, so a leaked quote id is worthless (as Cashu NUT-20 does).
  2. Pay. The client pays the invoice from any Lightning wallet, including a Cashu wallet melting ecash.
  3. Issue. The client sends ISSUE with ValueIn{1, quote_id}, its blinded outputs and a signature by client_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.

ThreatClosed by
Double spendSpend record per K, committed atomically for a whole spend
One payment replayed for many actionsA known Digest returns the stored answer and the action MUST NOT run again
A spend reused with another body or at another originThe scope covers content and recipient, and the Digest covers the scope
Note theft from logs, proxies or CDNsK must sign; a copied Note is useless without k_n
Change theft by a proxyThe signature covers the outputs and the whole body
Paying a cheap challenge for a costly actionScope inside the challenge MAC
Value created by mixed units or overflowOne unit per spend; exponents checked before checked arithmetic
Admission minted by someone elseThe operator accepts only its own keysets
Issuer linking a spend to a purchaseBlinding; the DLEQ is never presented
Per-user tagging keysFull key lists cross-checked; keysets published 30 days ahead
Wallet fingerprintingPower-of-two notes; exactly one GREASE record in every spend
A refund claimed twiceRefunded Digests are recorded
A leaked Lightning quote idQuotes are locked to a client key
Lost responsesReplays served from the stored record, even after expiry
Junk spends as denial of serviceSize, limit and lookup checks before any elliptic-curve work
Tracking across sitesEach K is used at one origin, once
OssificationEven/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.

DocumentWhat it addsStatus
BNAP coreThe Note, three verbs, ack-refund (value-in type 2)Mandatory
HTTP binding402 challenge, Authorization spend, content-covering scopeMandatory for HTTP servers
Value-in 1: LightningQuote locked to a client key, pay, issueMandatory for issuers
SASL+BNAPBNAP and BNAP-PLUS mechanisms for SMTP submission, IMAP, XMPP and MQTT 5First extension
BNAP-B: depositsHeld change with release or forfeit; operator-chosen forfeit destination, reason codes, forfeit log, claim rates; Ark and Cashu escrowExperimental
More value-in typesBOLT12, Ark, Cashu, hashcash, Privacy Pass attestation, operator grantsOptional, one registry entry each
WithdrawalNotes back to LightningOptional
Nostr NIP, ESMTP parameterStore-and-forward carriers using self-issued challengesOptional
DNS SVCB bnap hintSkip the first 402Optional
x402 scheme, MPP method, L402 bridgeInteroperability with other 402 schemesOptional
Pricing guidanceLoad-based prices, ceilings, fair queuing, a reserve for humansInformational
Wallet guidanceBudgets, top-ups, agent SDK, people without walletsInformational
TLS/QUIC admissionPricing handshake floods before any application runsDeferred until deployments exist
Sessions, linkable mode, per-request K-signaturesReplaced by cookies after a SPENDRemoved

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.

CrateResponsibilityBuilds on
bnapNote, CHALLENGE, SPEND, ISSUE, Ack, TLV codec, test vectors; no_stdsecp256k1, sha2
caparra-issuerKeysets, blind signing, DLEQ, spent-set backendsCDK (Cashu Dev Kit) primitives
caparra-httpTower middleware and the caparra-gate reverse proxytower, hyper, rustls
caparra-lnValue-in type 1LDK
caparra-walletClient wallet, budgets, the bnap CLI, an MCP server for agentsAll of the above
caparra-sasl, caparra-b, …One crate per extensionbnap

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.

DocumentLengthVenueTarget
BNAP core~12 pagesCashu NUT, then IETF individual draftStandards Track candidate
HTTP binding~8 pagesIETF HTTP areaStandards Track candidate
Value-in 1: Lightning~4 pagesCashu NUTAccepted NUT
SASL+BNAP~6 pagesIETF, IANA SASL registryStandards Track candidate
BNAP-B deposits—Individual draftExperimental

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.

QuestionMinimalistCore defenderVerdict
The primitiveA blind-signed bearer noteMust be key-locked and extensibleNote = blind-signed public key K; K is the nullifier
What the spend signsChallenge and bindingThe entire spend, outputs includedThe entire body, like TLS Finished
VersioningVersion, suite and reserved bits in the NoteExtension map and GREASENo version field in the Note, one version byte per message; suite in the keyset ID; even/odd TLVs and GREASE
ReceiptsThe change is the receiptA signed receipt is mandatoryOne operator signature, the Ack
Failure and retriesApplication concernCore ruleAtomic, idempotent spends in core; refunds through ack-refund
Spends without a round tripNot allowedNeeded for Nostr and mailSelf-issued challenges, operator opt-in
Mandatory railNoneLightningLightning BOLT11
BondsDeferred change, an extensionOut of the coreBNAP-B, Experimental
TLS/QUIC bindingDeleteDeleteDeferred until deployments exist
Privacy PassShip as a token type?Fixed digest, no change channelNot 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

DecisionVerdict
CustodyThe operator issues its own notes; Bitcoin rails only fund them; no global mint allowlist
Economic corePer-action prices are primary; refundable bonds cannot deter abuse nobody flags
LayerApplication carriers first; transport admission later
ForfeitsOperator chooses the destination and must declare it; objective reason codes; public log
Relationship to x402Bridge, don’t compete
FramingSpam 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 caparra crate 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_s for 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:

Found by search but not opened:

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.

TermMeaning in this spec
OperatorThe service that admits traffic and judges abuse
StampNon-refundable payment consumed by one action
Session credentialPrepaid token consumed at admission, re-issued on clean close
Escrowed bondTimelocked deposit with a forfeit path and a refund path
Funding railHow value enters: Lightning, Cashu, Ark, or no money (hashcash, free grants)
CarrierThe 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.

ActorGoalOutcome under Caparra
Slop and spam farmsMass low-value posts, mail, commentsPriced out once the stamp exceeds revenue per action
Sybil swarmsEvade per-account rate limitsSplitting identities does not help: cost is per action
Abusive crawlersBulk readsPriced only where the operator charges for reads
Paying agentsLegitimate automated workAdmitted; they pay the same price as anyone
Capital-rich or state actorsHigh-value influenceNot deterred; out of scope
Volumetric floods (L3/L4)Exhaust bandwidthOut of scope; the Experimental QUIC binding only limits handshake floods
Greedy operatorsProfit from false forfeitsConstrained by objective reason codes and a public forfeit log
Mints and payment intermediariesTheft, censorship, surveillanceKept 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.

  1. Price actions, not actors. Per-action stamps are the primary instrument; bonds are an optional penalty layer.
  2. 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.
  3. 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.
  4. 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.
  5. Unlinkable by default, linkable only per origin. Blind signatures hide which funding event a session came from. Linkable mode never spans origins.
  6. One token format, many carriers. HTTP, SASL, SMTP, Nostr and TLS/QUIC carry identical bytes bound to their channel.
  7. Rail-agnostic spec, Bitcoin reference. Funding rails are plugins; the reference implementation ships Lightning, Cashu and Ark.
  8. 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.

  1. 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.
  2. 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.
  3. 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.
  4. Carriers (L3). The HTTP Caparra auth scheme and the SASL CAPARRA mechanism 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:

  1. Parse the envelope; reject an unknown version with VERSION.
  2. Check the size (at most 4 KiB) and that the proof type and binding profile are accepted.
  3. Recompute ctx from the challenge, or from a timestamp within clock_skew for a pre-informed client; reject a mismatch with BINDING_MISMATCH.
  4. Check each proof’s keyset is listed and not past final_expiry, and that amounts cover the price.
  5. Check the signature by K over ctx.
  6. Check C = k_amount · hash_to_curve(secret) with the operator’s private key.
  7. Atomically test-and-set each proof’s nullifier in the seen-set; reject SPENT if it is already there.
  8. 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 typeStatusVerified againstNullifierUse
op-ecashCore, MUSTOperator’s own keysetY = hash_to_curve(secret)Sessions, stamps, credit
hashcashCore, MUSTSHA-256 over ctx and a counter, d leading zero bitsHash of the solutionNo-money fallback with an operator-set weight
op-grantCore, SHOULDOperator keyset, unit grantYFree tokens issued after an attestation (email, CAPTCHA, invite)
cashu-p2pkOptionalThird-party mint keyset on the operator’s own list, with DLEQYPortable first-visit stamps and small escrowed bonds
ln-preimageOptionalSHA-256 of preimage against an operator macaroon (L402)Payment hashInterop with L402 clients
pp-tokenOptionalPrivacy Pass publicly verifiable token (RFC 9578, blind RSA); a separate token typeToken nonceInterop with Privacy Pass issuers

Funding rails

RailHow credit is boughtRefund pathRole
Lightning BOLT12Client pays the operator’s offer; operator blind-signs unitsNew payment to the client’s offerReference rail
Cashu via LightningClient melts its ecash at its own mint to pay the operator’s invoice; the preimage proves paymentLightning paymentPortable for ecash holders; the operator trusts no mint
Cashu directOperator swaps tokens from mints on its own listFresh ecashOptional; the operator carries that mint’s risk
Ark VTXOOut-of-round transfer into a timelocked script with a presigned forfeit output, at least ark_minCooperative release, or unilateral exit after the timelockThe only fully non-custodial escrowed bond
HashcashWork instead of moneyNoneLast-resort fallback
Operator grantAttestation instead of moneyNoneFree 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 gets VERSION with the supported list.
  • Errors. INVALID, SPENT, KEYSET_EXPIRED, UNDERPAID, QUOTE_EXPIRED, UNSUPPORTED_TYPE, BINDING_MISMATCH, VERSION, RATE_LIMITED. HTTP carries them in Caparra-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 for refund_window and keeps verifying until final_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_key on 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.

InstrumentRefundableWhere the value sitsForfeit enforced by
StampNoConsumed by the actionNot applicable
Session credentialYes, minus the session fee, by re-issueOperator credit (capped)Operator withholds the replacement
Escrowed bond, ArkYes, by timelockClient’s VTXO in a shared scriptPresigned forfeit output; the operator only triggers it
Escrowed bond, CashuYes, by timelockThird-party mintOperator swaps before the locktime

Session states

  1. PRESENTED. The client sends an envelope of kind session with deposit proofs and blinded outputs for its replacement. Outputs total at most the deposit minus session_fee, MUST name the keyset active at this moment, and duplicate blinded messages are rejected.
  2. 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 published sid alone is useless.
  3. 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_window ends, 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 its detection_window, and a session can be flagged until it is released.
  4. 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-p2pk proof 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.

DestinationMeaning
selfOperator keeps it; wallets flag this
donatePaid to a published BOLT12 offer or address
burnSettled 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
arbiterHeld in 2-of-2 with a named arbiter for disputed reasons
Reason codeObjective test
RATEActions per interval above the published limit
QUOTABytes or actions per session above the published cap
PROTOCOLMalformed or abusive protocol use
REPLAYToken reuse detected
POLICYContent-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

  1. DNS discovery (shared with all carriers). The SVCB/HTTPS caparra SvcParam lists proof types and the manifest hash, next to the existing ech parameter.
  2. 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.
  3. TLS extension, ECH only. The client advertises the caparra extension 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 the tls-hello profile 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.
PathExtra round tripsLinkable
Pre-informed client with ECH0No
Uninformed client, HelloRetryRequest challenge1No
QUIC NEW_TOKEN handshake priority0Yes
No ECHFalls back to HTTP or SASLNo

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 by sid.
  • 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 Authorization already carries application auth, the client sends Caparra-Authorization instead.
  • On admission the server answers Authentication-Info: sid="s_9f…", exp=3600, receipt="…". Later requests send Authorization: Caparra sid="s_9f…", ts="…", sig="…", where sig is the K-signature over (sid, method, target, ts).
  • Native clients and agents use the tls-exporter binding 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 the nonce profile.
  • 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

ActionWire form
Clean closePOST {claim}/{sid}/close with a K-signature, or expiry
Claim replacementPOST {claim}/{sid}/release with a K-signature; idempotent until refund_window ends; answers Caparra-Status: released; sigs="…"; receipt="…"
ForfeitCaparra-Status: forfeited; reason="RATE"; receipt="…" on the 403 or 429 response
Buy creditPOST {fund} with the rail payload; answers with blind signatures
Withdraw creditPOST {fund}/withdraw with tokens and a refund destination
Forfeit logGET {log}/{epoch}
ErrorsCaparra-Error: SPENT (codes listed under Conformance details)

Other carriers

CarrierFormStatus
SASLCAPARRA 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 AUTHNormative
ESMTP (MX to MX)MAIL FROM:<a@b> CAPARRA=<token> extension; inbound MX has no SASLOptional
NostrA NIP adding a ["caparra", token] event tag and a NIP-42 AUTH variantOptional
ActivityPubPlain HTTP binding on the inbox POSTCovered by HTTP
gRPC, WebSocketHTTP binding on the upgrade or callCovered by HTTP

Interop with existing schemes

  • x402: publish caparra as 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 caparra as a payment method and bond as the first entry in the payment-intent registry.
  • L402: accept ln-preimage as 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.

ActionAssumed value to a spammerStampCost to a human
Forum post$0.0028 sat ($0.005)20 posts a day = 160 sat ($0.11)
Signup$0.0164 sat ($0.04)Once
Inbound mail$0.00052 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.

AttackSeverityMitigation in this specResidual
Abuse that never gets flaggedHighStamps priced above value per actionMedium: calibration is the operator’s job
Surge-price manipulationHighLoad pricing on stamps only, ceilings, fair queuingLow
Operator greed, false forfeitsHighReason codes, receipts, forfeit log, declared destination, Ark presigned forfeitsMedium: the self destination stays allowed
Gamed claim ratesMediumSigned admission counts, client-published receiptsMedium: denominators can be padded
Fake bonds from attacker mintsHighOperator-issued credentials; Cashu holders fund by paying the operator’s Lightning invoice, so no mint is trustedLow
Mint rug, censorship, surveillanceMediumMints off the admission path; NUT-28 blinding; batched Tor sweeps; capsLow to medium for cashu-p2pk users
Forfeit race against the locktimeMediumLocktime at least session plus detection window plus 24 h; forfeit earlyLow; NUT-11 post-locktime semantics to confirm
Replay across anycast points of presenceMediumPer-PoP keysets or a central atomic seen-set; ephemeral key signature; nonce or exporter bindingLow
0-RTT and resumption replayMediumNo new tokens in 0-RTT; resumption inherits sidLow
Verifier DoS with junk tokensMediumAddress validation, size and seen-set checks before elliptic-curve work, per-source budgetsLow
Session stuffing (many actions, one session)MediumPer-action stamps; QUOTA caps per sessionLow
Amount, keyset and timing fingerprintsMediumPower-of-two amounts, aged keysets, delayed issuanceMedium
Free-tier farming by AI CAPTCHA solversMediumGrants bound to origin and rate-limited; low weightMedium
Hashcash hardware asymmetryMediumLowest weight, last resort onlyHigh, accepted
Linkable mode becoming a global identityHighLinkability per origin only; cross-origin key reuse forbiddenMedium
Exclusion of people without walletsHighGrant tier and hashcash; reserved human capacityMedium
Legal exposure of operators and mintsHighClosed-loop operator issuers; foundation holds no fundsHigh until counsel confirms
Incumbent x402 wins by defaultHighBridge as an x402 scheme rather than competeHigh

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.

CrateResponsibilityBuilds on
caparra-coreEnvelope, manifest, receipts, ctx hashing, session state machine; no_std-friendlyciborium, secp256k1
caparra-issuerOperator keyset, blind signing, funding and withdrawal flowsCDK (Cashu Dev Kit)
caparra-verifyOffline verification, seen-set (memory, Redis, sled), check ordering, per-source budgetscaparra-core
caparra-logForfeit log epochs, Merkle roots, inclusion proofsrs_merkle or equivalent
caparra-httpTower middleware for axum and hyper, plus the caparra-gate reverse proxy binarytower, hyper, rustls exporter API
caparra-saslCAPARRA and CAPARRA-PLUS mechanismscaparra-core
caparra-fund-lightningBOLT12 offers, refundsLDK
caparra-fund-cashuOnline swap of incoming Cashu, cashu-p2pk proofsCDK
caparra-fund-arkEscrowed bond scriptsRust Ark client (beta)
caparra-walletClient wallet, budgets, receipts, cpr CLI, MCP serverAll of the above
caparra-quicNEW_TOKEN handshake priority (Experimental)quinn, small upstream patch
caparra-tlsECH-only TLS extension (Experimental)rustls fork behind a feature flag

Build order

  1. caparra-core, caparra-verify, caparra-issuer and the test vectors that the NUT will cite.
  2. caparra-http and caparra-gate, so any site can deploy behind a proxy.
  3. caparra-wallet with the cpr CLI and MCP server, so agents can pay from day one.
  4. caparra-fund-lightning and caparra-fund-cashu; then caparra-log.
  5. caparra-sasl and the Nostr relay plugin.
  6. caparra-fund-ark, caparra-quic, then caparra-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

DocumentVenueStatus target
Caparra envelope, operator credentials, receiptsCashu NUTAccepted NUT
NUT-24 extension: kinds, ctx binding, release and forfeit headersCashu NUTAccepted NUT
HTTP Caparra authentication schemeIETF individual draft (HTTP area)Standards Track candidate
SASL CAPARRA and CAPARRA-PLUSIETF individual draft, IANA SASL registryStandards Track candidate
SVCB caparra SvcParamIANA registrationRegistered
caparra method and bond intentdraft-httpauth-payment registriesRegistered
caparra x402 schemex402 FoundationAccepted scheme
Nostr NIP, ESMTP extension, MQTT profileNIPs repo, IETFOptional
TLS 1.3/QUIC bindingIETF individual draftExperimental

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.

DecisionPosition APosition BVerdict
Bond custodyCashu ecash: P2PK-locked proofs, offline-verifiable, native locktime refundNon-custodial: operator-issued blind tokens, funded by Lightning or ArkOperator-issued credentials in Cashu format; Cashu, Lightning and Ark are funding rails; third-party Cashu proofs optional for portable stamps
Economic coreRefundable bond (owner’s choice)Red team: bonds are nearly free for unflagged attackersStamps are primary; bonds are an optional, capped penalty layer
Mint allowlistsMandatory, to stop attacker mintsA new gatekeeper; avoid entirelyNo global or default allowlist; Cashu holders fund by melting to the operator’s own Lightning invoice, so no mint is trusted
Protocol layerTransport: admit in the TLS/QUIC handshake, universal across protocolsHTTP and SASL: abuse is judged where the semantics liveHTTP and SASL normative; TLS/QUIC a separate Experimental document
Transport forfeit signalTLS alert and QUIC error codeApplication receipt onlyApplication receipt only; transport admits, application judges
TLS path without ECHBond as a client certificate typeCollides with mTLS; no handshake carriage without ECHDropped; clients without ECH use the application binding
Forfeit destinationOperator keeps it (owner’s choice)Red team: default to burn or donateOperator chooses, must declare it; spec recommends non-operator destinations
Unlinkability promiseUnlinkable by constructionSmall operators link by IP and timing anywayGuaranteed between sessions and funding; not promised against small operators
Relationship to x402Compete with a Bitcoin-native standardSkeptic: bridge, don’t competeBridge: publish caparra as an x402 scheme
Thesis framingSuperintelligence and the human high groundSkeptic: unfalsifiable, deters operatorsSpam economics only
NameStampCollides with Bitcoin Stamps and the taken crateCaparra, 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