The credit issuer for BitcoinPIR paid queries. Cashu ecash is the only
credit. The issuer keeps a Cashu wallet at the mint; a client presents a
cashuB… token to any PIR server, the server forwards it to the issuer
(POST /v2/redeem), and the issuer swaps it into that wallet and books
its value to the server's settlement ledger in gas. The issuer also sells
ecash from the wallet for Lightning (x402 exact/lnbtc,
POST /v2/x402/ecash).
The contract this service implements is "Issuer API" in
docs/CREDITS.md
in the main repository. Its types (pir-credit) are consumed from that
repository by git revision, so issuer and servers can never disagree on
the bytes. The v1 session grants (/v1/info, /v1/grants) are retired,
and so are the ARC credentials (POST /v2/credentials, presentation
kind 2): /v2/redeem refuses kind 2 with 400 unsupported_kind, a config
that still has an [arc] table fails to load, and arc.seed is no longer
read (keeping or deleting the file is the operator's call).
| Endpoint | Behaviour |
|---|---|
GET /v2/info |
the credits contract (docs/CREDITS.md): credit_sat, gas_per_credit, base_gas_per_frame, egress_gas_per_mb, mints, rate_card; offers is empty (nothing is sold up front) |
POST /v2/redeem |
a PIR server forwards what a client presented (RedeemRequestV1, signed by the server's identity key and carrying its operator-signed certificate); the issuer swaps each Cashu token (kind 1) into its wallet at the mint through a cdk wallet (gas_added = sats × gas_per_credit / credit_sat), books the value to that server, and answers RedeemResponseV1 signed by the same key servers pin |
POST /v2/x402/ecash |
with an [x402] table: sells Cashu ecash from the wallet for a Lightning payment (below); GET /v2/x402/invoices/{payment_hash} reports the state of a challenge invoice |
GET /healthz |
ok |
Rules that matter:
- Never credit without money. A redemption is booked only after the
mint accepted the swap; a token the mint refuses
gets
402 token_rejected, a mint that cannot be reached gets503 mint_unavailableand the token stays spendable. - Idempotent per token. The idempotency key is derived from the token's proof secrets; a token is spent at most once across all paths.
- Honest about the unknown. If the process dies (or the mint times out)
after the swap request was sent, the token is left in a
pendingstate and logged at error level for manual reconciliation. - No secrets on the PIR hosts. The issuer is the only component that
holds the issuer signing seed and the wallet seed.
Servers pin the issuer public key with
--credit-issuer-pubkey. - Redeem is authenticated and replay-safe. Only servers certified by an
operator key in
operator_pubkeysmay redeem;server_idmust match the certificate; a repeated(server_id, nonce)returns the stored signed answer without touching the mint; a token that already bought something (an ARC credential or a v1 grant, before their retirement) or was redeemed once answers402 already_redeemed. Every redemption is appended toredeem.jsonl, the per-server settlement ledger (bpir-issuer settlement --config …).
cargo build --release # rust-toolchain.toml pins the compiler
cargo testcdk is built with default-features = false, features = ["wallet"]; no
mint, nostr, or Lightning code is compiled into the binary. tests/mint.rs
runs the ecash sale against an in-process cdk mint (a dev-dependency whose
signatory compiles gRPC code), so cargo test needs protoc.
# once: keys
bpir-issuer keygen --out /etc/bitcoinpir/issuer/grant.key # issuer seed; prints the pubkey to pin
bpir-issuer wallet-seed --out /etc/bitcoinpir/issuer/wallet.seed
# config
cp config.example.toml /etc/bitcoinpir/issuer/config.toml # edit mints, CORS, operator keys, [x402]
# run
bpir-issuer serve --config /etc/bitcoinpir/issuer/config.toml
bpir-issuer balance --config /etc/bitcoinpir/issuer/config.toml # ecash held per (mint, unit)
bpir-issuer settlement --config /etc/bitcoinpir/issuer/config.toml # gas and sat redeemed per PIR server
bpir-issuer pubkey --key /etc/bitcoinpir/issuer/grant.key
bpir-issuer mnemonic --out /etc/bitcoinpir/mint/seed # BIP39 phrase for a cdk-mintd --seed-file (mode 0400)deploy/bpir-issuer.service is a hardened systemd unit; put a reverse
proxy or a Cloudflare tunnel in front (the browser pins
https://issuer.bitcoinpir.org). The service speaks plain HTTP and sends
the CORS headers the browser needs.
On every PIR server:
unified_server … --credit-issuer-url https://issuer.bitcoinpir.org \
--credit-issuer-pubkey /etc/bitcoinpir/issuer/grant.pub # 64 hex chars from keygen
# what each backend charges: --require-credits and --access (docs/CREDITS.md "Access policy")The issuer's operator_pubkeys must list the operator key that signed the
servers' identity certificates, or POST /v2/redeem refuses them.
| File | Contents | Backup |
|---|---|---|
grant.key |
32-byte Ed25519 issuer seed (signs redeem answers) | yes — rotating it means re-pinning every server |
wallet.seed |
64-byte Cashu wallet seed | yes — with the mint, it recovers the ecash |
wallet.sqlite |
proofs the issuer holds (cdk wallet store) | yes |
grants.jsonl |
append-only log: every token seen, what it bought, every failure (the name predates credits) | yes — it is the idempotency store and the reconciliation record |
redeem.jsonl |
every redemption per server | yes — it is the settlement ledger |
Money accumulates in the wallet as ecash. Melt it to Lightning with any cdk
wallet (for example cdk-cli with the same seed) or extend the balance
command; the issuer itself never pays out.
A mint may deduct input fees on the swap, so the amount credited can be
below the token's face value. /v2/redeem books what the mint actually
credited (recorded in grants.jsonl); an ecash sale spends sat plus the
input fee of its inputs, and the issuer absorbs that fee.
With a [x402] table (config.example.toml), POST /v2/x402/ecash
accepts the x402 flow (x402-foundation/x402
specs/schemes/exact/scheme_exact_lnbtc.md, merged 2026-09-23; HTTP
transport specs/transports-v2/http.md):
- A request without
PAYMENT-SIGNATUREis answered402withPAYMENT-REQUIRED(base64PaymentRequired, also the JSON body): schemeexact, networklnbtc:000000000019d6689c085ae165831e93,amountin millisatoshi,payTo= the node key,extra.invoice= a fresh BOLT11 whose description hash is thehttp:1request hash (JCS of method, URL, body hash, and the bound headers),extra.requestHash, profile, and params. - The client pays and retries the byte-identical request with
PAYMENT-SIGNATURE(base64PaymentPayloadcarryingpayload.preimage). The issuer recomputes the binding from the actual request, runs the facilitator checks (accepted terms, strict BOLT11 decode, payee =payTo, description hash = request hash, preimage = payment hash, paid-but-expired window), recordsx402:<network>:<payment_hash>in the store (its replay entry: a second claim isduplicate_settlement, an identical retry replays the answer), and answers withPAYMENT-RESPONSE(transaction= payment hash, nopayer). - A human can pay the same invoice from a phone:
GET /v2/x402/invoices/{payment_hash}reportsunpaid/paid/expiredand, once paid, the preimage, so the browser finishes step 2 unchanged. Only invoices with the x402 label prefix are visible.
Fresh invoices are limited per client address and globally. Money lands in
the node's channel balance (not in the Cashu wallet); bpir-issuer settlement is unaffected.
The rail sells plain Cashu ecash from the issuer's wallet (first mint
in mints). The body is {"sat": 1000, "outputs": [{"amount": 8, "id": "<keyset id>", "B_": "<66 hex>"}, …]}: 1 to 256 NUT-00 blinded outputs of
the mint's active sat keyset, powers of two it signs, distinct B_,
summing to sat within [x402] ecash_min_sat..ecash_max_sat (defaults 10
and 20000); anything else is 400 invalid_request.
- Without
PAYMENT-SIGNATURE: the402challenge above forsat × 1000msat, bound to this path and body, or503 float_exhaustedwhen the wallet cannot coversatplus the mint's input fee. - The paid, byte-identical retry settles as above. The issuer records
x402_ecash_pendingunder the payment, reserves proofs worth exactlysatplus their input fee (one ordinary wallet swap makes change first when it lacks the denominations), swaps them at the mint into exactly the client's outputs, recordsx402_ecash_issued, and answers{"signatures": [{"amount", "id", "C_", "dleq": {"e", "s"}}, …]}(NUT-00 blind signatures with NUT-12 DLEQ, in output order) withPAYMENT-RESPONSE. The client unblinds them; the issuer never sees the proofs it sold. An identical retry replays the stored signatures. - If the mint cannot be reached or the outcome is unknown after settlement,
the answer is
503 retryand the record stays pending (the reserved inputs stay pending in the wallet). The next identical retry asks the mint first (NUT-09 restore): outputs it already signed are answered and the inputs marked spent; otherwise the inputs are checked (NUT-07) and the swap is made again.
Sales draw down the Cashu wallet (bpir-issuer balance); keep it topped up.
The issuer never opens the Core Lightning socket. For x402 (exact/lnbtc)
it needs three node calls, invoice, listinvoices, and waitinvoice, and it
gets exactly those through bpir-cln-rpc-guard: a proxy that runs in the node
socket's group, exposes a second Unix socket to the issuer's group, forwards a
request unchanged only if its method and named parameters pass the allowlist,
and answers everything else itself with a JSON-RPC error. Bounds (flags):
label prefix (default bpir-x402-, also required on the read calls, so the
mint's invoices stay out of reach), amount_msat range, expiry maximum,
description size, deschashonly must be true (description-hash invoices
only, no preimage, no fallbacks), listinvoices needs exactly one of
label / payment_hash / invstring (no unfiltered listing), an invoice rate
limit per minute, and a connection cap. Responses are copied back byte for byte.
Unit: deploy/bpir-cln-rpc-guard.service (User= its own account,
SupplementaryGroups= the node socket's group, Group= the issuer's group,
UMask=0007, socket /run/bpir-cln-rpc-guard/rpc.sock).
src/config.rs TOML config and validation, seed-file reader
src/cashu.rs token summary, idempotency key, Swapper trait, cdk implementation
src/ecash.rs x402 ecash sale: request checks, EcashSeller trait, cdk implementation
src/issuer_key.rs the Ed25519 key that signs redeem answers
src/redeem.rs POST /v2/redeem verification, replay index, settlement ledger
src/store.rs JSON-lines idempotency/audit store
src/api.rs axum router, handlers, contract error codes
src/main.rs CLI
tests/api.rs contract tests with a scripted fake mint
tests/mint.rs ecash sales against an in-process cdk mint
License: MIT OR Apache-2.0.