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

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.md in the repository.

What is in this guide

  • Quickstart: build the binaries, put caparra-gate in front of a local service on the fake rail, and see what a client gets.
  • Operator config reference: the configuration file that caparra-gate reads.
  • Agent usage: how agents pay through bnap-mcp, the bnap CLI 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]

KeyDefaultMeaning
originrequiredThe origin the manifest is issued for, as an RFC 6454 serialization (https://api.example)
listen127.0.0.1:8080The address to serve on
frontproxyproxy (reverse proxy to upstream), forward_auth (Caddy), auth_request (nginx); envoy_ext_authz is refused at start
upstreamnoneThe application, http://host:port with no path; plain HTTP only; proxy front only
replay_window_s86400Must equal the manifest’s value (manifest sign writes it)
pending_timeout_s300Must exceed the longest action; must equal the manifest’s value
offer_lifetime_s300Lifetime of exact-scope offers, at least 60
route_offer_lifetime_s3600Lifetime of route-scope offers, at least 60
mac_key_filerequiredFile 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_bytes1048576Largest request content the gate buffers
max_response_bytes16777216Largest application answer the gate buffers
keep_response_bytes65536Answers up to this size are kept for replays; 0 keeps the receipt only
trust_forwarded_forfalseTake the client address from the rightmost X-Forwarded-For hop; set it only behind a trusted front; always on behind an auth front

[manifest]

KeyDefaultMeaning
filerequiredThe manifest made offline by caparra-gate manifest sign, served byte for byte at /.well-known/bnap
rootnoneThe root public key (64 hex) the manifest must be signed by; recommended
requiredfalseThe required flag manifest sign writes: clients then pay this origin only through BNAP offers
max_age_s604800The 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]

KeyDefaultMeaning
kindsqlitesqlite, or memory (a temporary SQLite file, for tests, logged loudly)
pathnoneThe SQLite file of the use store
issuer_pathpath + .issuerThe SQLite file of the bnap:notes issuer tables

The PostgreSQL store exists as a crate but is not wired into the gate yet.

[signer]

KeyMeaning
kindfile or process
keysfile: the signer key file (mode 0600; see crates/bnap-signer-file)
socketprocess: the Unix socket of bnap-signer
signer_uidprocess: the user bnap-signer runs as; the gate checks the peer

bnap:notes needs the file signer for now.

[[rail]]

KeyMeaning
idThe name schemes refer to
kindlnd, phoenixd, nwc or fake
invoice_expiry_sInvoice lifetime; must not outlive an exact-scope offer
address, cert, macaroonlnd: gRPC address, TLS certificate file, macaroon file
url, password_filephoenixd: HTTP API URL and the file holding its password
uri_envnwc: the environment variable that holds the NWC URI (default NWC_URI)
registry, seedfake: 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]]

KeyDefaultMeaning
idrequiredbnap:notes, httpauth:L402 or payment:lightning/charge
rail / railsThe rail or rails it settles over
enabledtrue
secret_fileL402 and MPP: file holding the scheme’s secret, 64 hex
realmorigin’s hostMPP: the challenge realm
networkmainnetMPP: mainnet, signet or regtest
max_fund_satbnap:notes: funding inside the 402 up to this many sat
issuer_url<origin>/cashubnap: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]]

KeyMeaning
idhttpauth:DPoP, httpauth:Bearer or httpsig:web-bot-auth
enabledDefault true
keysDPoP and Bearer: base64url JWK thumbprints of registered keys
tokensDPoP and Bearer: access tokens, { token = "...", jkt = "..." }; with jkt the token is bound to that key
agentsWeb 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]]

KeyDefaultMeaning
idrequiredThe route ID, part of the route scope
matchrequiredA method, a space and a path, such as POST /v1/chat/completions; /* matches every path below
scoperouteroute (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
meteredfalseThe charge is set at settlement by the meter
meterThe meter’s name, such as tokens
meter_path/usage/total_tokens for tokensJSON pointer to the count in the answer
ratePrice 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_experimentalfalseNeeded for every Lightning scheme and bnap:notes, which the registry marks experimental
allow_non_bitcoinfalseNeeded for schemes on non-Bitcoin rails or units
upstreamhttphttp, 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]

KeyMeaning
kindtable
table{ <route id> = { sat = N } }: overrides a route’s price

[limits]

Per client address, per minute (draft-00 section 12, DECISIONS row 24).

KeyDefaultMeaning
mints_per_minute20Offers that mint an invoice, and fund quotes
verifies_per_minute600Credentials that reach a scheme plug
verifies_per_offer_per_minute6000The same, per offer (route offers are shared)
max_change_outputs64Most change outputs one outcome may have signed

Defaults from the specification

SettingDefaultSource
pending_timeout300 sProvisional decision for ERRATA row 21
replay_window86,400 sProvisional decision for ERRATA row 21
Manifest max_ageat most 2,592,000 s (30 days)draft-00 9.1
Credential size on HTTPat most 9,216 bytes; larger is TOO_LARGEProvisional 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:

ToolArgumentsResult
fetch_paidurl, method?, body?, max_sat?Status, content type, body (text or base64, at most 256 KiB) and the payment
budgetaction? (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)
receiptsaction? (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 offer and has not passed its not_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:

FamilyRoleWorkspace crates today
bnap-coreThe socket: Offer, envelope, Digest, use store logic, outcome state machine, receipts, test vectorsbnap-core
bnap-plugThe plug traits, plus the WIT interface definition generated from thembnap-plug
bnap-scheme-*Credential schemesbnap-scheme-notes, bnap-scheme-l402, bnap-scheme-mpp, bnap-scheme-webbotauth, bnap-scheme-dpop
bnap-rail-*Settlement railsbnap-rail-phoenixd, bnap-rail-nwc, bnap-rail-lnd, bnap-rail-fake
bnap-store-*Use storesbnap-store-postgres, bnap-store-sqlite, bnap-store-tests
bnap-signer-*Offer and receipt signersbnap-signer-file, bnap-signer-process
bnap-carrier-*Carriers that move BNAP messagesbnap-carrier-http, bnap-carrier-mcp
ApplicationsThe gateway, the client wallet, the MCP server and the CLIcaparra-gate, caparra-wallet, bnap-mcp, bnap-cli

Workspace crates

CrateKindRole
bnap-corelibraryCore protocol types, encoding and verification logic, free of I/O
bnap-pluglibraryPlug traits that connect signers, stores, schemes, rails and carriers to the core
bnap-scheme-noteslibraryPayment scheme that accepts Cashu ecash notes (bnap:notes, the default scheme)
bnap-scheme-l402libraryL402: macaroon plus Lightning invoice
bnap-scheme-mpplibraryThe Machine Payments Protocol (MPP)
bnap-scheme-webbotauthlibraryWeb Bot Auth, based on HTTP Message Signatures
bnap-scheme-dpoplibraryProof of possession based on DPoP (RFC 9449)
bnap-rail-phoenixdlibrarySettlement through a phoenixd Lightning node
bnap-rail-nwclibrarySettlement over Nostr Wallet Connect (NIP-47)
bnap-rail-lndlibrarySettlement through an LND node over gRPC
bnap-rail-fakelibraryIn-memory rail for tests and local development
bnap-store-postgreslibraryPostgreSQL use store
bnap-store-sqlitelibrarySQLite use store
bnap-store-testslibraryConformance suite every use store must pass
bnap-signer-filelibrarySigner backed by a key file on local disk
bnap-signer-processlibrarySigner that delegates to an external process
bnap-carrier-httplibraryBNAP messages in HTTP requests and responses
bnap-carrier-mcplibraryBNAP messages in MCP tool calls and results
caparra-gatebinary caparra-gateGate in front of an HTTP or MCP service that enforces payment and authorization
caparra-walletlibraryClient wallet that holds funds and answers payment challenges
bnap-mcpbinary bnap-mcpMCP server exposing wallet and payment tools to agents
bnap-clibinary bnapCommand-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

WayForIsolationPlanned release
Compiled in (Cargo features)First-party plugsSame processv0.5
Sidecar over gRPC or HTTP, same interfaceAny language and heavy dependenciesSeparate processv0.6
WASM component (WIT world bnap:plug)Third-party schemes, pricers and judgesSandbox with fuel and memory limitsv0.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 secp256k1 per dependency tree, taken through the bitcoin re-export (bitcoin::secp256k1). cargo deny fails CI on a second copy.
  • Shared dependency versions live in [workspace.dependencies]; member crates use dep.workspace = true.
  • The minimum supported Rust version is 1.94. CI checks it on every push.
  • unsafe code is forbidden workspace-wide.