Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,61 @@

- CEP-8 capability pricing and payments (in progress; foundational pieces, not yet a
usable payment flow):
- NIP-47 (Nostr Wallet Connect) client infrastructure, letting a server issue and a
client pay Lightning BOLT11 invoices through a Nostr Wallet Connect wallet, behind
the off-by-default `nwc` feature. Enabling it pulls in NIP-04 and so adds `aes`,
`cbc` and `cipher` to the dependency tree. `parse_nwc_uri` reads a
connection string in either shape wallets emit, including the
`nostr+walletconnect:<pubkey>?...` pathname form that
`NostrWalletConnectURI::parse` rejects and the ts-sdk accepts. `NwcClient` speaks
request/response and notifications, building its own relay pool from the connection's
relays (`NwcClient::connect`) so its permanent subscription does not land on a pool
shared with the transport; `with_pool` injects one for tests. Because
`RelayPoolTrait` has no unsubscribe, it holds one subscription and one reader task and
correlates responses in process by request event id, rather than the ts-sdk
subscription-per-request shape that would leak one live REQ per payment. Wallet
responses are parsed permissively field for field, so an optional field that is
missing, unknown, or present with the wrong JSON type cannot turn a paid invoice into
a failed verification, and events are decrypted by ciphertext shape rather than by
kind, so a NIP-04 payload on kind 23197 still decodes and a wallet publishing both
notification kinds is not mis-handled. Failures are a typed `NwcError` whose messages
carry no wallet payload, and whose `Timeout` and `Closed` variants report a `Delivery`
of `NotSent`, `Unknown` or `Sent` whose `may_have_reached_wallet()` tells a caller
whether retrying a non-idempotent `pay_invoice` is safe. A
`MockWallet` behind `test-utils` scripts answers, errors, silence, delays and malformed
replies over a linked `MockRelayPool`, and can publish a `payment_received`
notification to drive settlement.
- The NWC payment processor, `LnBolt11NwcPaymentProcessor`, which lets a server charge
for a capability in Lightning over Nostr Wallet Connect, for PMI
`bitcoin-lightning-bolt11`, behind the same off-by-default `nwc` feature. `create_payment_required` mints a BOLT11 invoice via
`make_invoice` in msats with an expiry, caches the invoice to `payment_hash` mapping,
and advertises the configured TTL rather than the wallet's own `expires_at`, which
providers return in non-standard forms. `verify_payment` waits for settlement either
by polling `lookup_invoice` on the ts backoff schedule, floored at the configured poll
interval and jittered, or by listening for a `payment_received` notification when the
wallet advertises one on its info event. Concurrent verifications of the same invoice
are deduplicated onto one shared future so duplicate delivery cannot multiply wallet
and relay load. A lagging wallet answering `NOT_FOUND` is treated as still pending
while every other wallet error is fatal, `expired` and `failed` are terminal, and an
invoice counts as settled on either `state == "settled"` or a positive `settled_at`.
Every wait selects on the middleware's cancellation token, and a cancelled
verification always returns an error, never an empty success that the middleware
would read as payment. Amounts are validated before any wallet call, and no tracing
call carries the invoice, which is a bearer payment request.
- The NWC payment handler, `LnBolt11NwcPaymentHandler`, which lets a client pay for a
priced capability, completing the rail. It pays an offered BOLT11 invoice with
`pay_invoice`, sending a NIP-40 expiration so a wallet honouring it will not act on a
request the caller has already abandoned,
bounded by its own response timeout because the client engine gives `handle()`
neither a timeout nor a cancellation token, and leaves the spending decision with
`payment_policy` rather than adding a second gate. A wallet that answers with neither
a result nor an error is a failure, not a silent success. Note that the handler is
invoked automatically only in the transparent lifecycle; explicit gating routes
`-32042` to the `on_payment_required` callback, from which the handler can be driven
by hand. Both NWC option structs now carry `new()` and `with_*` builders, since
`#[non_exhaustive]` makes a struct literal unusable from a downstream crate.
`docs/payments.md` documents the rail, its options, which lifecycle invokes the
handler, when a failed payment is safe to retry, and its operational notes.
- Server-side payment-interaction negotiation and advertisement: the server transport
now parses client `pmi` and `payment_interaction` tags, negotiates the effective
session mode (`transparent` by default, `explicit_gating` when the server policy
Expand Down
19 changes: 19 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@ futures = "0.3"
# Nostr SDK with NIP-59 (Gift Wrap) support
nostr-sdk = { version = "0.44", features = ["nip59"] }

# Direct floor on the transitive `nostr` crate, not used directly: `nostr-sdk`
# accepts "0.44", and 0.44.5 and earlier panic on a malformed NIP-04 payload.
# The NWC rail decrypts wallet-controlled NIP-04 content on its reader task, so
# that panic is remotely reachable. Raising the floor here is the only way to
# refuse those versions.
nostr = { version = ">=0.44.7, <0.45", default-features = false, features = ["std"] }

# Logging
tracing = "0.1"

Expand All @@ -60,6 +67,10 @@ default = ["rmcp"]
rmcp = ["dep:rmcp"]
## Exposes `MockRelayPool` for use in downstream integration tests.
test-utils = []
## CEP-8 Lightning rail over NIP-47 (Nostr Wallet Connect). Off by default.
## Enables `nostr-sdk/nip47`, which pulls in NIP-04 and therefore adds three
## crates to the tree: `aes`, `cbc` and `cipher`. Measured with `cargo tree`.
nwc = ["nostr-sdk/nip47"]

[[example]]
name = "rmcp_integration_test"
Expand Down Expand Up @@ -113,6 +124,14 @@ required-features = ["test-utils"]
name = "payments_bilateral_e2e"
required-features = ["test-utils"]

[[test]]
name = "payments_nwc_e2e"
required-features = ["nwc", "test-utils"]

[[test]]
name = "payments_nwc_real_wallet"
required-features = ["nwc", "test-utils"]

[[test]]
name = "rmcp_handshake_survival"
required-features = ["rmcp", "test-utils"]
Expand Down
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,10 +228,13 @@ keepalive timer model.
Both CEP-8 payment lifecycles run end to end: servers price capabilities and
gate them through the transparent or explicit-gating lifecycle via
`with_server_payments`, and clients auto-pay, keep paying requests alive, and
drive the gated retry flow via `with_client_payments`. Payment rails ship as pluggable traits with
deterministic fakes; real rails are a later phase. See
drive the gated retry flow via `with_client_payments`. Payment rails plug into
the same traits: the first real rail, Lightning BOLT11 over NWC (NIP-47),
ships behind the off-by-default `nwc` feature as
`LnBolt11NwcPaymentProcessor` and `LnBolt11NwcPaymentHandler`, alongside the
deterministic fakes for wallet-free testing. See
[docs/payments.md](docs/payments.md) for both lifecycles, the client shapes,
and the operational notes.
the NWC rail and its operational notes.

### Server Transport Config

Expand Down
165 changes: 153 additions & 12 deletions docs/payments.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,17 @@ individual capabilities, and a client can pay for them either transparently
explicit gating (the request is refused with a payment error until paid).
This guide covers both sides of the wire as this SDK ships them.

CEP-8 support ships in two phases. What ships today is Phase A: the full
protocol machinery on both sides, with deterministic fakes
(`FakePaymentProcessor` and `FakePaymentHandler`, behind the `test-utils`
feature) standing in for real payment rails. Phase B is the rails themselves
(Lightning over NWC/NIP-47, LNURL, LNbits), which plug into the same
CEP-8 support ships in two phases. Phase A is the full protocol machinery on
both sides, with deterministic fakes (`FakePaymentProcessor` and
`FakePaymentHandler`, behind the `test-utils` feature) for testing without a
wallet. Phase B is the real payment rails, which plug into the same
`PaymentProcessor` / `PaymentHandler` traits without API changes.

The first Phase B rail, Lightning BOLT11 over NWC (NIP-47), ships behind the
off-by-default `nwc` feature and is documented under
[The NWC rail](#the-nwc-rail-nip-47) below. NIP-57 zaps are still to come.
LNbits, listed as a planned rail in earlier drafts, has been dropped.

The protocol reference is the CEP-8 specification in the ContextVM
documentation repository.

Expand Down Expand Up @@ -343,11 +347,148 @@ The `-32042` / `-32043` / `-32602` codes, the three notification methods,
and the wire field orders are pinned by the conformance suite against the
reference implementation.

## Phase B: payment rails
## The NWC rail (NIP-47)

Lightning BOLT11 over Nostr Wallet Connect, for PMI
`bitcoin-lightning-bolt11`. Enable the `nwc` feature, which is off by default.
It switches on `nostr-sdk/nip47`, which pulls in NIP-04 and therefore adds
three crates to the tree: `aes`, `cbc` and `cipher`.

```toml
contextvm-sdk = { version = "0.2", features = ["nwc"] }
```

Two halves, each usable on its own. On the server,
`LnBolt11NwcPaymentProcessor` mints an invoice with `make_invoice` and waits
for settlement. On the client, `LnBolt11NwcPaymentHandler` pays one with
`pay_invoice`.

```rust,ignore
use contextvm_sdk::payments::{
LnBolt11NwcPaymentProcessor, LnBolt11NwcPaymentProcessorOptions,
ServerPaymentsOptions, with_server_payments,
};

let processor = Arc::new(LnBolt11NwcPaymentProcessor::from_uri(
relay_pool.clone(),
"nostr+walletconnect://<wallet_pubkey>?relay=wss://relay.example&secret=<hex>",
LnBolt11NwcPaymentProcessorOptions::new()
.with_ttl(Duration::from_secs(300))
.with_poll_interval(Duration::from_millis(1500)),
)?);

with_server_payments(
&mut server_transport,
ServerPaymentsOptions::new(vec![processor], vec![priced_capability]),
)?;
```

The connection string is accepted in both shapes wallets emit, including the
`nostr+walletconnect:<pubkey>?...` form (pubkey in the path) that
`NostrWalletConnectURI::parse` rejects.

### Options

| Processor option | Default | Meaning |
| --- | --- | --- |
| `ttl` | 300 s | TTL advertised on `payment_required` |
| `invoice_expiry` | `ttl` | `make_invoice.expiry` |
| `poll_interval` | 1500 ms | floor under the `lookup_invoice` backoff |
| `response_timeout` | 60 s | per-request wallet response timeout |
| `notification_verification` | auto | `Some(false)` always polls, `Some(true)` always listens, `None` detects once from the wallet's info event |
| `max_in_flight_verifications` | 5000 | cap on deduplicated verifications |
| `invoice_hash_cache_size` | 10000 | cap on cached invoice to payment-hash mappings |

| Handler option | Default | Meaning |
| --- | --- | --- |
| `response_timeout` | 60 s | per-payment wallet response timeout |

Both option structs are `#[non_exhaustive]`, so construct them with `new()`
and the `with_*` builders rather than a struct literal.

### Which lifecycle invokes the handler

In the transparent lifecycle the client engine calls the handler
automatically when `payment_required` arrives, gated by `payment_policy`.

In explicit gating it does not: the engine routes `-32042` to the
`on_payment_required` callback, and the handler is the transparent
lifecycle's hook. To use this rail for gating, call the handler from inside
that callback with the offered `PaymentOption`, then report
`PaymentApproval { paid: true, .. }` so the engine retries. See
`tests/payments_nwc_e2e.rs` for a worked example.

### Operational notes

**A late settlement is money out with no execution.** `verify_payment` stops
when the middleware's payment TTL expires, but the invoice stays payable on
the Lightning network until its own expiry. A payer who settles after the TTL
has paid for a capability that will not run. Keep `invoice_expiry` at or
below `ttl` so the rail's own invoice expires with the window, and treat
reconciliation of late settlements as an operator concern: this SDK does not
refund.

**The wallet is a trust boundary.** The processor believes the wallet about
settlement. A compromised or buggy wallet reporting `state: "settled"` causes
the server to execute a paid capability for free. Point the processor at a
wallet you control, not one a counterparty supplies.

**Rate limiting is still upstream's job.** Every successful offer spawns one
detached verification task, and with a real rail each holds a wallet
connection and polls it. `max_pending_payments` and the authorization store
caps bound state, not tasks.

**Settlement is read generously, failure strictly.** An invoice counts as
settled on `state == "settled"` *or* a positive `settled_at`, because wallets
populate one or the other. `NOT_FOUND` is treated as still pending, since a
lagging wallet that has not yet seen its own invoice has not refused it; every
other wallet error code is fatal, as are `expired` and `failed`.

**Nothing logs the invoice.** A BOLT11 string is a bearer payment request, so
no tracing call in the rail binds `pay_req`, the invoice, or a preimage. Logs
carry the request event id, the state, and booleans for field presence.

**A failed `pay_invoice` is not automatically safe to retry.** `pay_invoice`
is not idempotent, so a caller that retries the wrong failure pays twice.
`NwcError::Timeout` and `NwcError::Closed` both carry a `Delivery`, and
`NwcError::may_have_reached_wallet()` reads it for you:

| `Delivery` | When | Safe to retry |
| --- | --- | --- |
| `NotSent` | the request never left, for example a closed client or a relay that rejected the event | yes |
| `Unknown` | the deadline fell *inside* the publish | no |
| `Sent` | a relay accepted the event and the answer never came | no |

`Unknown` exists because the pool writes the EVENT before it waits on the
relay's `OK`, so a deadline that fires mid-publish leaves delivery genuinely
unknowable. It is grouped with `Sent` rather than `NotSent`: the conservative
direction is the one that does not pay twice. A wallet error or a malformed
response also count as reached, since the wallet plainly answered.

### Testing against a real wallet

CI gates on relay-level mocks, not a live wallet: `MockWallet` (behind
`test-utils`) speaks the real NIP-47 wire protocol over a `MockRelayPool`, so
the client's own encrypt, publish, correlate and decrypt paths run in CI.

`tests/payments_nwc_real_wallet.rs` exercises a real wallet and is
`#[ignore]`d and environment-gated. It needs two distinct wallets, because
one side issues and the other pays:

```sh
export CONTEXTVM_NWC_SERVER_URI='nostr+walletconnect://...'
export CONTEXTVM_NWC_CLIENT_URI='nostr+walletconnect://...'
cargo test --features nwc,test-utils --test payments_nwc_real_wallet -- --ignored --nocapture
```

It moves real money. The amount is 1 sat unless `CONTEXTVM_NWC_AMOUNT_SATS`
says otherwise, and nothing it does is reversible.

## Other rails

The `PaymentProcessor` (server: create and verify a payment request) and
`PaymentHandler` (client: settle one) traits are the extension surface.
Planned rails include Lightning BOLT11 via NWC (NIP-47), LNURL (NIP-57
zaps), and LNbits REST; all are deliberately deferred, and the options
structs are non-exhaustive so rails can add configuration without breaking
changes.
The `PaymentProcessor` and `PaymentHandler` traits are the extension surface,
and the options structs are non-exhaustive so a rail can add configuration
without a breaking change. NIP-57 zaps are the next rail planned, server side
only: NIP-57 obliges the payer only to pay a BOLT11 invoice, and both rails
issue `pmi: bitcoin-lightning-bolt11`, so a zap offer already routes to the
NWC handler on the client.
Loading