Introduction
Caparra is the Rust reference implementation of BNAP, 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 and the schemes it accepts. The client attaches one credential to the retried request: a payment (blind ecash notes, L402 or MPP) and, if it wants, attestations such as DPoP or Web Bot Auth. The server records each payment as used once only, runs the action, and answers with a signed receipt.
BNAP’s default scheme is bnap:notes: blind notes bought over Lightning,
so the server does not learn which payment funded which request.
Caparra is built as a host for plugs. The protocol core, the socket, lives in one small crate without I/O. Every plug point (credential schemes, settlement rails, storage, signers and carriers) is a Rust trait, so an operator can mix the parts they need without forking the core.
Warning
Caparra is alpha (0.1.0-alpha), not audited, and tracks BNAP draft-00. Nothing is published to crates.io, every interface in this guide can change without notice, and it is not ready for mainnet money. Open findings are listed in
KNOWN-ISSUES.mdin the repository.
What is in this guide
- Quickstart: build the binaries, put
caparra-gatein front of a local service on the fake rail, and see what a client gets. - Operator config reference: the configuration file that
caparra-gatereads. - Agent usage: how agents pay through
bnap-mcp, thebnapCLI or the wallet library. - Architecture: the crate map and how the pieces fit.
Sections marked TODO are placeholders. They are filled in as the features they describe land.
The specification
The protocol is specified in the
bnap repository. The normative text
is spec/socket-draft-00.md; where it and the v0.5 design overview differ,
draft-00 wins. Caparra follows draft-00, the errata and the provisional
decisions recorded next to it. The rendered specification is published at
https://bnap-protocol.github.io/bnap/.
Source and license
The source lives at https://github.com/bnap-protocol/caparra. Caparra is
licensed under either of MIT or Apache-2.0, at your option. Contributions are
welcome; see CONTRIBUTING.md in the repository for the workflow, the
Developer Certificate of Origin and the checks every pull request runs.
Quickstart
This puts caparra-gate in front of a local file server with one priced
path, using the fake rail, which moves no money. It is the same run as the
quickstart in the repository’s README, and every command below was run
as written.
You need Rust (the toolchain is pinned in rust-toolchain.toml), protoc
(the LND rail compiles .proto files at build time), curl and Python 3.
1. Build the binaries
Binary releases are not published yet. Build from a checkout:
git clone https://github.com/bnap-protocol/caparra.git
cd caparra
cargo build --locked -p caparra-gate -p bnap-cli -p bnap-mcp
export PATH="$PWD/target/debug:$PATH"
This builds caparra-gate, bnap and bnap-mcp. The first build takes a
few minutes.
2. Configure the gate and sign its manifest
examples/quickstart/gate.toml protects GET /hello.txt at 21 sat, payable
with httpauth:L402 or payment:lightning/charge over the fake rail, with a
file signer and a SQLite use store. Paths in it are relative to the
directory the gate runs in.
mkdir -p target/quickstart/www && cd target/quickstart
cp ../../examples/quickstart/gate.toml .
sh ../../examples/quickstart/setup.sh
caparra-gate check --config gate.toml
setup.sh makes the root and backup keys, K_mac, the L402 and MPP
secrets (each with caparra-gate key new), a signer key file keys.toml
with an offer key and a receipt key valid for 7 days, and then signs the
manifest:
caparra-gate manifest sign --config gate.toml --root-key root.key \
--backup "$(cat backup.pub)" --seq 1 --keys-from keys.toml --out manifest.json
caparra-gate check runs every start-up check and prints
ok: caparra-gate would start with gate.toml. It warns that the fake rail
is in use. In production the root key never touches the gate’s host; see
examples/llm-proxy/README.md.
3. Start a service and the gate
echo 'hello, paid world' > www/hello.txt
python3 -m http.server 9000 --bind 127.0.0.1 --directory www &
caparra-gate run --config gate.toml &
4. See what a client gets
curl -s http://127.0.0.1:8080/.well-known/bnap # the manifest, byte for byte as signed
curl -si http://127.0.0.1:8080/hello.txt # 402 Payment Required
curl -s http://127.0.0.1:8080/ # no route: forwarded unpaid
The 402 carries three WWW-Authenticate challenges (a BNAP offer=..., the
L402 challenge and the Payment challenge, which share one invoice), the
field BNAP-Error with the code REQUIRED, and Cache-Control: private, no-store.
5. Make a wallet and try to pay
export BNAP_WALLET="$PWD/wallet.db" BNAP_PASSPHRASE='use a long passphrase'
bnap init
bnap budget set --allow-experimental true --per-request 100
bnap budget show
bnap get http://127.0.0.1:8080/hello.txt
bnap init shows the 12-word mnemonic once. A new wallet pays nothing
until --allow-experimental true, because every scheme in the registry is
still experimental. The last command fails with
error[BAD_INPUT]: Paid requests go over https only.: the wallet pays only
over HTTPS, and the gate does not terminate TLS. The fake rail’s invoices can
also be paid only from inside the process that holds it. The paid round
trip on the fake rail (402, payment, admission, replay, SPENT, receipt,
notes with change) therefore runs in the tests:
cargo test --locked -p caparra-gate --test gate --test notes
cargo test --locked -p caparra-wallet --test flows
To pay for real, serve the gate on a public HTTPS origin behind a TLS
proxy, use an lnd, phoenixd or nwc rail, and give the wallet a pay
source with bnap paysource nwc (or BNAP_NWC_URI) or
bnap paysource command .... Agents use the same wallet through
bnap-mcp; see agent usage.
6. Run the Lightning rails on regtest
devenv/regtest/ is a small Lightning network in Docker: one bitcoind and two
LND nodes, payer and payee. The payee is the operator’s node; the
bnap-rail-lnd plug talks to it over gRPC on 127.0.0.1:10009.
devenv/regtest/up.sh # start, fund the payer, open a payer -> payee channel
. devenv/regtest/.state/env # BNAP_REGTEST_LND_ADDRESS, _CERT, _MACAROON
cargo nextest run -p e2e --features regtest
devenv/regtest/down.sh # stop and delete everything
up.sh is idempotent: it waits until every container is healthy, mines to
the payer until it can pay, opens the channel once, and copies the payee’s
tls.cert and invoice.macaroon to devenv/regtest/.state/. The e2e tests
run it themselves, so CI only starts the stack (docker compose up -d) and
runs them. They create an invoice through the LND rail, pay it from the
payer with lncli, check that the rail reports it final with its amount, and
present the one payment through L402 and payment:lightning/charge: one
action is admitted, the other credential is SPENT.
To point a gate at that node, change its [[rail]] to kind = "lnd" with
address, cert and macaroon (see the
operator config reference). The wallet still needs an
HTTPS origin, so a full client run also needs a TLS proxy in front of the
gate.
Operator config reference
caparra-gate reads one TOML file, given with --config (or the
CAPARRA_CONFIG environment variable). Unknown keys are an error. Relative
paths are resolved from the directory the gate runs in. Secrets never sit
in the file itself: keys name files that hold them, or, for NWC, an
environment variable.
caparra-gate check --config gate.toml runs every start-up check and
exits 0 when the gate would start. Two complete examples are in the
repository: examples/quickstart/gate.toml (fake rail, local) and
examples/llm-proxy/gate.toml (phoenixd, process signer, a metered
OpenAI-compatible route).
[gate]
| Key | Default | Meaning |
|---|---|---|
origin | required | The origin the manifest is issued for, as an RFC 6454 serialization (https://api.example) |
listen | 127.0.0.1:8080 | The address to serve on |
front | proxy | proxy (reverse proxy to upstream), forward_auth (Caddy), auth_request (nginx); envoy_ext_authz is refused at start |
upstream | none | The application, http://host:port with no path; plain HTTP only; proxy front only |
replay_window_s | 86400 | Must equal the manifest’s value (manifest sign writes it) |
pending_timeout_s | 300 | Must exceed the longest action; must equal the manifest’s value |
offer_lifetime_s | 300 | Lifetime of exact-scope offers, at least 60 |
route_offer_lifetime_s | 3600 | Lifetime of route-scope offers, at least 60 |
mac_key_file | required | File holding K_mac, 64 hex (caparra-gate key new) |
retired_mac_key_files | [] | Retired K_mac files, kept until every offer made under them has expired |
max_request_bytes | 1048576 | Largest request content the gate buffers |
max_response_bytes | 16777216 | Largest application answer the gate buffers |
keep_response_bytes | 65536 | Answers up to this size are kept for replays; 0 keeps the receipt only |
trust_forwarded_for | false | Take the client address from the rightmost X-Forwarded-For hop; set it only behind a trusted front; always on behind an auth front |
[manifest]
| Key | Default | Meaning |
|---|---|---|
file | required | The manifest made offline by caparra-gate manifest sign, served byte for byte at /.well-known/bnap |
root | none | The root public key (64 hex) the manifest must be signed by; recommended |
required | false | The required flag manifest sign writes: clients then pay this origin only through BNAP offers |
max_age_s | 604800 | The manifest’s max_age, at most 30 days |
The gate refuses to start when the manifest’s signature, root, origin, routes, limits or keys do not match the configuration, when it has expired, or when the signer cannot sign with its offer and receipt keys.
[store]
| Key | Default | Meaning |
|---|---|---|
kind | sqlite | sqlite, or memory (a temporary SQLite file, for tests, logged loudly) |
path | none | The SQLite file of the use store |
issuer_path | path + .issuer | The SQLite file of the bnap:notes issuer tables |
The PostgreSQL store exists as a crate but is not wired into the gate yet.
[signer]
| Key | Meaning |
|---|---|
kind | file or process |
keys | file: the signer key file (mode 0600; see crates/bnap-signer-file) |
socket | process: the Unix socket of bnap-signer |
signer_uid | process: the user bnap-signer runs as; the gate checks the peer |
bnap:notes needs the file signer for now.
[[rail]]
| Key | Meaning |
|---|---|
id | The name schemes refer to |
kind | lnd, phoenixd, nwc or fake |
invoice_expiry_s | Invoice lifetime; must not outlive an exact-scope offer |
address, cert, macaroon | lnd: gRPC address, TLS certificate file, macaroon file |
url, password_file | phoenixd: HTTP API URL and the file holding its password |
uri_env | nwc: the environment variable that holds the NWC URI (default NWC_URI) |
registry, seed | fake: the registry rail entry it claims to be (required, for example ln:lnd) and an optional 64-hex seed |
The fake rail moves no money and logs a warning at start.
[[scheme]]
| Key | Default | Meaning |
|---|---|---|
id | required | bnap:notes, httpauth:L402 or payment:lightning/charge |
rail / rails | The rail or rails it settles over | |
enabled | true | |
secret_file | L402 and MPP: file holding the scheme’s secret, 64 hex | |
realm | origin’s host | MPP: the challenge realm |
network | mainnet | MPP: mainnet, signet or regtest |
max_fund_sat | bnap:notes: funding inside the 402 up to this many sat | |
issuer_url | <origin>/cashu | bnap:notes: the issuer’s Cashu API base. On the gate’s origin, the gate answers GET and HEAD of {issuer}/v1/keysets, {issuer}/v1/keys and {issuer}/v1/keys/{id} itself; it refuses to start when this is the origin’s root or a route lies under those paths |
[[attest]]
| Key | Meaning |
|---|---|
id | httpauth:DPoP, httpauth:Bearer or httpsig:web-bot-auth |
enabled | Default true |
keys | DPoP and Bearer: base64url JWK thumbprints of registered keys |
tokens | DPoP and Bearer: access tokens, { token = "...", jkt = "..." }; with jkt the token is bound to that key |
agents | Web Bot Auth: { url = "...", key = "<64 hex Ed25519 public key>" } entries |
Keys and agents are static lists; there is no token introspection or key directory fetching yet.
[[route]]
| Key | Default | Meaning |
|---|---|---|
id | required | The route ID, part of the route scope |
match | required | A method, a space and a path, such as POST /v1/chat/completions; /* matches every path below |
scope | route | route (one shared offer) or exact (an offer per request, bound to its content) |
price | { sat = N }: the price per call, or the ceiling on a metered route | |
metered | false | The charge is set at settlement by the meter |
meter | The meter’s name, such as tokens | |
meter_path | /usage/total_tokens for tokens | JSON pointer to the count in the answer |
rate | Price per 1,000 meter units, sent in the offer | |
require | {} | Required scheme properties, such as { use = "single" } |
accept | [] | Value schemes, in preference order |
attest | [] | { scheme, effect, value?, epoch? } with effect one of required, waive, discount (value in basis points) or quota |
headers | [] | Request fields hashed into the scope, lower case |
allow_experimental | false | Needed for every Lightning scheme and bnap:notes, which the registry marks experimental |
allow_non_bitcoin | false | Needed for schemes on non-Bitcoin rails or units |
upstream | http | http, or openai for OpenAI-compatible APIs (metering by tokens; "stream": true refused on metered routes) |
A route that its enabled schemes cannot satisfy refuses to start.
[pricing]
| Key | Meaning |
|---|---|
kind | table |
table | { <route id> = { sat = N } }: overrides a route’s price |
[limits]
Per client address, per minute (draft-00 section 12, DECISIONS row 24).
| Key | Default | Meaning |
|---|---|---|
mints_per_minute | 20 | Offers that mint an invoice, and fund quotes |
verifies_per_minute | 600 | Credentials that reach a scheme plug |
verifies_per_offer_per_minute | 6000 | The same, per offer (route offers are shared) |
max_change_outputs | 64 | Most change outputs one outcome may have signed |
Defaults from the specification
| Setting | Default | Source |
|---|---|---|
pending_timeout | 300 s | Provisional decision for ERRATA row 21 |
replay_window | 86,400 s | Provisional decision for ERRATA row 21 |
Manifest max_age | at most 2,592,000 s (30 days) | draft-00 9.1 |
| Credential size on HTTP | at most 9,216 bytes; larger is TOO_LARGE | Provisional decision for ERRATA row 30 |
Agent usage
An agent meets BNAP when a service answers a request with an offer instead of the result. The wallet picks a scheme the policy allows, attaches a credential to the retried request, and gets the result plus a signed receipt. Caparra gives agents three ways to do that, all over the same wallet file.
The wallet pays only https URLs, only after it has pinned the origin’s
manifest, and only within its caps: per request, per task, per origin
(24 h) and daily. A new wallet pays nothing until its owner runs
bnap budget set --allow-experimental true, because every scheme in the
registry is still experimental.
Through MCP: bnap-mcp
bnap-mcp is an MCP server over a wallet made with bnap init. The model
never chooses a scheme and never sees a key, the seed or a pay source
secret. It has exactly three tools:
| Tool | Arguments | Result |
|---|---|---|
fetch_paid | url, method?, body?, max_sat? | Status, content type, body (text or base64, at most 256 KiB) and the payment |
budget | action? (get, the default, or set), cap? (per-request, per-task, per-origin, daily), sat?, origin? | Owner caps, session caps, caps in force, what is left, notes held; set only narrows (above the owner’s cap is ABOVE_HARD_CAP) |
receipts | action? (list, the default, or verify), id? | Receipt summaries, or a third-party check of them |
Unknown arguments are refused, so a call that names a scheme fails.
Refusals come back as tool errors with structured content
{"error": {...}} carrying the same codes as the CLI.
Register it with an MCP client that reads an mcpServers configuration:
{
"mcpServers": {
"bnap": {
"command": "bnap-mcp",
"args": [
"--wallet", "/home/you/.bnap/wallet.db",
"--passphrase-file", "/home/you/.bnap/passphrase"
]
}
}
}
It serves over stdio by default. --http 127.0.0.1:PORT serves streamable
HTTP at /mcp on a loopback address only. That mode has no authentication:
any local process that reaches the port can spend within the caps.
--task NAME groups runs under one per-task cap; otherwise each run is its
own task.
From scripts: the bnap CLI
bnap [--wallet PATH] [--task NAME] [--json] <command>
init [--slot N] create a wallet, show the mnemonic once
fund <origin> <sat> [--issuer URL] buy notes at an origin over Lightning
get|post <url> [--max-sat N] [-H 'Name: value'] [-d TEXT | --data-file F]
budget [show [--origin O] | set ...] caps and policy
receipts [list | verify [ID]]
restore --origin O... [--issuer URL] recover notes from the seed
resume finish paid requests that got no final answer
paysource [show | nwc [URI] | command ARGV... | none]
Environment: BNAP_WALLET (default ~/.bnap/wallet.db), BNAP_PASSPHRASE
(otherwise a prompt), BNAP_TASK, BNAP_MNEMONIC (for restore) and
BNAP_NWC_URI (safer than passing the URI on the command line). Every
refusal prints error[CODE]: message; --json prints
{"ok": false, "error": {...}}. Exit status 2 means the caller can fix it
(input, policy, budget, wallet), 3 means try later, 1 anything else.
Embedded: caparra-wallet
caparra-wallet is the library both binaries use. Wallet::create and
Wallet::open handle the encrypted wallet file, Wallet::fetch takes a
FetchRequest { method, url, headers, body, max_sat } and returns a
FetchResult with the status, headers, body and the payment, and
Wallet::receipts lists what was paid. See the crate’s rustdoc and
crates/caparra-wallet/tests/flows.rs for complete flows.
Rules every client follows
These come from BNAP draft-00 (section 9.3) and hold whichever of the three ways an agent uses:
- Fetch the origin’s manifest over HTTPS, at most once per five minutes, and pin its root and backup keys.
- Accept an offer only if its offer key is listed in the pinned manifest with
use
offerand has not passed itsnot_after, and the offer passes the checks of draft-00 section 4.4. - Once an origin’s manifest has set
required, pay that origin only through a BNAP offer, never through a bare native payment header. - Pay a native challenge only if its bytes equal a native entry of an offer that passed the checks, or when there is no pin for the origin yet.
Architecture
Caparra is a host for plugs: the BNAP socket sits in one small crate, every plug point is a Rust trait, and the binaries assemble the plugs an operator chooses. This page maps the BNAP v0.5 overview’s crate plan onto the workspace.
Crate map
The overview groups the crates into families:
| Family | Role | Workspace crates today |
|---|---|---|
bnap-core | The socket: Offer, envelope, Digest, use store logic, outcome state machine, receipts, test vectors | bnap-core |
bnap-plug | The plug traits, plus the WIT interface definition generated from them | bnap-plug |
bnap-scheme-* | Credential schemes | bnap-scheme-notes, bnap-scheme-l402, bnap-scheme-mpp, bnap-scheme-webbotauth, bnap-scheme-dpop |
bnap-rail-* | Settlement rails | bnap-rail-phoenixd, bnap-rail-nwc, bnap-rail-lnd, bnap-rail-fake |
bnap-store-* | Use stores | bnap-store-postgres, bnap-store-sqlite, bnap-store-tests |
bnap-signer-* | Offer and receipt signers | bnap-signer-file, bnap-signer-process |
bnap-carrier-* | Carriers that move BNAP messages | bnap-carrier-http, bnap-carrier-mcp |
| Applications | The gateway, the client wallet, the MCP server and the CLI | caparra-gate, caparra-wallet, bnap-mcp, bnap-cli |
Workspace crates
| Crate | Kind | Role |
|---|---|---|
bnap-core | library | Core protocol types, encoding and verification logic, free of I/O |
bnap-plug | library | Plug traits that connect signers, stores, schemes, rails and carriers to the core |
bnap-scheme-notes | library | Payment scheme that accepts Cashu ecash notes (bnap:notes, the default scheme) |
bnap-scheme-l402 | library | L402: macaroon plus Lightning invoice |
bnap-scheme-mpp | library | The Machine Payments Protocol (MPP) |
bnap-scheme-webbotauth | library | Web Bot Auth, based on HTTP Message Signatures |
bnap-scheme-dpop | library | Proof of possession based on DPoP (RFC 9449) |
bnap-rail-phoenixd | library | Settlement through a phoenixd Lightning node |
bnap-rail-nwc | library | Settlement over Nostr Wallet Connect (NIP-47) |
bnap-rail-lnd | library | Settlement through an LND node over gRPC |
bnap-rail-fake | library | In-memory rail for tests and local development |
bnap-store-postgres | library | PostgreSQL use store |
bnap-store-sqlite | library | SQLite use store |
bnap-store-tests | library | Conformance suite every use store must pass |
bnap-signer-file | library | Signer backed by a key file on local disk |
bnap-signer-process | library | Signer that delegates to an external process |
bnap-carrier-http | library | BNAP messages in HTTP requests and responses |
bnap-carrier-mcp | library | BNAP messages in MCP tool calls and results |
caparra-gate | binary caparra-gate | Gate in front of an HTTP or MCP service that enforces payment and authorization |
caparra-wallet | library | Client wallet that holds funds and answers payment challenges |
bnap-mcp | binary bnap-mcp | MCP server exposing wallet and payment tools to agents |
bnap-cli | binary bnap | Command-line tool for inspecting, paying and testing BNAP flows |
The three binaries are the ones release builds ship.
Planned, not in the workspace yet
The overview also names plugs that have no crate yet: the x402, privpass
and oauth schemes; the ldk, ark and fedimint rails plus the
off-by-default x402-evm and mpp-card rails; a KMS-unwrap signer; and SASL
and Nostr carriers.
How the pieces fit
caparra-gate caparra-wallet bnap-mcp bnap (bnap-cli)
\ | | /
+-------------+--------------+---------+
|
schemes rails stores signers carriers (the plugs)
|
bnap-plug (the plug traits)
|
bnap-core (the socket, no I/O)
The socket owns everything that must be computed one way only. Per the
overview, 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 capped signer 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.
TODO: replace the sketch above with the dependency graph the crates actually have once they depend on each other, and link each plug trait to its rustdoc.
Ways to load a plug
| Way | For | Isolation | Planned release |
|---|---|---|---|
| Compiled in (Cargo features) | First-party plugs | Same process | v0.5 |
| Sidecar over gRPC or HTTP, same interface | Any language and heavy dependencies | Separate process | v0.6 |
WASM component (WIT world bnap:plug) | Third-party schemes, pricers and judges | Sandbox with fuel and memory limits | v0.7 |
Fronts
Operators on any stack can put caparra-gate in front of a service as a
standalone proxy or behind Envoy ext_authz, Caddy forward_auth or nginx
auth_request, so they need no Rust of their own.
Workspace rules
- One
secp256k1per dependency tree, taken through thebitcoinre-export (bitcoin::secp256k1).cargo denyfails CI on a second copy. - Shared dependency versions live in
[workspace.dependencies]; member crates usedep.workspace = true. - The minimum supported Rust version is 1.94. CI checks it on every push.
unsafecode is forbidden workspace-wide.