From 4bd505f288090ab9c1847a1115c88bd20b338b13 Mon Sep 17 00:00:00 2001 From: ai-chen2050 <1033467071@qq.com> Date: Tue, 21 Apr 2026 18:29:12 +0800 Subject: [PATCH 01/32] feat: add yaml tags to config fields and normalize paths for sqlite and admin settings --- aperture.go | 10 ++++++++++ aperturedb/postgres.go | 4 ++-- aperturedb/sqlite.go | 2 +- config.go | 2 +- 4 files changed, 14 insertions(+), 4 deletions(-) diff --git a/aperture.go b/aperture.go index 99a760a2..38ec3774 100644 --- a/aperture.go +++ b/aperture.go @@ -697,6 +697,16 @@ func getConfig() (*Config, error) { cfg.Authenticator.MacDir = lnd.CleanAndExpandPath( cfg.Authenticator.MacDir, ) + if cfg.Sqlite != nil { + cfg.Sqlite.DatabaseFileName = lnd.CleanAndExpandPath( + cfg.Sqlite.DatabaseFileName, + ) + } + if cfg.Admin != nil { + cfg.Admin.MacaroonPath = lnd.CleanAndExpandPath( + cfg.Admin.MacaroonPath, + ) + } // Set default mailbox address if none is set. if cfg.Authenticator.MailboxAddress == "" { diff --git a/aperturedb/postgres.go b/aperturedb/postgres.go index 8d955fa8..9b5f428c 100644 --- a/aperturedb/postgres.go +++ b/aperturedb/postgres.go @@ -34,8 +34,8 @@ type PostgresConfig struct { User string `long:"user" description:"Database user."` Password string `long:"password" description:"Database user's password."` DBName string `long:"dbname" description:"Database name to use."` - MaxOpenConnections int32 `long:"maxconnections" description:"Max open connections to keep alive to the database server."` - RequireSSL bool `long:"requiressl" description:"Whether to require using SSL (mode: require) when connecting to the server."` + MaxOpenConnections int32 `long:"maxconnections" description:"Max open connections to keep alive to the database server." yaml:"maxconnections"` + RequireSSL bool `long:"requiressl" description:"Whether to require using SSL (mode: require) when connecting to the server." yaml:"requireSSL"` } // DSN returns the dns to connect to the database. diff --git a/aperturedb/sqlite.go b/aperturedb/sqlite.go index a8c0a4a7..770f2b71 100644 --- a/aperturedb/sqlite.go +++ b/aperturedb/sqlite.go @@ -44,7 +44,7 @@ type SqliteConfig struct { // DatabaseFileName is the full file path where the database file can be // found. - DatabaseFileName string `long:"dbfile" description:"The full path to the database."` + DatabaseFileName string `long:"dbfile" description:"The full path to the database." yaml:"dbfile"` } // SqliteStore is a database store implementation that uses a sqlite backend. diff --git a/config.go b/config.go index f55d4971..5b4bf234 100644 --- a/config.go +++ b/config.go @@ -192,7 +192,7 @@ type AdminConfig struct { // CORSOrigins controls which origins are allowed to call the admin REST // API. If empty, CORS is disabled and browsers can only use same-origin. - CORSOrigins []string `long:"corsorigin" description:"Allowed CORS origins for the admin REST API."` + CORSOrigins []string `long:"corsorigin" description:"Allowed CORS origins for the admin REST API." yaml:"corsorigin"` } type Config struct { From 474713d0dc8eab1a9a0b833a21357ac21ab395f0 Mon Sep 17 00:00:00 2001 From: ai-chen2050 <1033467071@qq.com> Date: Wed, 22 Apr 2026 15:21:20 +0800 Subject: [PATCH 02/32] refactor: rebrand aperture to loka-prism-l402 with updated documentation and config templates --- .gitignore | 1 + README.md | 364 ++++++++++++++++++++++++++++++----------------------- 2 files changed, 208 insertions(+), 157 deletions(-) diff --git a/.gitignore b/.gitignore index c9c81861..3a8f54c9 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ cmd/aperture/aperture # misc .vscode .idea +.aperture # dashboard build output (generated by npm run build / make build-dashboard) dashboard/out/ diff --git a/README.md b/README.md index 4751dcfd..2846cca3 100644 --- a/README.md +++ b/README.md @@ -1,154 +1,222 @@ -# L402 (Lightning HTTP 402) API Key proxy - -Aperture is your portal to the Lightning-Native Web. Aperture is used in -production today by [Lightning Loop](https://lightning.engineering/loop), a -non-custodial on/off ramp for the Lightning Network. - -Aperture is a HTTP 402 reverse proxy that supports proxying requests for gRPC -(HTTP/2) and REST (HTTP/1 and HTTP/2) backends using the [L402 Protocol -Standard][l402]. L402 is short for: the Lightning HTTP 402 -protocol. L402 combines HTTP 402, macaroons, and the Lightning Network to -create a new standard for authentication and paid services on the web. - -L402 is a new standard protocol for authentication and paid APIs developed by -Lightning Labs. L402 API keys can serve both as authentication, as well as a -payment mechanism (one can view it as a ticket) for paid APIs. In order to -obtain a token, we require the user to pay us over Lightning in order to obtain -a preimage, which itself is a cryptographic component of the final L402 token - -The implementation of the authentication token is chosen to be macaroons, as -they allow us to package attributes and capabilities along with the token. This -system allows one to automate pricing on the fly and allows for a number of -novel constructs such as automated tier upgrades. In another light, this can be -viewed as a global HTTP 402 reverse proxy at the load balancing level for web -services and APIs. +# Loka Prism L402 — Agentic Paywall Proxy + +[![Website](https://img.shields.io/badge/website-lokachain.org-blue.svg)](https://lokachain.org/) +[![Twitter](https://img.shields.io/badge/twitter-@lokachain-1DA1F2.svg)](https://x.com/lokachain) +[![Status](https://img.shields.io/badge/status-Active-success.svg)](https://github.com/loka-network) +[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) + +> **The HTTP paywall layer of the Loka agentic payment stack.** A reverse +> proxy that turns any gRPC / REST backend into an L402-metered API, settled +> over Loka's Sui-adapted Lightning Network. + +Loka Prism L402 is a production-ready HTTP 402 reverse proxy that mints, +verifies, and redeems [L402 (Lightning HTTP 402)][l402] tokens in front of +arbitrary backend services. Clients — humans or autonomous AI agents — pay a +Lightning invoice to obtain a macaroon-based token, which then authorizes +subsequent API calls. The proxy handles pricing, invoice generation, token +issuance, rate limiting, and revenue accounting, so your backend only has to +serve requests. + +This is a fork of [lightninglabs/aperture][upstream], adapted for the **Loka +agentic payment ecosystem**: Sui-native settlement via +[`loka-p2p-lnd`](https://github.com/loka-network/loka-p2p-lnd), per-agent +wallet isolation via +[`agents-pay-service`](https://github.com/loka-network/agents-pay-service), +and an admin API / dashboard / CLI / MCP surface designed for programmatic +use by AI agents. [l402]: https://github.com/lightninglabs/L402 +[upstream]: https://github.com/lightninglabs/aperture + +--- + +## Position in the Loka Stack + +```text +┌──────────────────────────────────────────────────────────────┐ +│ AI Agents (humans too) │ +└───────────────┬──────────────────────────┬───────────────────┘ + │ HTTP / gRPC │ REST API + │ + L402 / MPP token │ (per-wallet key) + ▼ ▼ +┌──────────────────────────┐ ┌────────────────────────────────┐ +│ Loka Prism L402 │ │ Agents-Pay-Service │ +│ (this repo) │ │ (LNbits fork, per-agent │ +│ │ │ wallet + API key isolation) │ +│ • L402 / MPP paywalls │ │ • BOLT11 / LNURL invoices │ +│ • Reverse proxy │ │ • SUI / MIST denomination │ +│ • Admin API / dashboard │ │ • Extensions (orders, tpos) │ +└────────────┬─────────────┘ └────────────────┬───────────────┘ + │ invoice / settle │ funding source + └──────────────┬───────────────────┘ + ▼ + ┌──────────────────────────────────┐ + │ Loka P2P Lightning Node (LND) │ + │ • BOLT-compliant HTLC routing │ + │ • Sui adapter (suinotify, │ + │ suiwallet, sui_estimator) │ + │ • Move-enforced channel state │ + │ • Setu backend (upcoming) │ + └──────────────────────────────────┘ +``` + +| Repo | Role | +|------|------| +| **[loka-prism-l402](.)** _(you are here)_ | L402 paywall proxy — the request-level metering layer in front of your APIs | +| **[loka-p2p-lnd](https://github.com/loka-network/loka-p2p-lnd)** | Lightning Network Daemon, adapted to run channels on Sui (and Setu, upcoming) | +| **[agents-pay-service](https://github.com/loka-network/agents-pay-service)** | LNbits-based per-agent wallet service — each AI agent gets an isolated API key and balance | + +An AI agent calls a metered API; the agent's wallet lives in +`agents-pay-service`; Prism gates the request with an L402 challenge; the +agent's wallet pays the invoice via `loka-p2p-lnd`; Prism verifies the +preimage and forwards the request. One payment rail, three purpose-built +services. + +--- + +## What Prism Does + +**L402 (Lightning HTTP 402) paywalls.** Prism intercepts incoming HTTP/gRPC +requests, and if the client doesn't yet hold a valid token, it responds with +`402 Payment Required` plus a Lightning invoice. Once the client pays, it +presents a macaroon-based token on subsequent requests. + +**Payment HTTP Auth scheme (MPP).** Alongside classic L402, Prism supports +the newer MPP scheme and prepaid sessions (deposit → top-up → close), which +remove per-request invoice overhead for agents that make many calls. Enable +via `authenticator.enablempp: true` and `enablesessions: true`. + +**Reverse proxy.** Configurable per-service routing by host/path regex, +pricing (static or via a dynamic pricer gRPC), whitelist paths, and +per-endpoint token-bucket rate limits. + +**Admin API + dashboard + CLI + MCP.** Manage services, inspect +transactions, revoke tokens, and query revenue stats — from a browser, a +shell, or an AI agent over MCP. + +**Pluggable storage.** SQLite (default), PostgreSQL, or etcd (etcd does not +support the admin transaction store). + +--- ## Installation / Setup -**lnd** - -* Make sure `lnd` ports are reachable. - -**aperture** - -* Compilation requires Go `1.25` or later. -* To build both `aperture` and `aperturecli`, run `make build`. To install - them into your `$GOPATH/bin`, run `make install`. -* Make sure port `8081` is reachable from outside (or whatever port you choose). -* Make sure there is a valid `tls.cert` and `tls.key` file located in the - `~/.aperture` directory that is valid for the domain that aperture is running on. - Aperture doesn't support creating its own certificate through Let's Encrypt yet. - If there is no `tls.cert` and `tls.key` found, a self-signed pair will be - created. -* If Aperture is behind a TLS-terminating load balancer/ingress, make sure the - load balancer's ALPN policy advertises `h2` (for example, AWS NLB - `HTTP2Preferred` or `HTTP2Only`). Some gRPC clients fail with - `missing selected ALPN property` if no ALPN protocol is negotiated. - On AWS NLB, the default ALPN policy is `None`, which does not negotiate ALPN. - If you use TCP passthrough instead of TLS termination at the load balancer, - Aperture negotiates ALPN directly. -* Make sure all required configuration items are set in `~/.aperture/aperture.yaml`, - compare with `sample-conf.yaml`. -* Start aperture without any command line parameters (`./aperture`), all configuration - is done in the `~/.aperture/aperture.yaml` file. +**Prerequisites** + +* Go `1.25` or later. +* A running Loka LND node (see + [loka-p2p-lnd](https://github.com/loka-network/loka-p2p-lnd)), reachable + over gRPC. TLS cert and admin macaroon paths go into `authenticator.tlspath` + and `authenticator.macdir`. +* Port `8081` (or your chosen `listenaddr`) reachable from clients. +* A valid `tls.cert` / `tls.key` pair in your `basedir` (auto-generated + self-signed if missing). + +**Build** + +```bash +make build # produces ./aperture and ./aperturecli +make install # installs to $GOPATH/bin +make build-withdashboard # includes embedded Next.js dashboard +``` + +**Run** + +```bash +# Using a custom config file: +aperture --configfile=/path/to/aperture.yaml + +# Or with all config in ~/.aperture/aperture.yaml (default): +aperture +``` + +Compare your config against [`sample-conf.yaml`](sample-conf.yaml) — every +option is documented inline. Paths in `configfile`, `basedir`, +`sqlite.dbfile`, `admin.macaroonpath`, `authenticator.tlspath`, and +`authenticator.macdir` all accept `~`, `$VAR`, absolute, and CWD-relative +forms. + +If Prism is behind a TLS-terminating load balancer / ingress, make sure its +ALPN policy advertises `h2` (on AWS NLB use `HTTP2Preferred` or +`HTTP2Only`) — gRPC clients will otherwise fail with +`missing selected ALPN property`. + +--- ## Admin API -Aperture ships with an optional gRPC and REST admin API for managing services -at runtime, querying transaction history, and monitoring revenue. Enable it by -adding an `admin` section to your config: +Prism ships with an optional gRPC + REST admin API for runtime service +management, transaction history, and revenue monitoring. Enable by adding: ```yaml admin: enabled: true - macaroonpath: "/path/to/admin.macaroon" # defaults to ~/.aperture/admin.macaroon + macaroonpath: "~/.aperture/admin.macaroon" # default ``` -On first startup Aperture generates a random root key and writes an admin -macaroon to the configured path. All admin endpoints (except health) require -this macaroon for authentication, passed as hex-encoded gRPC metadata or an -HTTP header. - -The admin API exposes ten RPCs covering the full lifecycle of the proxy: +On first start Prism generates a 32-byte root key and a macaroon at the +configured path. All admin endpoints except `GetHealth` require this macaroon +(hex-encoded in `Grpc-Metadata-Macaroon` header or gRPC metadata). | RPC | REST | Description | |-----|------|-------------| -| `GetHealth` | `GET /api/admin/health` | Health check (no auth required) | -| `GetInfo` | `GET /api/admin/info` | Server info: network, listen address, TLS status | -| `ListServices` | `GET /api/admin/services` | List all proxied backend services | -| `CreateService` | `POST /api/admin/services` | Register a new service with pricing and auth | -| `UpdateService` | `PUT /api/admin/services/{name}` | Update service config (pricing, address, auth) | +| `GetHealth` | `GET /api/admin/health` | Health check (no auth) | +| `GetInfo` | `GET /api/admin/info` | Server info: network, listen addr, TLS, MPP config | +| `ListServices` | `GET /api/admin/services` | List proxied backend services | +| `CreateService` | `POST /api/admin/services` | Register a new service | +| `UpdateService` | `PUT /api/admin/services/{name}` | Update service (partial) | | `DeleteService` | `DELETE /api/admin/services/{name}` | Remove a service | -| `ListTransactions` | `GET /api/admin/transactions` | Query L402 transactions with filters | -| `ListTokens` | `GET /api/admin/tokens` | List issued L402 tokens | +| `ListTransactions` | `GET /api/admin/transactions` | Query L402 transactions | +| `ListTokens` | `GET /api/admin/tokens` | List issued tokens | | `RevokeToken` | `DELETE /api/admin/tokens/{token_id}` | Revoke a token | -| `GetStats` | `GET /api/admin/stats` | Revenue statistics with per-service breakdown | +| `GetStats` | `GET /api/admin/stats` | Revenue stats, per-service breakdown | + +Services created through the admin API are persisted and survive restarts. +The proxy's routing table is updated in-place — pricing changes or backend +swaps take effect immediately with no downtime. -Services created through the admin API are persisted to the database and -survive restarts. Changes take effect immediately — the proxy's routing table -is updated in-place, so you can adjust pricing or swap backends without -downtime. +See [docs/admin-api.md](docs/admin-api.md) for full details. -See [docs/admin-api.md](docs/admin-api.md) for full configuration details. +--- ## Dashboard -When built with the `dashboard` build tag (`make build-withdashboard`), -Aperture embeds a Next.js web dashboard served at the root path. The dashboard -provides a visual interface for everything the admin API exposes: service -management, transaction history with filtering and pagination, revenue charts, -and token administration. +Built with `make build-withdashboard`, Prism embeds a Next.js web dashboard +served at the root path. It provides a visual interface for the admin API: +service management, transaction history with filtering / pagination, revenue +charts, and token administration. -The dashboard communicates with the admin API through a server-side proxy that -injects the macaroon automatically, so no client-side credentials are needed. -Access is restricted to loopback connections for security. +The dashboard talks to the admin API through a server-side proxy that +injects the macaroon automatically. Access is restricted to loopback for +security. -See [docs/dashboard.md](docs/dashboard.md) for setup and screenshots. +See [docs/dashboard.md](docs/dashboard.md). + +--- ## CLI (`aperturecli`) -`aperturecli` is a standalone command-line tool for the admin gRPC API. It -connects directly over gRPC (not REST) and authenticates with the same admin -macaroon. +A standalone command-line tool for the admin gRPC API. Designed to work +well for **both humans and AI agents** — tables when stdout is a TTY, JSON +when piped; semantic exit codes for scripting. ```bash -# Install -make install - -# Basic usage aperturecli --insecure health aperturecli --insecure services list aperturecli --insecure services create --name myapi --address 127.0.0.1:8080 --price 100 aperturecli --insecure services update --name myapi --price 500 aperturecli --insecure stats +aperturecli schema --all # dumps full command tree as JSON +aperturecli --dry-run services delete --name myapi ``` -The CLI is designed to work well for both humans and AI agents. When stdout is -a TTY it renders tables; when piped it emits JSON. Errors carry semantic exit -codes (connection failure, auth failure, not found, etc.) and structured JSON -on stderr, so scripts and agents can branch on the exit code without parsing -error text. - -A `schema` command dumps the full command tree as machine-readable JSON for -agent discovery: - -```bash -aperturecli schema --all -``` - -All mutating commands support `--dry-run`, which prints the request that would -be sent without actually calling the server. - -See [docs/cli.md](docs/cli.md) for the full command reference. +See [docs/cli.md](docs/cli.md). ### MCP Server -`aperturecli` also embeds an MCP (Model Context Protocol) server, started with -`aperturecli mcp serve`. This exposes every admin RPC as a typed tool over -stdio JSON-RPC, letting agent frameworks like Claude Code manage Aperture -directly. Add it to your MCP config: +`aperturecli` embeds an MCP (Model Context Protocol) server that exposes +every admin RPC as a typed tool over stdio JSON-RPC. Agent frameworks like +Claude Code can manage Prism directly: ```json { @@ -161,32 +229,14 @@ directly. Add it to your MCP config: } ``` -See [docs/mcp-server.md](docs/mcp-server.md) for setup details. +See [docs/mcp-server.md](docs/mcp-server.md). -## Rate Limiting +--- -Aperture supports optional per-endpoint rate limiting using a token bucket -algorithm. Rate limits are configured per service and applied based on the -client's L402 token ID for authenticated requests, or IP address for -unauthenticated requests. - -### Features - -* **Token bucket algorithm**: Allows controlled bursting while maintaining a - steady-state request rate. -* **Per-client isolation**: Each L402 token ID or IP address has independent - rate limit buckets. -* **Path-based rules**: Different endpoints can have different rate limits using - regular expressions. -* **Multiple rules**: All matching rules are evaluated; if any rule denies the - request, it is rejected. This allows layering global and endpoint-specific - limits. -* **Protocol-aware responses**: Returns HTTP 429 with `Retry-After` header for - REST requests, and gRPC `ResourceExhausted` status for gRPC requests. - -### Configuration +## Rate Limiting -Rate limits are configured in the `ratelimits` section of each service: +Per-endpoint token-bucket rate limiting, keyed on L402 token ID (or IP for +unauthenticated requests). ```yaml services: @@ -196,35 +246,35 @@ services: protocol: https ratelimits: - # Global rate limit for all endpoints - - requests: 100 # Requests allowed per time window - per: 1s # Time window duration (1s, 1m, 1h, etc.) - burst: 100 # Max burst capacity (defaults to 'requests') - - # Stricter limit for expensive endpoints - - pathregexp: '^/api/v1/expensive.*$' + - requests: 100 # global: 100/s per client + per: 1s + burst: 100 + - pathregexp: '^/api/v1/expensive.*$' # stricter per path requests: 5 per: 1m burst: 5 ``` -This example configures two rate limit rules using a token bucket algorithm. Each -client gets a "bucket" of tokens that refills at the `requests/per` rate, up to the -`burst` capacity. A request consumes one token; if no tokens are available, the -request is rejected. This allows clients to make quick bursts of requests (up to -`burst`) while enforcing a steady-state rate limit over time. - -1. **Global limit**: All endpoints are limited to 100 requests per second per client, - with a burst capacity of 100. -2. **Endpoint-specific limit**: Paths matching `/api/v1/expensive.*` have a stricter - limit of 5 requests per minute with a burst of 5. Since both rules are evaluated, - requests to expensive endpoints must satisfy both limits. - -### Configuration Options +Multiple rules layer — a request is rejected if **any** matching rule +denies it. Responses are protocol-aware: HTTP 429 with `Retry-After` for +REST, gRPC `ResourceExhausted` for gRPC. | Option | Description | Required | |--------|-------------|----------| -| `pathregexp` | Regular expression to match request paths. If omitted, matches all paths. | No | -| `requests` | Number of requests allowed per time window. | Yes | -| `per` | Time window duration (e.g., `1s`, `1m`, `1h`). | Yes | -| `burst` | Maximum burst size. Defaults to `requests` if not set. | No | +| `pathregexp` | Regex to match request paths. Matches all paths if omitted. | No | +| `requests` | Requests allowed per time window. | Yes | +| `per` | Time window (`1s`, `1m`, `1h`, …). | Yes | +| `burst` | Max burst capacity. Defaults to `requests`. | No | + +--- + +## Attribution + +Prism is a downstream fork of [Lightning Labs' Aperture][upstream], extended +for the Loka ecosystem. Upstream routing, L402 token logic, and protocol +implementations remain compatible; Loka additions focus on the admin API, +dashboard, CLI, MCP server, rate limiting, MPP session support, and +integration with the Sui-adapted Lightning backend. + +The L402 protocol itself is developed by Lightning Labs — see +[the L402 spec][l402]. From d976e6f7b440cabebb113c1a25aa7ee7d55fac90 Mon Sep 17 00:00:00 2001 From: ai-chen2050 <1033467071@qq.com> Date: Wed, 22 Apr 2026 16:27:31 +0800 Subject: [PATCH 03/32] refactor: rename aperture/aperturecli binaries to prism/prismcli MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename cmd/aperture → cmd/prism and cmd/aperturecli → cmd/prismcli; produces ./prism (daemon) and ./prismcli (admin CLI). - Rename skills/aperture → skills/prism with updated frontmatter. - Switch default data dir from ~/.aperture to ~/.prism; default config filename to prism.yaml, log to prism.log, sqlite db to prism.db. - Update CLI defaultMacaroon to ~/.prism/admin.macaroon and rewrite user-facing Use/Short/Long/version strings to prismcli. - MCP server advertises itself as "prismcli"; Claude Code config key changed from "aperture" to "prism". - Point Dockerfile git clone to github.com/loka-network/loka-prism-l402. - Update Makefile build/install/clean targets and .gitignore entries. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 4 +++ Dockerfile | 4 +-- Makefile | 26 +++++++------- README.md | 28 +++++++-------- cli/errors.go | 2 +- cli/mcp.go | 8 ++--- cli/root.go | 18 +++++----- cli/schema.go | 10 +++--- cli/services.go | 4 +-- cmd/{aperture => prism}/main.go | 0 cmd/{aperturecli => prismcli}/main.go | 2 +- config.go | 8 ++--- docs/cli.md | 44 +++++++++++------------ docs/dashboard.md | 8 ++--- docs/mcp-server.md | 16 ++++----- mcpserver/server.go | 6 ++-- sample-conf.yaml | 22 ++++++------ skills/{aperture => prism}/SKILL.md | 52 +++++++++++++-------------- 18 files changed, 133 insertions(+), 129 deletions(-) rename cmd/{aperture => prism}/main.go (100%) rename cmd/{aperturecli => prismcli}/main.go (91%) rename skills/{aperture => prism}/SKILL.md (59%) diff --git a/.gitignore b/.gitignore index 3a8f54c9..77f935b7 100644 --- a/.gitignore +++ b/.gitignore @@ -14,10 +14,14 @@ /aperture cmd/aperture/aperture +/prism +cmd/prism/prism + # misc .vscode .idea .aperture +.prism # dashboard build output (generated by npm run build / make build-dashboard) dashboard/out/ diff --git a/Dockerfile b/Dockerfile index 04f82740..0d7c351d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,8 +13,8 @@ ARG checkout="master" RUN apk add --no-cache --update alpine-sdk \ git \ make \ - && git clone https://github.com/lightninglabs/aperture /go/src/github.com/lightninglabs/aperture \ - && cd /go/src/github.com/lightninglabs/aperture \ + && git clone https://github.com/loka-network/loka-prism-l402 /go/src/github.com/loka-network/loka-prism-l402 \ + && cd /go/src/github.com/loka-network/loka-prism-l402 \ && git checkout $checkout \ && make install diff --git a/Makefile b/Makefile index bd43f424..4063fdfc 100644 --- a/Makefile +++ b/Makefile @@ -53,27 +53,27 @@ $(GOACC_BIN): # ============ build: - @$(call print, "Building aperture.") - $(GOBUILD) $(PKG)/cmd/aperture - $(GOBUILD) -ldflags "-X $(PKG)/cli.Version=$(shell git describe --tags --always --dirty 2>/dev/null || echo dev)" $(PKG)/cmd/aperturecli + @$(call print, "Building prism.") + $(GOBUILD) $(PKG)/cmd/prism + $(GOBUILD) -ldflags "-X $(PKG)/cli.Version=$(shell git describe --tags --always --dirty 2>/dev/null || echo dev)" $(PKG)/cmd/prismcli build-dashboard: @$(call print, "Building dashboard static export.") cd dashboard && npm ci && npm run build build-withdashboard: build-dashboard - @$(call print, "Building aperture with embedded dashboard.") - $(GOBUILD) -tags=dashboard $(PKG)/cmd/aperture + @$(call print, "Building prism with embedded dashboard.") + $(GOBUILD) -tags=dashboard $(PKG)/cmd/prism install: - @$(call print, "Installing aperture and aperturecli.") - $(GOINSTALL) -tags="${tags}" $(PKG)/cmd/aperture - $(GOINSTALL) -ldflags "-X $(PKG)/cli.Version=$(shell git describe --tags --always --dirty 2>/dev/null || echo dev)" $(PKG)/cmd/aperturecli + @$(call print, "Installing prism and prismcli.") + $(GOINSTALL) -tags="${tags}" $(PKG)/cmd/prism + $(GOINSTALL) -ldflags "-X $(PKG)/cli.Version=$(shell git describe --tags --always --dirty 2>/dev/null || echo dev)" $(PKG)/cmd/prismcli install-dashboard: build-dashboard - @$(call print, "Installing aperture with embedded dashboard.") - $(GOINSTALL) -tags="dashboard ${tags}" $(PKG)/cmd/aperture - $(GOINSTALL) -ldflags "-X $(PKG)/cli.Version=$(shell git describe --tags --always --dirty 2>/dev/null || echo dev)" $(PKG)/cmd/aperturecli + @$(call print, "Installing prism with embedded dashboard.") + $(GOINSTALL) -tags="dashboard ${tags}" $(PKG)/cmd/prism + $(GOINSTALL) -ldflags "-X $(PKG)/cli.Version=$(shell git describe --tags --always --dirty 2>/dev/null || echo dev)" $(PKG)/cmd/prismcli docker-tools: @$(call print, "Building tools docker image.") @@ -173,7 +173,7 @@ rpc-check: rpc clean: @$(call print, "Cleaning source.$(NC)") - $(RM) ./aperture - $(RM) ./aperturecli + $(RM) ./prism + $(RM) ./prismcli $(RM) coverage.txt $(RM) -r dashboard/out diff --git a/README.md b/README.md index 2846cca3..97e567ab 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,7 @@ support the admin transaction store). **Build** ```bash -make build # produces ./aperture and ./aperturecli +make build # produces ./prism (proxy daemon) and ./prismcli (admin CLI) make install # installs to $GOPATH/bin make build-withdashboard # includes embedded Next.js dashboard ``` @@ -124,10 +124,10 @@ make build-withdashboard # includes embedded Next.js dashboard ```bash # Using a custom config file: -aperture --configfile=/path/to/aperture.yaml +prism --configfile=/path/to/aperture.yaml # Or with all config in ~/.aperture/aperture.yaml (default): -aperture +prism ``` Compare your config against [`sample-conf.yaml`](sample-conf.yaml) — every @@ -194,35 +194,35 @@ See [docs/dashboard.md](docs/dashboard.md). --- -## CLI (`aperturecli`) +## CLI (`prismcli`) A standalone command-line tool for the admin gRPC API. Designed to work well for **both humans and AI agents** — tables when stdout is a TTY, JSON when piped; semantic exit codes for scripting. ```bash -aperturecli --insecure health -aperturecli --insecure services list -aperturecli --insecure services create --name myapi --address 127.0.0.1:8080 --price 100 -aperturecli --insecure services update --name myapi --price 500 -aperturecli --insecure stats -aperturecli schema --all # dumps full command tree as JSON -aperturecli --dry-run services delete --name myapi +prismcli --insecure health +prismcli --insecure services list +prismcli --insecure services create --name myapi --address 127.0.0.1:8080 --price 100 +prismcli --insecure services update --name myapi --price 500 +prismcli --insecure stats +prismcli schema --all # dumps full command tree as JSON +prismcli --dry-run services delete --name myapi ``` See [docs/cli.md](docs/cli.md). ### MCP Server -`aperturecli` embeds an MCP (Model Context Protocol) server that exposes +`prismcli` embeds an MCP (Model Context Protocol) server that exposes every admin RPC as a typed tool over stdio JSON-RPC. Agent frameworks like Claude Code can manage Prism directly: ```json { "mcpServers": { - "aperture": { - "command": "aperturecli", + "prism": { + "command": "prismcli", "args": ["--insecure", "mcp", "serve"] } } diff --git a/cli/errors.go b/cli/errors.go index 8cc6f250..c95442ec 100644 --- a/cli/errors.go +++ b/cli/errors.go @@ -7,7 +7,7 @@ import ( "io" ) -// Exit codes for aperturecli. These are semantic exit codes that agents +// Exit codes for prismcli. These are semantic exit codes that agents // can parse for control flow, going beyond the typical 0/1 binary. const ( // ExitSuccess indicates the command completed successfully. diff --git a/cli/mcp.go b/cli/mcp.go index 2bf41b34..71bf92e1 100644 --- a/cli/mcp.go +++ b/cli/mcp.go @@ -15,7 +15,7 @@ func NewMCPCmd() *cobra.Command { cmd := &cobra.Command{ Use: "mcp", Short: "Model Context Protocol server", - Long: "Expose aperturecli operations as MCP tools over stdio JSON-RPC.", + Long: "Expose prismcli operations as MCP tools over stdio JSON-RPC.", } cmd.AddCommand(newMCPServeCmd()) @@ -30,9 +30,9 @@ func newMCPServeCmd() *cobra.Command { Use: "serve", Short: "Start the MCP server on stdio", Long: `Start an MCP (Model Context Protocol) server that exposes -Aperture admin operations as typed tools over stdio JSON-RPC. -This enables direct integration with agent frameworks like -Claude Code. +Loka Prism L402 admin operations as typed tools over stdio +JSON-RPC. This enables direct integration with agent frameworks +like Claude Code. Available tools: get_info, get_health, list_services, create_service, update_service, delete_service, diff --git a/cli/root.go b/cli/root.go index 974cfcbf..5b1e6fc8 100644 --- a/cli/root.go +++ b/cli/root.go @@ -1,4 +1,4 @@ -// Package cli provides the command-line interface for aperturecli. +// Package cli provides the command-line interface for prismcli. package cli import ( @@ -10,7 +10,7 @@ import ( const ( defaultHost = "localhost:8081" - defaultMacaroon = "~/.aperture/admin.macaroon" + defaultMacaroon = "~/.prism/admin.macaroon" ) // flags holds the CLI flags. @@ -41,14 +41,14 @@ var flags struct { timeout time.Duration } -// NewRootCmd creates the root aperturecli command with all subcommands +// NewRootCmd creates the root prismcli command with all subcommands // registered. func NewRootCmd() *cobra.Command { rootCmd := &cobra.Command{ - Use: "aperturecli", - Short: "CLI for the Aperture admin API", - Long: `aperturecli is a command-line interface and MCP server for -managing an Aperture L402 reverse proxy. It connects to the + Use: "prismcli", + Short: "CLI for the Loka Prism L402 admin API", + Long: `prismcli is a command-line interface and MCP server for +managing a Loka Prism L402 reverse proxy. It connects to the admin gRPC API to manage services, view transactions, and control the system. @@ -127,9 +127,9 @@ var Version = "dev" func newVersionCmd() *cobra.Command { return &cobra.Command{ Use: "version", - Short: "Print aperturecli version", + Short: "Print prismcli version", Run: func(cmd *cobra.Command, args []string) { - fmt.Printf("aperturecli version %s\n", Version) + fmt.Printf("prismcli version %s\n", Version) }, } } diff --git a/cli/schema.go b/cli/schema.go index 9b3bc0a6..72989da7 100644 --- a/cli/schema.go +++ b/cli/schema.go @@ -9,13 +9,13 @@ import ( ) // CommandSchema is the machine-readable schema for a CLI command, -// designed for agent introspection via aperturecli schema. +// designed for agent introspection via prismcli schema. type CommandSchema struct { // Name is the command name (e.g. "services"). Name string `json:"name"` // FullPath is the fully qualified command path - // (e.g. "aperturecli services list"). + // (e.g. "prismcli services list"). FullPath string `json:"full_path"` // Description is the short description of the command. @@ -90,9 +90,9 @@ func NewSchemaCmd() *cobra.Command { Short: "Show machine-readable CLI schema", Long: `Dump the CLI schema as JSON for agent introspection. - aperturecli schema List all commands - aperturecli schema services Show schema for the services command - aperturecli schema --all Full schema tree`, + prismcli schema List all commands + prismcli schema services Show schema for the services command + prismcli schema --all Full schema tree`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { root := cmd.Root() diff --git a/cli/services.go b/cli/services.go index 3a25206b..4984f5fa 100644 --- a/cli/services.go +++ b/cli/services.go @@ -191,10 +191,10 @@ that are explicitly provided will be changed. Examples: # Change price only: - aperturecli services update --name myapi --price 500 + prismcli services update --name myapi --price 500 # Change address and protocol: - aperturecli services update --name myapi --address 10.0.0.5:8080 --protocol https`, + prismcli services update --name myapi --address 10.0.0.5:8080 --protocol https`, RunE: func(cmd *cobra.Command, args []string) error { if name == "" { return ErrInvalidArgsf("--name is required") diff --git a/cmd/aperture/main.go b/cmd/prism/main.go similarity index 100% rename from cmd/aperture/main.go rename to cmd/prism/main.go diff --git a/cmd/aperturecli/main.go b/cmd/prismcli/main.go similarity index 91% rename from cmd/aperturecli/main.go rename to cmd/prismcli/main.go index da1944f2..06959f63 100644 --- a/cmd/aperturecli/main.go +++ b/cmd/prismcli/main.go @@ -1,4 +1,4 @@ -// Package main is the entry point for the aperturecli CLI. +// Package main is the entry point for the prismcli CLI. package main import ( diff --git a/config.go b/config.go index 5b4bf234..0d841ba0 100644 --- a/config.go +++ b/config.go @@ -13,16 +13,16 @@ import ( ) var ( - apertureDataDir = btcutil.AppDataDir("aperture", false) - defaultConfigFilename = "aperture.yaml" + apertureDataDir = btcutil.AppDataDir("prism", false) + defaultConfigFilename = "prism.yaml" defaultTLSKeyFilename = "tls.key" defaultTLSCertFilename = "tls.cert" defaultLogLevel = "info" - defaultLogFilename = "aperture.log" + defaultLogFilename = "prism.log" defaultInvoiceBatchSize = 100000 defaultStrictVerify = false - defaultSqliteDatabaseFileName = "aperture.db" + defaultSqliteDatabaseFileName = "prism.db" // defaultSqliteDatabasePath is the default path under which we store // the SQLite database file. diff --git a/docs/cli.md b/docs/cli.md index 6751c483..4d9e9155 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,9 +1,9 @@ -# aperturecli +# prismcli -`aperturecli` is a standalone command-line interface for the Aperture admin -gRPC API. It provides full CRUD management of backend services, transaction -queries, token management, revenue statistics, and an embedded MCP server for -AI agent integration. +`prismcli` is a standalone command-line interface for the Loka Prism L402 +admin gRPC API. It provides full CRUD management of backend services, +transaction queries, token management, revenue statistics, and an embedded +MCP server for AI agent integration. ## Installation @@ -12,20 +12,20 @@ AI agent integration. make install # Or directly: -go install github.com/lightninglabs/aperture/cmd/aperturecli@latest +go install github.com/lightninglabs/aperture/cmd/prismcli@latest ``` ## Quick Start ```bash # Check server health (insecure mode for local dev): -aperturecli --insecure health +prismcli --insecure health # List all services: -aperturecli --insecure services list +prismcli --insecure services list # Create a new service with pricing: -aperturecli --insecure services create \ +prismcli --insecure services create \ --name myapi \ --address 127.0.0.1:8080 \ --protocol http \ @@ -33,17 +33,17 @@ aperturecli --insecure services create \ --auth on # Dynamically change pricing: -aperturecli --insecure services update --name myapi --price 500 +prismcli --insecure services update --name myapi --price 500 # Preview a change without executing: -aperturecli --insecure --dry-run services delete --name myapi +prismcli --insecure --dry-run services delete --name myapi ``` ## Global Flags | Flag | Default | Description | |------|---------|-------------| -| `--host` | `localhost:8081` | Aperture admin gRPC host:port | +| `--host` | `localhost:8081` | Prism admin gRPC host:port | | `--macaroon` | `~/.aperture/admin.macaroon` | Path to admin macaroon file | | `--tls-cert` | | Path to TLS certificate for server verification | | `--insecure` | `false` | Skip TLS (plaintext gRPC) | @@ -54,7 +54,7 @@ aperturecli --insecure --dry-run services delete --name myapi ## Output Modes -`aperturecli` is agent-friendly by default: +`prismcli` is agent-friendly by default: - **When stdout is a TTY** (interactive terminal): human-readable tables. - **When stdout is piped** (agent/script mode): JSON output. @@ -99,10 +99,10 @@ explicitly provided are changed, enabling targeted updates: ```bash # Change only the price: -aperturecli services update --name myapi --price 500 +prismcli services update --name myapi --price 500 # Change address and protocol: -aperturecli services update --name myapi --address 10.0.0.5:8080 --protocol https +prismcli services update --name myapi --address 10.0.0.5:8080 --protocol https ``` ### `services delete` @@ -112,7 +112,7 @@ Delete a backend service by name. Query L402 transactions with optional filters: ```bash -aperturecli transactions list \ +prismcli transactions list \ --service myapi \ --state settled \ --from 2026-01-01T00:00:00Z \ @@ -124,13 +124,13 @@ aperturecli transactions list \ List issued L402 tokens with pagination (`--limit`, `--offset`). ### `tokens revoke` -Revoke a token by ID: `aperturecli tokens revoke --token-id `. +Revoke a token by ID: `prismcli tokens revoke --token-id `. ### `stats` Revenue statistics with optional date range and per-service breakdown: ```bash -aperturecli stats --from 2026-01-01T00:00:00Z --to 2026-03-25T00:00:00Z +prismcli stats --from 2026-01-01T00:00:00Z --to 2026-03-25T00:00:00Z ``` ### `schema` @@ -138,13 +138,13 @@ Machine-readable CLI introspection for agent discovery: ```bash # List top-level commands: -aperturecli schema +prismcli schema # Full schema tree: -aperturecli schema --all +prismcli schema --all # Schema for a specific command: -aperturecli schema services +prismcli schema services ``` ### `version` @@ -156,7 +156,7 @@ All mutating commands support `--dry-run`, which serializes the gRPC request as JSON without calling the server: ```bash -$ aperturecli --dry-run services create --name test --address 127.0.0.1:8080 --price 100 +$ prismcli --dry-run services create --name test --address 127.0.0.1:8080 --price 100 { "dry_run": true, "rpc": "CreateService", diff --git a/docs/dashboard.md b/docs/dashboard.md index ec1327a3..50261a93 100644 --- a/docs/dashboard.md +++ b/docs/dashboard.md @@ -1,6 +1,6 @@ # Dashboard -Aperture includes an embedded web dashboard for monitoring L402 payment +Prism includes an embedded web dashboard for monitoring L402 payment activity, managing proxy services, and viewing transaction history. The dashboard is a Next.js static export compiled into the Go binary at build time. @@ -15,7 +15,7 @@ make build-withdashboard # Or step by step: cd dashboard && npm ci && npm run build # produces dashboard/out/ -go build -tags=dashboard ./cmd/aperture # embeds dashboard/out/ via go:embed +go build -tags=dashboard ./cmd/prism # embeds dashboard/out/ via go:embed ``` The default `make build` produces a binary **without** the dashboard. This @@ -24,7 +24,7 @@ means the standard build works without Node.js or npm installed. ## Accessing When the admin API is enabled (`admin.enabled: true`), the dashboard is served -at the root path of the aperture listen address: +at the root path of the prism listen address: ``` http://localhost:8081/ @@ -160,7 +160,7 @@ npm ci npm run dev # Start Next.js dev server on :3000 ``` -The dev server expects the aperture admin API to be running at +The dev server expects the prism admin API to be running at `localhost:8081`. Edit `dashboard/next.config.ts` to adjust the proxy target if needed. diff --git a/docs/mcp-server.md b/docs/mcp-server.md index c3890b5c..f48ac31f 100644 --- a/docs/mcp-server.md +++ b/docs/mcp-server.md @@ -1,13 +1,13 @@ # MCP Server -`aperturecli` includes an embedded MCP (Model Context Protocol) server that +`prismcli` includes an embedded MCP (Model Context Protocol) server that exposes all admin API operations as typed tools over stdio JSON-RPC. This enables direct integration with AI agent frameworks like Claude Code. ## Starting the Server ```bash -aperturecli --insecure mcp serve +prismcli --insecure mcp serve ``` The server uses the same connection flags as the CLI (`--host`, `--macaroon`, @@ -22,8 +22,8 @@ project-level): ```json { "mcpServers": { - "aperture": { - "command": "aperturecli", + "prism": { + "command": "prismcli", "args": ["--insecure", "mcp", "serve"] } } @@ -35,10 +35,10 @@ For production with TLS: ```json { "mcpServers": { - "aperture": { - "command": "aperturecli", + "prism": { + "command": "prismcli", "args": [ - "--host", "aperture.example.com:8081", + "--host", "prism.example.com:8081", "--macaroon", "/path/to/admin.macaroon", "--tls-cert", "/path/to/tls.cert", "mcp", "serve" @@ -84,7 +84,7 @@ like dynamic pricing changes. ## Example Agent Interaction -An agent can manage Aperture services programmatically: +An agent can manage Prism services programmatically: ``` Agent: "List all services" diff --git a/mcpserver/server.go b/mcpserver/server.go index 49b8dd0b..f5a9e63f 100644 --- a/mcpserver/server.go +++ b/mcpserver/server.go @@ -1,5 +1,5 @@ // Package mcpserver provides the Model Context Protocol server for -// aperturecli. It exposes the admin API operations as typed MCP tools +// prismcli. It exposes the admin API operations as typed MCP tools // over stdio JSON-RPC, enabling direct integration with agent // frameworks. package mcpserver @@ -11,7 +11,7 @@ import ( gomcp "github.com/modelcontextprotocol/go-sdk/mcp" ) -// NewServer creates a new MCP server with all aperturecli tools +// NewServer creates a new MCP server with all prismcli tools // registered. The version parameter is injected from the CLI's build // version to avoid import cycles. func NewServer(client adminrpc.AdminClient, @@ -19,7 +19,7 @@ func NewServer(client adminrpc.AdminClient, server := gomcp.NewServer( &gomcp.Implementation{ - Name: "aperturecli", + Name: "prismcli", Version: version, }, nil, diff --git a/sample-conf.yaml b/sample-conf.yaml index 686b79c3..bd82d69f 100644 --- a/sample-conf.yaml +++ b/sample-conf.yaml @@ -15,24 +15,24 @@ servestatic: false debuglevel: "debug" # Custom path to a config file. -configfile: "/path/to/your/aperture.yaml" +configfile: "/path/to/your/prism.yaml" -# Directory to place all of aperture's files in. -basedir: "/path/to/.aperture" +# Directory to place all of prism's files in. +basedir: "/path/to/.prism" # Whether the proxy should create a valid certificate through Let's Encrypt for # the fully qualifying domain name. autocert: false -servername: aperture.example.com +servername: prism.example.com # Whether to listen on an insecure connection, disabling TLS for incoming # connections. insecure: false -# If TLS is terminated by a load balancer/ingress in front of aperture, make +# If TLS is terminated by a load balancer/ingress in front of prism, make # sure the load balancer's ALPN policy includes "h2". On AWS NLB, the default # ALPN policy "None" does not negotiate ALPN and gRPC clients may fail with -# "missing selected ALPN property". With TCP passthrough, aperture negotiates +# "missing selected ALPN property". With TCP passthrough, prism negotiates # ALPN directly. # Whether we should verify the invoice status strictly or not. If set to true, @@ -128,7 +128,7 @@ authenticator: # transactions, and tokens at runtime. Disabled by default. # admin: # enabled: true -# macaroonpath: "/path/to/aperture/data/admin.macaroon" +# macaroonpath: "/path/to/prism/data/admin.macaroon" # corsorigin: # - "https://dashboard.example.com" @@ -137,15 +137,15 @@ blocklist: - "1.1.1.1" - "1.0.0.1" -# The selected database backend. The current default backend is "sqlite". -# Aperture also has support for postgres and etcd. +# The selected database backend. The current default backend is "sqlite". +# Prism also has support for postgres and etcd. dbbackend: "sqlite" # Settings for the sqlite process which the proxy will use to reliably store and # retrieve token information. sqlite: # The full path to the database. - dbfile: "/path/to/.aperture/aperture.db" + dbfile: "/path/to/.prism/prism.db" # Skip applying migrations on startup. skipmigrations: false @@ -230,7 +230,7 @@ services: # The set of constraints that are applied to tokens of the service at the # base tier. constraints: - # This is just an example of how aperture could be extended + # This is just an example of how prism could be extended # but would not have any effect without additional support added. "valid_until": 1682483169 diff --git a/skills/aperture/SKILL.md b/skills/prism/SKILL.md similarity index 59% rename from skills/aperture/SKILL.md rename to skills/prism/SKILL.md index 40ea22e6..bd0893fa 100644 --- a/skills/aperture/SKILL.md +++ b/skills/prism/SKILL.md @@ -1,29 +1,29 @@ --- -name: aperture -description: Manage Aperture L402 reverse proxy via aperturecli. Use when creating/updating/deleting services, checking transactions, managing tokens, viewing stats, or changing pricing on an Aperture instance. +name: prism +description: Manage Loka Prism L402 reverse proxy via prismcli. Use when creating/updating/deleting services, checking transactions, managing tokens, viewing stats, or changing pricing on a Prism instance. --- -# Aperture CLI Skill +# Prism CLI Skill -Manage an Aperture L402 reverse proxy using `aperturecli`, the admin CLI and +Manage a Loka Prism L402 reverse proxy using `prismcli`, the admin CLI and MCP server. ## Quick Reference | Action | Command | |--------|---------| -| Server info | `aperturecli --insecure info` | -| Health check | `aperturecli --insecure health` | -| List services | `aperturecli --insecure services list` | -| Create service | `aperturecli --insecure services create --name --address --price ` | -| Update price | `aperturecli --insecure services update --name --price ` | -| Delete service | `aperturecli --insecure services delete --name ` | -| List transactions | `aperturecli --insecure transactions list` | -| List tokens | `aperturecli --insecure tokens list` | -| Revoke token | `aperturecli --insecure tokens revoke --token-id ` | -| Revenue stats | `aperturecli --insecure stats` | -| CLI schema | `aperturecli schema --all` | -| Start MCP server | `aperturecli --insecure mcp serve` | +| Server info | `prismcli --insecure info` | +| Health check | `prismcli --insecure health` | +| List services | `prismcli --insecure services list` | +| Create service | `prismcli --insecure services create --name --address --price ` | +| Update price | `prismcli --insecure services update --name --price ` | +| Delete service | `prismcli --insecure services delete --name ` | +| List transactions | `prismcli --insecure transactions list` | +| List tokens | `prismcli --insecure tokens list` | +| Revoke token | `prismcli --insecure tokens revoke --token-id ` | +| Revenue stats | `prismcli --insecure stats` | +| CLI schema | `prismcli schema --all` | +| Start MCP server | `prismcli --insecure mcp serve` | ## Connection Flags @@ -42,7 +42,7 @@ All commands accept these connection flags: To change a service's price without affecting other fields: ```bash -aperturecli --insecure services update --name myapi --price 500 +prismcli --insecure services update --name myapi --price 500 ``` Only flags that are explicitly passed are updated. This enables targeted @@ -53,7 +53,7 @@ changes to pricing, address, protocol, auth, or routing patterns. Preview mutating operations without executing them: ```bash -aperturecli --insecure --dry-run services create \ +prismcli --insecure --dry-run services create \ --name test --address 127.0.0.1:8080 --price 100 ``` @@ -82,7 +82,7 @@ Outputs the request JSON and exits with code 10 (no mutation). Start the MCP server for agent framework integration: ```bash -aperturecli --insecure mcp serve +prismcli --insecure mcp serve ``` This exposes all admin RPCs as typed MCP tools over stdio JSON-RPC: @@ -95,8 +95,8 @@ This exposes all admin RPCs as typed MCP tools over stdio JSON-RPC: ```json { "mcpServers": { - "aperture": { - "command": "aperturecli", + "prism": { + "command": "prismcli", "args": ["--insecure", "mcp", "serve"] } } @@ -107,7 +107,7 @@ This exposes all admin RPCs as typed MCP tools over stdio JSON-RPC: ```bash # Create a service gating an API behind Lightning payments: -aperturecli --insecure services create \ +prismcli --insecure services create \ --name weather-api \ --address 10.0.0.5:8080 \ --protocol http \ @@ -117,23 +117,23 @@ aperturecli --insecure services create \ --auth on # Verify it was created: -aperturecli --insecure services list +prismcli --insecure services list # Check revenue: -aperturecli --insecure stats --from 2026-01-01T00:00:00Z +prismcli --insecure stats --from 2026-01-01T00:00:00Z ``` ## Filtering Transactions ```bash # Filter by service and state: -aperturecli --insecure transactions list \ +prismcli --insecure transactions list \ --service weather-api \ --state settled \ --limit 100 # Filter by date range: -aperturecli --insecure transactions list \ +prismcli --insecure transactions list \ --from 2026-03-01T00:00:00Z \ --to 2026-03-31T23:59:59Z ``` From 04d8ac0bfce0abacc5cd5cbd61ec1b362f56173a Mon Sep 17 00:00:00 2001 From: ai-chen2050 <1033467071@qq.com> Date: Wed, 22 Apr 2026 17:13:36 +0800 Subject: [PATCH 04/32] test: add prism + sui-lnd integration & manual payment scripts - scripts/itest_prism_sui.sh: automated end-to-end smoke test against the sui-adapted lnd (alice). Stands up an isolated prism on :18080 and verifies admin REST/gRPC, L402 challenge generation, and invoice issuance via AddInvoice. No payment required. - scripts/manual_pay_through_prism.sh: end-to-end payment walk using the running prism on :8080 (user's sample-conf-tmp.yaml) and bob (second lnd from itest_sui_single_coin.sh). Parses the 402 challenge, pays the invoice via bob over the alice<->bob channel, replays the request with the LSAT token, then queries prism's admin API to show the settled transaction, issued token, and revenue stats. Useful for manually observing payment data in the admin API or dashboard. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/itest_prism_sui.sh | 312 ++++++++++++++++++++++++++++ scripts/manual_pay_through_prism.sh | 249 ++++++++++++++++++++++ 2 files changed, 561 insertions(+) create mode 100755 scripts/itest_prism_sui.sh create mode 100755 scripts/manual_pay_through_prism.sh diff --git a/scripts/itest_prism_sui.sh b/scripts/itest_prism_sui.sh new file mode 100755 index 00000000..5b0a606f --- /dev/null +++ b/scripts/itest_prism_sui.sh @@ -0,0 +1,312 @@ +#!/bin/bash +# itest_prism_sui.sh +# End-to-end integration test for Loka Prism L402 running against +# Sui-adapted LND. Verifies that: +# 1. Prism can reach LND via gRPC with the Sui macaroon +# 2. Admin API endpoints respond correctly +# 3. L402 challenge flow issues a valid Lightning invoice (AddInvoice +# round-trip over the aperture→lnd wire is intact on Sui-LND) +# +# Assumes the Sui-LND node "alice" is already running (started via +# /Users/blake/work/nagara/code/chain/loka-payment/lnd/scripts/itest_sui_single_coin.sh). +# +# Usage: +# ./scripts/itest_prism_sui.sh # runs against Alice, devnet macaroons +# NETWORK=localnet ./scripts/itest_prism_sui.sh +# ALICE_DIR=/custom/path ./scripts/itest_prism_sui.sh + +set -euo pipefail + +# --- Configuration --------------------------------------------------------- + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +NETWORK="${NETWORK:-devnet}" +ALICE_DIR="${ALICE_DIR:-/tmp/lnd-sui-test/alice}" +LND_RPC_HOST="${LND_RPC_HOST:-127.0.0.1:10009}" + +PRISM_BIN="${PRISM_BIN:-$REPO_DIR/prism}" +PRISMCLI_BIN="${PRISMCLI_BIN:-$REPO_DIR/prismcli}" + +PRISM_DATA="${PRISM_DATA:-/tmp/prism-itest}" +PRISM_LISTEN="${PRISM_LISTEN:-127.0.0.1:18080}" +PRISM_LOG="$PRISM_DATA/prism.log" +PRISM_CONFIG="$PRISM_DATA/prism.yaml" + +ADMIN_MAC="$PRISM_DATA/admin.macaroon" + +# Colors (fall back gracefully if stdout isn't a tty) +if [ -t 1 ]; then + G=$'\033[32m'; R=$'\033[31m'; Y=$'\033[33m'; N=$'\033[0m' +else + G=""; R=""; Y=""; N="" +fi + +pass() { echo " ${G}✓${N} $*"; } +fail() { echo " ${R}✗${N} $*"; exit 1; } +info() { echo "${Y}➤${N} $*"; } + +# --- 1. Preflight --------------------------------------------------------- + +info "[1/7] Preflight checks" + +[ -f "$ALICE_DIR/tls.cert" ] \ + || fail "LND TLS cert not found at $ALICE_DIR/tls.cert — is alice running?" + +MACAROON_DIR="$ALICE_DIR/data/chain/sui/$NETWORK" +[ -d "$MACAROON_DIR" ] \ + || fail "LND macaroon dir not found at $MACAROON_DIR" +[ -f "$MACAROON_DIR/admin.macaroon" ] \ + || fail "LND admin macaroon missing at $MACAROON_DIR/admin.macaroon" + +nc -z "${LND_RPC_HOST%:*}" "${LND_RPC_HOST##*:}" \ + || fail "LND gRPC not reachable at $LND_RPC_HOST" + +if nc -z "${PRISM_LISTEN%:*}" "${PRISM_LISTEN##*:}" 2>/dev/null; then + fail "port $PRISM_LISTEN already in use — set PRISM_LISTEN=127.0.0.1: and retry" +fi + +pass "LND reachable, prism listen port free" + +# --- 2. Build binaries --------------------------------------------------- + +info "[2/7] Building prism + prismcli" + +if [ ! -x "$PRISM_BIN" ] || [ ! -x "$PRISMCLI_BIN" ]; then + (cd "$REPO_DIR" && make build) >/dev/null +fi +[ -x "$PRISM_BIN" ] || fail "prism binary not found at $PRISM_BIN" +[ -x "$PRISMCLI_BIN" ] || fail "prismcli binary not found at $PRISMCLI_BIN" +pass "binaries ready" + +# --- 3. Write prism config ----------------------------------------------- + +info "[3/7] Preparing prism data dir + config" + +rm -rf "$PRISM_DATA" +mkdir -p "$PRISM_DATA" + +cat > "$PRISM_CONFIG" <"$PRISM_LOG" 2>&1 & +PRISM_PID=$! + +cleanup() { + if kill -0 "$PRISM_PID" 2>/dev/null; then + kill "$PRISM_PID" 2>/dev/null || true + wait "$PRISM_PID" 2>/dev/null || true + fi +} +trap cleanup EXIT + +# Wait up to 15s for the admin API to be actually serving 200 (not just +# accepting TCP). The REST gateway needs a moment after the TLS listener +# is up before it can round-trip to the internal gRPC server. +ready=0 +for i in $(seq 1 30); do + if ! kill -0 "$PRISM_PID" 2>/dev/null; then + echo "--- prism log ---"; cat "$PRISM_LOG"; echo "------------------" + fail "prism died during startup" + fi + code=$(curl -sk --max-time 1 \ + -o /dev/null -w '%{http_code}' \ + "https://$PRISM_LISTEN/api/admin/health" || echo "000") + if [ "$code" = "200" ]; then + ready=1 + break + fi + sleep 0.5 +done +[ "$ready" = "1" ] || { + echo "--- prism log ---"; tail -20 "$PRISM_LOG"; echo "-------------" + fail "admin API never reached 200 after 15s (last code: $code)" +} +pass "prism up (pid=$PRISM_PID)" + +# --- 5. Admin API smoke --------------------------------------------------- + +info "[5/7] Admin API smoke" + +ADMIN_MAC_HEX=$(xxd -ps -c 10000 "$ADMIN_MAC") + +curl_admin() { + curl -sk \ + -H "Grpc-Metadata-Macaroon: $ADMIN_MAC_HEX" \ + -w '\n%{http_code}' \ + "$@" +} + +# 5a. health (no auth) +body_and_code=$(curl -sk -w '\n%{http_code}' "https://$PRISM_LISTEN/api/admin/health") +code=${body_and_code##*$'\n'} +body=${body_and_code%$'\n'*} +[ "$code" = "200" ] || fail "health expected 200, got $code (body: $body)" +echo "$body" | grep -q '"status"' || fail "health response missing status field: $body" +pass "health → 200 $body" + +# 5b. info (auth) +body_and_code=$(curl_admin "https://$PRISM_LISTEN/api/admin/info") +code=${body_and_code##*$'\n'} +body=${body_and_code%$'\n'*} +[ "$code" = "200" ] || fail "info expected 200, got $code (body: $body)" +echo "$body" | grep -q '"network"' || fail "info missing network field: $body" +echo "$body" | grep -q "\"$NETWORK\"" \ + || fail "info network mismatch, expected $NETWORK: $body" +pass "info → 200, network=$NETWORK" + +# 5c. services list (auth) — config-defined service should appear +body_and_code=$(curl_admin "https://$PRISM_LISTEN/api/admin/services") +code=${body_and_code##*$'\n'} +body=${body_and_code%$'\n'*} +[ "$code" = "200" ] || fail "services list expected 200, got $code" +echo "$body" | grep -q '"itest-service"' \ + || fail "services list missing itest-service: $body" +pass "services list contains itest-service" + +# 5d. Round-trip via prismcli (exercises gRPC admin path, not just REST) +if "$PRISMCLI_BIN" --host="$PRISM_LISTEN" \ + --macaroon="$ADMIN_MAC" --insecure=false \ + --tls-cert="$PRISM_DATA/tls.cert" \ + --json services list >/dev/null 2>&1; then + pass "prismcli gRPC → services list OK" +else + # Fall back to insecure (self-signed TLS) with explicit cert + if "$PRISMCLI_BIN" --host="$PRISM_LISTEN" \ + --macaroon="$ADMIN_MAC" --insecure \ + --json services list >/dev/null 2>&1; then + pass "prismcli gRPC (insecure) → services list OK" + else + fail "prismcli cannot reach admin gRPC" + fi +fi + +# --- 6. L402 challenge flow ---------------------------------------------- + +info "[6/7] L402 challenge (exercises LND AddInvoice over Sui)" + +# Hit the protected service with the matching Host header. Expect 402 + +# WWW-Authenticate header containing a BOLT11 invoice. +resp_headers=$(mktemp) +resp_body=$(mktemp) +trap 'rm -f "$resp_headers" "$resp_body"; cleanup' EXIT + +code=$(curl -sk -o "$resp_body" -D "$resp_headers" -w '%{http_code}' \ + -H "Host: itest.local" \ + "https://$PRISM_LISTEN/test") + +[ "$code" = "402" ] \ + || fail "expected 402 Payment Required on /test, got $code (body: $(cat "$resp_body"))" +pass "proxy returned 402 Payment Required" + +# Extract invoice from WWW-Authenticate: L402 macaroon="...", invoice="lnbc..." +www_auth=$(grep -i '^www-authenticate:' "$resp_headers" | head -1 || true) +[ -n "$www_auth" ] \ + || fail "missing WWW-Authenticate header (headers: $(cat "$resp_headers"))" +echo " $www_auth" + +invoice=$(echo "$www_auth" \ + | sed -n 's/.*invoice="\([^"]*\)".*/\1/p') +[ -n "$invoice" ] || fail "could not extract invoice from: $www_auth" + +# Basic BOLT11 sanity: starts with "ln" + network-prefix, contains payment hash. +case "$invoice" in + ln*) pass "invoice issued by LND: ${invoice:0:30}..." ;; + *) fail "invoice does not look like BOLT11: $invoice" ;; +esac + +macaroon=$(echo "$www_auth" \ + | sed -n 's/.*macaroon="\([^"]*\)".*/\1/p') +[ -n "$macaroon" ] || fail "could not extract macaroon from: $www_auth" +pass "L402 macaroon issued (${#macaroon} chars)" + +# --- 7. Transaction appears in admin API --------------------------------- + +info "[7/7] Verify transaction logged" + +# Give prism a moment to commit the transaction row. +sleep 1 +body_and_code=$(curl_admin "https://$PRISM_LISTEN/api/admin/transactions?limit=10") +code=${body_and_code##*$'\n'} +body=${body_and_code%$'\n'*} +[ "$code" = "200" ] \ + || fail "transactions expected 200, got $code (body: $body)" + +if echo "$body" | grep -q '"service":"itest-service"'; then + pass "transaction row recorded for itest-service" +else + # Soft-pass: some prism versions only record on settlement. + echo " ${Y}~${N} no unsettled transaction row yet (this may be expected)" +fi + +# --- done ----------------------------------------------------------------- + +echo "" +echo "${G}=== All checks passed ===${N}" +echo "Prism log: $PRISM_LOG" +echo "Prism data: $PRISM_DATA" +echo "" +echo "Next manual steps:" +echo " - Pay the invoice from a second LND node to exercise the settlement" +echo " path (SubscribeInvoices stream). See lnd/scripts/itest_sui_single_coin.sh" +echo " for an example of opening a channel + paying." +echo " - Build prism with the dashboard embedded to get a UI at /:" +echo " make build-withdashboard" diff --git a/scripts/manual_pay_through_prism.sh b/scripts/manual_pay_through_prism.sh new file mode 100755 index 00000000..706dd1a0 --- /dev/null +++ b/scripts/manual_pay_through_prism.sh @@ -0,0 +1,249 @@ +#!/bin/bash +# manual_pay_through_prism.sh +# +# Manual verification of the full L402 payment flow through Prism. +# Uses your running prism on :8080 (per sample-conf-tmp.yaml) and drives +# the payment with *bob* (the second LND node from +# /Users/blake/work/nagara/code/chain/loka-payment/lnd/scripts/itest_sui_single_coin.sh). +# After payment, replays the request with the LSAT token and dumps +# what appears in Prism's admin API so you can see the transaction +# recorded — visible at https://127.0.0.1:8080/api/admin/transactions +# (or in the dashboard if you build prism with `make build-withdashboard`). +# +# Prerequisites: +# 1. Alice's LND running on :10009 (prism's authenticator) +# 2. Bob's LND running on :10010 +# 3. An open channel Alice ↔ Bob with enough capacity (itest script opens this) +# 4. Prism running: `prism --configfile=./sample-conf-tmp.yaml` +# +# Usage: +# ./scripts/manual_pay_through_prism.sh # default: service1 +# SERVICE_HOST=foo.com PATH_SUFFIX=/bar ./scripts/manual_pay_through_prism.sh +# PRISM_BASEDIR=/abs/path ./scripts/manual_pay_through_prism.sh + +set -euo pipefail + +# --- Configuration ------------------------------------------------------- + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +PRISM_HOST="${PRISM_HOST:-127.0.0.1:8080}" +PRISM_BASEDIR="${PRISM_BASEDIR:-$REPO_DIR/.prism}" +SERVICE_HOST="${SERVICE_HOST:-service1.com}" +PATH_SUFFIX="${PATH_SUFFIX:-/probe}" + +NETWORK="${NETWORK:-devnet}" +ALICE_DIR="${ALICE_DIR:-/tmp/lnd-sui-test/alice}" +BOB_DIR="${BOB_DIR:-/tmp/lnd-sui-test/bob}" +ALICE_RPC="${ALICE_RPC:-127.0.0.1:10009}" +BOB_RPC="${BOB_RPC:-127.0.0.1:10010}" + +LND_REPO="${LND_REPO:-/Users/blake/work/nagara/code/chain/loka-payment/lnd}" +LNCLI="${LNCLI:-$LND_REPO/lncli-debug}" + +# Colors +if [ -t 1 ]; then + G=$'\033[32m'; R=$'\033[31m'; Y=$'\033[33m'; C=$'\033[36m'; N=$'\033[0m' +else + G=""; R=""; Y=""; C=""; N="" +fi + +step() { echo; echo "${C}━━━ $* ━━━${N}"; } +pass() { echo " ${G}✓${N} $*"; } +warn() { echo " ${Y}~${N} $*"; } +fail() { echo " ${R}✗${N} $*"; exit 1; } + +# lncli wrappers. We pass --lnddir (not --network/--chain) because lncli's +# hard-coded network whitelist doesn't include "devnet"; using lnddir makes +# lncli resolve paths from the daemon's own state dir instead. +alice_cli() { + "$LNCLI" --lnddir="$ALICE_DIR" --rpcserver="$ALICE_RPC" \ + --macaroonpath="$ALICE_DIR/data/chain/sui/$NETWORK/admin.macaroon" \ + "$@" +} +bob_cli() { + "$LNCLI" --lnddir="$BOB_DIR" --rpcserver="$BOB_RPC" \ + --macaroonpath="$BOB_DIR/data/chain/sui/$NETWORK/admin.macaroon" \ + "$@" +} + +# --- 1. Preflight -------------------------------------------------------- + +step "[1/7] Preflight" + +for dep in curl jq xxd nc; do + command -v "$dep" >/dev/null 2>&1 || fail "missing dependency: $dep" +done +[ -x "$LNCLI" ] || fail "lncli not found at $LNCLI (set LNCLI=...)" + +nc -z "${PRISM_HOST%:*}" "${PRISM_HOST##*:}" \ + || fail "prism not listening on $PRISM_HOST — start it with: prism --configfile=./sample-conf-tmp.yaml" +nc -z "${ALICE_RPC%:*}" "${ALICE_RPC##*:}" \ + || fail "alice LND not reachable at $ALICE_RPC" +nc -z "${BOB_RPC%:*}" "${BOB_RPC##*:}" \ + || fail "bob LND not reachable at $BOB_RPC — did itest_sui_single_coin.sh finish?" +pass "prism, alice, bob all reachable" + +ADMIN_MAC="$PRISM_BASEDIR/admin.macaroon" +[ -f "$ADMIN_MAC" ] \ + || fail "admin macaroon not found at $ADMIN_MAC — adjust PRISM_BASEDIR" +ADMIN_MAC_HEX=$(xxd -ps -c 10000 "$ADMIN_MAC") +pass "admin macaroon loaded (${#ADMIN_MAC_HEX} hex chars)" + +# Quick channel sanity (best-effort — bob→alice route) +channels=$(bob_cli listchannels 2>/dev/null || echo '{"channels":[]}') +active=$(echo "$channels" | jq '[.channels[] | select(.active==true)] | length' 2>/dev/null || echo 0) +if [ "$active" -gt 0 ]; then + pass "bob has $active active channel(s)" +else + warn "bob has no active channels — payment step may fail. Run itest_sui_single_coin.sh first." +fi + +curl_admin() { + curl -sk -H "Grpc-Metadata-Macaroon: $ADMIN_MAC_HEX" "$@" +} + +# --- 2. Unauthenticated request ----------------------------------------- + +step "[2/7] Unauthenticated GET https://$PRISM_HOST$PATH_SUFFIX (Host: $SERVICE_HOST)" + +HDR=$(mktemp); BODY=$(mktemp) +trap 'rm -f "$HDR" "$BODY"' EXIT + +code=$(curl -sk -o "$BODY" -D "$HDR" -w '%{http_code}' \ + -H "Host: $SERVICE_HOST" \ + "https://$PRISM_HOST$PATH_SUFFIX") + +echo " HTTP $code" +if [ "$code" = "200" ]; then + warn "Got 200 immediately. This service is probably whitelisted, price=0, or" + warn "the Host header doesn't match any protected service. Inspect your config." + echo " body: $(head -c 200 "$BODY")" + echo "" + echo "Hint: the sample config's service1 has price:0. To force a 402, edit" + echo "sample-conf-tmp.yaml and set a non-zero price, then restart prism." + exit 0 +fi +[ "$code" = "402" ] || fail "expected 402, got $code. body: $(head -c 200 "$BODY")" +pass "prism challenged with 402" + +www=$(grep -i '^www-authenticate:' "$HDR" | head -1) +[ -n "$www" ] || fail "no WWW-Authenticate header. Headers: $(cat "$HDR")" +echo " $www" | fold -s -w 100 | sed 's/^/ /' + +# --- 3. Parse LSAT challenge -------------------------------------------- + +step "[3/7] Extract macaroon + invoice" + +mac=$(echo "$www" | sed -n 's/.*macaroon="\([^"]*\)".*/\1/p') +inv=$(echo "$www" | sed -n 's/.*invoice="\([^"]*\)".*/\1/p') + +[ -n "$mac" ] || fail "could not parse macaroon from: $www" +[ -n "$inv" ] || fail "could not parse invoice from: $www" +pass "macaroon: ${#mac} base64 chars" +pass "invoice: ${inv:0:40}..." + +# --- 4. Decode invoice -------------------------------------------------- + +step "[4/7] Decode invoice (via alice)" + +decoded=$(alice_cli decodepayreq "$inv" 2>&1) \ + || fail "decodepayreq failed: $decoded" +echo "$decoded" | jq '{ + amount_sats: .num_satoshis, + payment_hash: .payment_hash, + description: .description, + expiry_sec: .expiry, + destination: .destination +}' | sed 's/^/ /' + +amt=$(echo "$decoded" | jq -r '.num_satoshis') +phash=$(echo "$decoded" | jq -r '.payment_hash') + +# --- 5. Pay with bob ---------------------------------------------------- + +step "[5/7] Pay the invoice from bob" + +pay_json=$(bob_cli payinvoice --force --json "$inv" 2>&1) || { + echo "$pay_json" | sed 's/^/ /' + fail "bob payinvoice failed — is there a routable channel alice←bob with capacity ≥ $amt sats?" +} + +# lncli sometimes returns multiple JSON objects (streaming status updates). +# Take the last one (the terminal status). +final=$(echo "$pay_json" | jq -s '.[-1]' 2>/dev/null) \ + || final="$pay_json" + +preimage=$(echo "$final" | jq -r '.payment_preimage // empty') +status=$(echo "$final" | jq -r '.status // .payment_error // empty') + +if [ -z "$preimage" ] || [ "$preimage" = "null" ]; then + echo "$final" | sed 's/^/ /' + fail "no payment_preimage returned — status: $status" +fi + +echo " amount: $amt sats" +echo " status: $status" +echo " preimage: $preimage" +pass "payment settled" + +# --- 6. Replay with LSAT token ------------------------------------------ + +step "[6/7] Replay request with LSAT token" + +# Give prism a moment to process the SubscribeInvoices settlement event. +sleep 2 + +code2=$(curl -sk -o "$BODY" -D "$HDR" -w '%{http_code}' \ + -H "Host: $SERVICE_HOST" \ + -H "Authorization: LSAT $mac:$preimage" \ + "https://$PRISM_HOST$PATH_SUFFIX") + +echo " HTTP $code2" +case "$code2" in + 401|402) + echo " body: $(head -c 200 "$BODY")" + fail "auth rejected — did the preimage match the invoice's payment hash?" + ;; + 200|201|204) + pass "backend reached through prism — auth succeeded" + ;; + *) + # Any non-auth-error status means LSAT validation passed; the + # backend just responded oddly (dummy endpoints like Alice's gRPC + # return 415 to HTTP requests, down services return 502/503, etc.) + pass "LSAT auth succeeded (backend returned $code2 — expected for dummy backends)" + ;; +esac + +# --- 7. Inspect admin API ----------------------------------------------- + +step "[7/7] What prism recorded" + +echo "${C}Transactions (last 5):${N}" +curl_admin "https://$PRISM_HOST/api/admin/transactions?limit=5" \ + | jq '.transactions // . | .[:5]' 2>/dev/null \ + | sed 's/^/ /' || echo " (none or endpoint unavailable)" + +echo +echo "${C}Tokens (last 5):${N}" +curl_admin "https://$PRISM_HOST/api/admin/tokens?limit=5" \ + | jq '.tokens // . | .[:5]' 2>/dev/null \ + | sed 's/^/ /' || echo " (none)" + +echo +echo "${C}Revenue stats:${N}" +curl_admin "https://$PRISM_HOST/api/admin/stats" \ + | jq . 2>/dev/null \ + | sed 's/^/ /' || echo " (unavailable)" + +echo +echo "${G}━━━ Done ━━━${N}" +echo "Inspect the admin API anytime:" +echo " curl -sk -H \"Grpc-Metadata-Macaroon: \$(xxd -ps -c 10000 $ADMIN_MAC)\" \\" +echo " https://$PRISM_HOST/api/admin/transactions | jq ." +echo +echo "For a web UI, rebuild with the dashboard embedded:" +echo " (cd dashboard && npm ci) && make build-withdashboard" +echo "Then https://$PRISM_HOST/ will serve the Next.js dashboard." From 41ad8d56278c2fe16e4f902b5b72a93c0f30619d Mon Sep 17 00:00:00 2001 From: ai-chen2050 <1033467071@qq.com> Date: Thu, 23 Apr 2026 10:52:31 +0800 Subject: [PATCH 05/32] docs(config): add mixed-auth service example Show in sample-conf.yaml how to configure a single service with free, token-required-but-no-invoice, and paid paths within one entry. Documents the distinction between authwhitelistpaths (bypass auth entirely) and authskipinvoicecreationpaths (still require a valid L402 token, but return 401 instead of 402 to avoid breaking streaming clients that can't consume a payment challenge). Co-Authored-By: Claude Opus 4.7 (1M context) --- sample-conf.yaml | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/sample-conf.yaml b/sample-conf.yaml index bd82d69f..b7016d17 100644 --- a/sample-conf.yaml +++ b/sample-conf.yaml @@ -314,6 +314,43 @@ services: insecure: false tlscertpath: "path-to-pricer-server-tls-cert/tls.cert" + # Mixed-auth example: a single backend where health checks and public + # documentation are free, most API calls require payment, and streaming + # endpoints require a pre-paid token but never issue a new one themselves. + - name: "mixed-api" + hostregexp: '^api\.example\.com$' + pathregexp: '^/.*$' # match everything, then filter below + address: "127.0.0.1:9999" + protocol: http + timeout: 31557600 # L402 token valid for 1 year + price: 1000 # default price for paid paths, in satoshis + + # Free paths — no L402 challenge, request is proxied straight through. + # Use for health checks, docs, and any public read-only endpoint. + authwhitelistpaths: + - '^/health$' + - '^/metrics$' + - '^/docs$' + - '^/docs/.*$' + - '^/openapi\.json$' + + # "Token required, no new invoice" paths. Prism does NOT return a 402 + # challenge on these paths — a request without a valid L402 token gets + # 401 Unauthorized instead. The client must first acquire a token on a + # normal paid path (e.g. POST /auth/bootstrap, which returns 402 → + # client pays → gets macaroon+preimage), then replay that token here. + # + # Why this exists: streaming protocols (SSE, WebSocket, gRPC streams) + # can't gracefully consume a 402 response — the client is expecting a + # long-lived stream, not a JSON error body. So we force the token to + # be obtained on a regular request first. + authskipinvoicecreationpaths: + - '^/stream/.*$' # server-sent events / WebSocket upgrade + - '^/events/subscribe$' + - '^/grpc\.StreamService/.*$' + + # Any path not matched above costs `price` satoshis per token. + # Settings for a Tor instance to allow requests over Tor as onion services. # Configuring Tor is optional. tor: From 96c85b54f018863907288023fa4a0217d541b9be Mon Sep 17 00:00:00 2001 From: ai-chen2050 <1033467071@qq.com> Date: Thu, 23 Apr 2026 15:05:44 +0800 Subject: [PATCH 06/32] test: add demo HTTP backend for L402 payment flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/serve_demo_backend.sh starts a Python http.server on :9998 with fixture files (index.html, data.json, probe) so the default paid-flow demo in manual_pay_through_prism.sh can round-trip end-to-end: 402 challenge → Lightning payment → LSAT replay → HTTP 200 with real backend content. Pair with sample-conf-tmp.yaml service1 pointed at 127.0.0.1:9998 (protocol http). Fixture files are idempotent — the script only writes them if missing, so edits stick across restarts. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/serve_demo_backend.sh | 110 ++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100755 scripts/serve_demo_backend.sh diff --git a/scripts/serve_demo_backend.sh b/scripts/serve_demo_backend.sh new file mode 100755 index 00000000..21d57907 --- /dev/null +++ b/scripts/serve_demo_backend.sh @@ -0,0 +1,110 @@ +#!/bin/bash +# serve_demo_backend.sh +# +# Starts a tiny Python HTTP server on :9998 with a few fixture files, used +# as a dummy backend for the L402 payment flow demo (see +# manual_pay_through_prism.sh). Point a Prism service at 127.0.0.1:9998 +# with protocol=http and exercise the full 402 → pay → 200 round-trip. +# +# Example service1 stanza in sample-conf-tmp.yaml: +# +# services: +# - name: "service1" +# hostregexp: '^service1.com$' +# pathregexp: '^/.*$' +# address: "127.0.0.1:9998" +# protocol: http +# price: 0 +# +# Usage: +# ./scripts/serve_demo_backend.sh # foreground, Ctrl-C to stop +# PORT=9998 ./scripts/serve_demo_backend.sh # custom port +# ./scripts/serve_demo_backend.sh & # background +# +# Default fixture content served: +# GET / → index.html (HTML page) +# GET /data.json → JSON payload +# GET /probe → probe.txt (matches manual_pay_through_prism.sh default) +# GET / → 404 (standard http.server behavior) + +set -euo pipefail + +PORT="${PORT:-9998}" +BIND="${BIND:-127.0.0.1}" +SERVE_DIR="${SERVE_DIR:-/tmp/prism-backend}" + +# Prepare fixture files only if the serve dir doesn't already have them. +mkdir -p "$SERVE_DIR" + +if [ ! -f "$SERVE_DIR/index.html" ]; then + cat > "$SERVE_DIR/index.html" <<'HTML' + + + + +Prism L402 Demo Backend + + + +

Hello from the Prism backend

+
+ If you are seeing this page, your Lightning payment + cleared and Prism validated the L402 token before forwarding the + request to this backend (127.0.0.1:9998). +
+

Try other endpoints:

+ +

Swap this backend for your real service by changing + services[0].address in sample-conf-tmp.yaml + and restarting Prism.

+ + +HTML +fi + +if [ ! -f "$SERVE_DIR/data.json" ]; then + cat > "$SERVE_DIR/data.json" <<'JSON' +{ + "status": "paid", + "message": "If you received this response, the L402 token was validated.", + "demo": true, + "backend": "python http.server", + "note": "Pair this with sample-conf-tmp.yaml service1 (address=127.0.0.1:9998)." +} +JSON +fi + +# /probe is the default path used by scripts/manual_pay_through_prism.sh, +# so we ship a fixture file with no extension so GET /probe returns 200. +if [ ! -f "$SERVE_DIR/probe" ]; then + cat > "$SERVE_DIR/probe" <<'TEXT' +ok — L402 token validated; backend reached via Prism. +TEXT +fi + +if ! command -v python3 >/dev/null 2>&1; then + echo "error: python3 not found on PATH" >&2 + exit 1 +fi + +echo "Serving $SERVE_DIR on http://$BIND:$PORT" +echo "Fixtures:" +echo " GET / → index.html" +echo " GET /data.json → data.json" +echo " GET /probe → probe" +echo +echo "Ctrl-C to stop. Pair with:" +echo " ./prism --configfile=./sample-conf-tmp.yaml # Prism on :8080" +echo " ./scripts/manual_pay_through_prism.sh # drives a paid request" + +cd "$SERVE_DIR" +exec python3 -m http.server "$PORT" --bind "$BIND" From a0a3af8b19967b11df06a4f9cea4e0dee0d569ac Mon Sep 17 00:00:00 2001 From: ai-chen2050 <1033467071@qq.com> Date: Thu, 23 Apr 2026 15:35:00 +0800 Subject: [PATCH 07/32] feat(admin,dashboard): chain-aware currency (SUI vs sats) Expose the underlying lightning chain via the admin API and use it in the dashboard to pick the unit label (SUI for the Sui-adapted lnd, sats for bitcoin). Backend - adminrpc: add chain field to GetInfoResponse. - aperture startup: open a read-only lnd client (readonly.macaroon) and call GetInfo once to cache chains[0].chain on the Aperture struct. invoice.macaroon lacks info:read so we use the readonly one. Best- effort: on failure we proceed with an empty chain and log a warning. - admin.ServerConfig: add Chain, populate in GetInfo handler. - createAdminServer: thread the chain from aperture startup through. Frontend - lib/currency.ts: new formatter utility with chainKind/unitLabel/ baseUnitLabel/formatAmount. Sui path divides raw MIST by 1e9 and trims trailing zeros so the dashboard shows e.g. "0.000001 SUI" instead of "1000 MIST". Bitcoin path keeps "sats" (which are small enough to stay readable as integers). - lib/types.ts: add chain to InfoResponse. - app/page.tsx, app/transactions/page.tsx, app/services/page.tsx, app/services/detail/page.tsx: pull chain via useInfo, replace hardcoded "sats" in KPI tiles, service price cells, transaction tables, and toast messages. - components/ActivityChart.tsx, components/RevenueChart.tsx: accept chain prop, format tooltip and axis label accordingly. Form inputs still use baseUnitLabel (MIST on Sui) so the user types the exact integer stored by lnd; display contexts scale into SUI. Co-Authored-By: Claude Opus 4.7 (1M context) --- admin/server.go | 6 + adminrpc/admin.pb.go | 22 +- adminrpc/admin.pb.gw.go | 553 +++++++++---------------- adminrpc/admin.proto | 5 + adminrpc/admin.swagger.json | 59 +-- adminrpc/admin_grpc.pb.go | 110 +++-- aperture.go | 44 ++ dashboard/app/page.tsx | 16 +- dashboard/app/services/detail/page.tsx | 16 +- dashboard/app/services/page.tsx | 11 +- dashboard/app/transactions/page.tsx | 11 +- dashboard/components/ActivityChart.tsx | 10 +- dashboard/components/RevenueChart.tsx | 13 +- dashboard/lib/currency.ts | 107 +++++ dashboard/lib/types.ts | 4 + 15 files changed, 532 insertions(+), 455 deletions(-) create mode 100644 dashboard/lib/currency.ts diff --git a/admin/server.go b/admin/server.go index 04187eec..d35f59ea 100644 --- a/admin/server.go +++ b/admin/server.go @@ -117,6 +117,11 @@ type ServerConfig struct { // MPPRealm is the realm string used in MPP challenge headers. MPPRealm string + + // Chain identifies the blockchain the connected lnd is running on. + // Populated from lnd GetInfo.chains[0].chain at aperture startup + // (e.g. "bitcoin", "sui"). Empty if lnd could not be queried. + Chain string } // Server implements the adminrpc.AdminServer gRPC interface. Thread safety @@ -144,6 +149,7 @@ func (s *Server) GetInfo(_ context.Context, MppEnabled: s.cfg.MPPEnabled, SessionsEnabled: s.cfg.SessionsEnabled, MppRealm: s.cfg.MPPRealm, + Chain: s.cfg.Chain, }, nil } diff --git a/adminrpc/admin.pb.go b/adminrpc/admin.pb.go index 5e1685cb..3d6fec45 100644 --- a/adminrpc/admin.pb.go +++ b/adminrpc/admin.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.10 -// protoc v3.21.12 +// protoc-gen-go v1.36.11 +// protoc v7.34.1 // source: admin.proto package adminrpc @@ -124,7 +124,11 @@ type GetInfoResponse struct { // sessions_enabled indicates whether MPP session intents are enabled. SessionsEnabled bool `protobuf:"varint,5,opt,name=sessions_enabled,json=sessionsEnabled,proto3" json:"sessions_enabled,omitempty"` // mpp_realm is the realm string used in MPP challenge headers. - MppRealm string `protobuf:"bytes,6,opt,name=mpp_realm,json=mppRealm,proto3" json:"mpp_realm,omitempty"` + MppRealm string `protobuf:"bytes,6,opt,name=mpp_realm,json=mppRealm,proto3" json:"mpp_realm,omitempty"` + // chain is the underlying blockchain the connected lnd is running on, + // as reported by lnd's GetInfo.chains[0].chain (e.g. "bitcoin", "sui"). + // Empty if lnd could not be queried at startup. + Chain string `protobuf:"bytes,7,opt,name=chain,proto3" json:"chain,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -201,6 +205,13 @@ func (x *GetInfoResponse) GetMppRealm() string { return "" } +func (x *GetInfoResponse) GetChain() string { + if x != nil { + return x.Chain + } + return "" +} + type GetHealthRequest struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields @@ -1352,7 +1363,7 @@ var File_admin_proto protoreflect.FileDescriptor const file_admin_proto_rawDesc = "" + "\n" + "\vadmin.proto\x12\badminrpc\"\x10\n" + - "\x0eGetInfoRequest\"\xd1\x01\n" + + "\x0eGetInfoRequest\"\xe7\x01\n" + "\x0fGetInfoResponse\x12\x18\n" + "\anetwork\x18\x01 \x01(\tR\anetwork\x12\x1f\n" + "\vlisten_addr\x18\x02 \x01(\tR\n" + @@ -1361,7 +1372,8 @@ const file_admin_proto_rawDesc = "" + "\vmpp_enabled\x18\x04 \x01(\bR\n" + "mppEnabled\x12)\n" + "\x10sessions_enabled\x18\x05 \x01(\bR\x0fsessionsEnabled\x12\x1b\n" + - "\tmpp_realm\x18\x06 \x01(\tR\bmppRealm\"\x12\n" + + "\tmpp_realm\x18\x06 \x01(\tR\bmppRealm\x12\x14\n" + + "\x05chain\x18\a \x01(\tR\x05chain\"\x12\n" + "\x10GetHealthRequest\"+\n" + "\x11GetHealthResponse\x12\x16\n" + "\x06status\x18\x01 \x01(\tR\x06status\"\x15\n" + diff --git a/adminrpc/admin.pb.gw.go b/adminrpc/admin.pb.gw.go index bf7550f8..5eec9604 100644 --- a/adminrpc/admin.pb.gw.go +++ b/adminrpc/admin.pb.gw.go @@ -10,6 +10,7 @@ package adminrpc import ( "context" + "errors" "io" "net/http" @@ -24,396 +25,347 @@ import ( ) // Suppress "imported and not used" errors -var _ codes.Code -var _ io.Reader -var _ status.Status -var _ = runtime.String -var _ = utilities.NewDoubleArray -var _ = metadata.Join +var ( + _ codes.Code + _ io.Reader + _ status.Status + _ = errors.New + _ = runtime.String + _ = utilities.NewDoubleArray + _ = metadata.Join +) func request_Admin_GetInfo_0(ctx context.Context, marshaler runtime.Marshaler, client AdminClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var protoReq GetInfoRequest - var metadata runtime.ServerMetadata - + var ( + protoReq GetInfoRequest + metadata runtime.ServerMetadata + ) + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } msg, err := client.GetInfo(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) return msg, metadata, err - } func local_request_Admin_GetInfo_0(ctx context.Context, marshaler runtime.Marshaler, server AdminServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var protoReq GetInfoRequest - var metadata runtime.ServerMetadata - + var ( + protoReq GetInfoRequest + metadata runtime.ServerMetadata + ) msg, err := server.GetInfo(ctx, &protoReq) return msg, metadata, err - } func request_Admin_GetHealth_0(ctx context.Context, marshaler runtime.Marshaler, client AdminClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var protoReq GetHealthRequest - var metadata runtime.ServerMetadata - + var ( + protoReq GetHealthRequest + metadata runtime.ServerMetadata + ) + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } msg, err := client.GetHealth(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) return msg, metadata, err - } func local_request_Admin_GetHealth_0(ctx context.Context, marshaler runtime.Marshaler, server AdminServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var protoReq GetHealthRequest - var metadata runtime.ServerMetadata - + var ( + protoReq GetHealthRequest + metadata runtime.ServerMetadata + ) msg, err := server.GetHealth(ctx, &protoReq) return msg, metadata, err - } func request_Admin_ListServices_0(ctx context.Context, marshaler runtime.Marshaler, client AdminClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var protoReq ListServicesRequest - var metadata runtime.ServerMetadata - + var ( + protoReq ListServicesRequest + metadata runtime.ServerMetadata + ) + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } msg, err := client.ListServices(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) return msg, metadata, err - } func local_request_Admin_ListServices_0(ctx context.Context, marshaler runtime.Marshaler, server AdminServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var protoReq ListServicesRequest - var metadata runtime.ServerMetadata - + var ( + protoReq ListServicesRequest + metadata runtime.ServerMetadata + ) msg, err := server.ListServices(ctx, &protoReq) return msg, metadata, err - } func request_Admin_CreateService_0(ctx context.Context, marshaler runtime.Marshaler, client AdminClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var protoReq CreateServiceRequest - var metadata runtime.ServerMetadata - - newReader, berr := utilities.IOReaderFactory(req.Body) - if berr != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", berr) - } - if err := marshaler.NewDecoder(newReader()).Decode(&protoReq); err != nil && err != io.EOF { + var ( + protoReq CreateServiceRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) } - + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } msg, err := client.CreateService(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) return msg, metadata, err - } func local_request_Admin_CreateService_0(ctx context.Context, marshaler runtime.Marshaler, server AdminServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var protoReq CreateServiceRequest - var metadata runtime.ServerMetadata - - newReader, berr := utilities.IOReaderFactory(req.Body) - if berr != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", berr) - } - if err := marshaler.NewDecoder(newReader()).Decode(&protoReq); err != nil && err != io.EOF { + var ( + protoReq CreateServiceRequest + metadata runtime.ServerMetadata + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) } - msg, err := server.CreateService(ctx, &protoReq) return msg, metadata, err - } func request_Admin_UpdateService_0(ctx context.Context, marshaler runtime.Marshaler, client AdminClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var protoReq UpdateServiceRequest - var metadata runtime.ServerMetadata - - newReader, berr := utilities.IOReaderFactory(req.Body) - if berr != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", berr) - } - if err := marshaler.NewDecoder(newReader()).Decode(&protoReq); err != nil && err != io.EOF { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - var ( - val string - ok bool - err error - _ = err + protoReq UpdateServiceRequest + metadata runtime.ServerMetadata + err error ) - - val, ok = pathParams["name"] + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } + val, ok := pathParams["name"] if !ok { return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "name") } - protoReq.Name, err = runtime.String(val) if err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "name", err) } - msg, err := client.UpdateService(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) return msg, metadata, err - } func local_request_Admin_UpdateService_0(ctx context.Context, marshaler runtime.Marshaler, server AdminServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var protoReq UpdateServiceRequest - var metadata runtime.ServerMetadata - - newReader, berr := utilities.IOReaderFactory(req.Body) - if berr != nil { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", berr) - } - if err := marshaler.NewDecoder(newReader()).Decode(&protoReq); err != nil && err != io.EOF { - return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) - } - var ( - val string - ok bool - err error - _ = err + protoReq UpdateServiceRequest + metadata runtime.ServerMetadata + err error ) - - val, ok = pathParams["name"] + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + val, ok := pathParams["name"] if !ok { return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "name") } - protoReq.Name, err = runtime.String(val) if err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "name", err) } - msg, err := server.UpdateService(ctx, &protoReq) return msg, metadata, err - } func request_Admin_DeleteService_0(ctx context.Context, marshaler runtime.Marshaler, client AdminClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var protoReq DeleteServiceRequest - var metadata runtime.ServerMetadata - var ( - val string - ok bool - err error - _ = err + protoReq DeleteServiceRequest + metadata runtime.ServerMetadata + err error ) - - val, ok = pathParams["name"] + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } + val, ok := pathParams["name"] if !ok { return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "name") } - protoReq.Name, err = runtime.String(val) if err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "name", err) } - msg, err := client.DeleteService(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) return msg, metadata, err - } func local_request_Admin_DeleteService_0(ctx context.Context, marshaler runtime.Marshaler, server AdminServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var protoReq DeleteServiceRequest - var metadata runtime.ServerMetadata - var ( - val string - ok bool - err error - _ = err + protoReq DeleteServiceRequest + metadata runtime.ServerMetadata + err error ) - - val, ok = pathParams["name"] + val, ok := pathParams["name"] if !ok { return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "name") } - protoReq.Name, err = runtime.String(val) if err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "name", err) } - msg, err := server.DeleteService(ctx, &protoReq) return msg, metadata, err - } -var ( - filter_Admin_ListTransactions_0 = &utilities.DoubleArray{Encoding: map[string]int{}, Base: []int(nil), Check: []int(nil)} -) +var filter_Admin_ListTransactions_0 = &utilities.DoubleArray{Encoding: map[string]int{}, Base: []int(nil), Check: []int(nil)} func request_Admin_ListTransactions_0(ctx context.Context, marshaler runtime.Marshaler, client AdminClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var protoReq ListTransactionsRequest - var metadata runtime.ServerMetadata - + var ( + protoReq ListTransactionsRequest + metadata runtime.ServerMetadata + ) + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } if err := req.ParseForm(); err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) } if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_Admin_ListTransactions_0); err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) } - msg, err := client.ListTransactions(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) return msg, metadata, err - } func local_request_Admin_ListTransactions_0(ctx context.Context, marshaler runtime.Marshaler, server AdminServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var protoReq ListTransactionsRequest - var metadata runtime.ServerMetadata - + var ( + protoReq ListTransactionsRequest + metadata runtime.ServerMetadata + ) if err := req.ParseForm(); err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) } if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_Admin_ListTransactions_0); err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) } - msg, err := server.ListTransactions(ctx, &protoReq) return msg, metadata, err - } -var ( - filter_Admin_ListTokens_0 = &utilities.DoubleArray{Encoding: map[string]int{}, Base: []int(nil), Check: []int(nil)} -) +var filter_Admin_ListTokens_0 = &utilities.DoubleArray{Encoding: map[string]int{}, Base: []int(nil), Check: []int(nil)} func request_Admin_ListTokens_0(ctx context.Context, marshaler runtime.Marshaler, client AdminClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var protoReq ListTokensRequest - var metadata runtime.ServerMetadata - + var ( + protoReq ListTokensRequest + metadata runtime.ServerMetadata + ) + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } if err := req.ParseForm(); err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) } if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_Admin_ListTokens_0); err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) } - msg, err := client.ListTokens(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) return msg, metadata, err - } func local_request_Admin_ListTokens_0(ctx context.Context, marshaler runtime.Marshaler, server AdminServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var protoReq ListTokensRequest - var metadata runtime.ServerMetadata - + var ( + protoReq ListTokensRequest + metadata runtime.ServerMetadata + ) if err := req.ParseForm(); err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) } if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_Admin_ListTokens_0); err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) } - msg, err := server.ListTokens(ctx, &protoReq) return msg, metadata, err - } func request_Admin_RevokeToken_0(ctx context.Context, marshaler runtime.Marshaler, client AdminClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var protoReq RevokeTokenRequest - var metadata runtime.ServerMetadata - var ( - val string - ok bool - err error - _ = err + protoReq RevokeTokenRequest + metadata runtime.ServerMetadata + err error ) - - val, ok = pathParams["token_id"] + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } + val, ok := pathParams["token_id"] if !ok { return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "token_id") } - protoReq.TokenId, err = runtime.String(val) if err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "token_id", err) } - msg, err := client.RevokeToken(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) return msg, metadata, err - } func local_request_Admin_RevokeToken_0(ctx context.Context, marshaler runtime.Marshaler, server AdminServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var protoReq RevokeTokenRequest - var metadata runtime.ServerMetadata - var ( - val string - ok bool - err error - _ = err + protoReq RevokeTokenRequest + metadata runtime.ServerMetadata + err error ) - - val, ok = pathParams["token_id"] + val, ok := pathParams["token_id"] if !ok { return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "token_id") } - protoReq.TokenId, err = runtime.String(val) if err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "token_id", err) } - msg, err := server.RevokeToken(ctx, &protoReq) return msg, metadata, err - } -var ( - filter_Admin_GetStats_0 = &utilities.DoubleArray{Encoding: map[string]int{}, Base: []int(nil), Check: []int(nil)} -) +var filter_Admin_GetStats_0 = &utilities.DoubleArray{Encoding: map[string]int{}, Base: []int(nil), Check: []int(nil)} func request_Admin_GetStats_0(ctx context.Context, marshaler runtime.Marshaler, client AdminClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var protoReq GetStatsRequest - var metadata runtime.ServerMetadata - + var ( + protoReq GetStatsRequest + metadata runtime.ServerMetadata + ) + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } if err := req.ParseForm(); err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) } if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_Admin_GetStats_0); err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) } - msg, err := client.GetStats(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) return msg, metadata, err - } func local_request_Admin_GetStats_0(ctx context.Context, marshaler runtime.Marshaler, server AdminServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { - var protoReq GetStatsRequest - var metadata runtime.ServerMetadata - + var ( + protoReq GetStatsRequest + metadata runtime.ServerMetadata + ) if err := req.ParseForm(); err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) } if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_Admin_GetStats_0); err != nil { return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) } - msg, err := server.GetStats(ctx, &protoReq) return msg, metadata, err - } // RegisterAdminHandlerServer registers the http handlers for service Admin to "mux". // UnaryRPC :call AdminServer directly. // StreamingRPC :currently unsupported pending https://github.com/grpc/grpc-go/issues/906. // Note that using this registration option will cause many gRPC library features to stop working. Consider using RegisterAdminHandlerFromEndpoint instead. +// GRPC interceptors will not work for this type of registration. To use interceptors, you must use the "runtime.WithMiddlewares" option in the "runtime.NewServeMux" call. func RegisterAdminHandlerServer(ctx context.Context, mux *runtime.ServeMux, server AdminServer) error { - - mux.Handle("GET", pattern_Admin_GetInfo_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + mux.Handle(http.MethodGet, pattern_Admin_GetInfo_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() var stream runtime.ServerTransportStream ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - var err error - var annotatedContext context.Context - annotatedContext, err = runtime.AnnotateIncomingContext(ctx, mux, req, "/adminrpc.Admin/GetInfo", runtime.WithHTTPPathPattern("/api/admin/info")) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/adminrpc.Admin/GetInfo", runtime.WithHTTPPathPattern("/api/admin/info")) if err != nil { runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) return @@ -425,20 +377,15 @@ func RegisterAdminHandlerServer(ctx context.Context, mux *runtime.ServeMux, serv runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) return } - forward_Admin_GetInfo_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - - mux.Handle("GET", pattern_Admin_GetHealth_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + mux.Handle(http.MethodGet, pattern_Admin_GetHealth_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() var stream runtime.ServerTransportStream ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - var err error - var annotatedContext context.Context - annotatedContext, err = runtime.AnnotateIncomingContext(ctx, mux, req, "/adminrpc.Admin/GetHealth", runtime.WithHTTPPathPattern("/api/admin/health")) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/adminrpc.Admin/GetHealth", runtime.WithHTTPPathPattern("/api/admin/health")) if err != nil { runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) return @@ -450,20 +397,15 @@ func RegisterAdminHandlerServer(ctx context.Context, mux *runtime.ServeMux, serv runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) return } - forward_Admin_GetHealth_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - - mux.Handle("GET", pattern_Admin_ListServices_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + mux.Handle(http.MethodGet, pattern_Admin_ListServices_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() var stream runtime.ServerTransportStream ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - var err error - var annotatedContext context.Context - annotatedContext, err = runtime.AnnotateIncomingContext(ctx, mux, req, "/adminrpc.Admin/ListServices", runtime.WithHTTPPathPattern("/api/admin/services")) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/adminrpc.Admin/ListServices", runtime.WithHTTPPathPattern("/api/admin/services")) if err != nil { runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) return @@ -475,20 +417,15 @@ func RegisterAdminHandlerServer(ctx context.Context, mux *runtime.ServeMux, serv runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) return } - forward_Admin_ListServices_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - - mux.Handle("POST", pattern_Admin_CreateService_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + mux.Handle(http.MethodPost, pattern_Admin_CreateService_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() var stream runtime.ServerTransportStream ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - var err error - var annotatedContext context.Context - annotatedContext, err = runtime.AnnotateIncomingContext(ctx, mux, req, "/adminrpc.Admin/CreateService", runtime.WithHTTPPathPattern("/api/admin/services")) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/adminrpc.Admin/CreateService", runtime.WithHTTPPathPattern("/api/admin/services")) if err != nil { runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) return @@ -500,20 +437,15 @@ func RegisterAdminHandlerServer(ctx context.Context, mux *runtime.ServeMux, serv runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) return } - forward_Admin_CreateService_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - - mux.Handle("PUT", pattern_Admin_UpdateService_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + mux.Handle(http.MethodPut, pattern_Admin_UpdateService_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() var stream runtime.ServerTransportStream ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - var err error - var annotatedContext context.Context - annotatedContext, err = runtime.AnnotateIncomingContext(ctx, mux, req, "/adminrpc.Admin/UpdateService", runtime.WithHTTPPathPattern("/api/admin/services/{name}")) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/adminrpc.Admin/UpdateService", runtime.WithHTTPPathPattern("/api/admin/services/{name}")) if err != nil { runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) return @@ -525,20 +457,15 @@ func RegisterAdminHandlerServer(ctx context.Context, mux *runtime.ServeMux, serv runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) return } - forward_Admin_UpdateService_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - - mux.Handle("DELETE", pattern_Admin_DeleteService_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + mux.Handle(http.MethodDelete, pattern_Admin_DeleteService_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() var stream runtime.ServerTransportStream ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - var err error - var annotatedContext context.Context - annotatedContext, err = runtime.AnnotateIncomingContext(ctx, mux, req, "/adminrpc.Admin/DeleteService", runtime.WithHTTPPathPattern("/api/admin/services/{name}")) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/adminrpc.Admin/DeleteService", runtime.WithHTTPPathPattern("/api/admin/services/{name}")) if err != nil { runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) return @@ -550,20 +477,15 @@ func RegisterAdminHandlerServer(ctx context.Context, mux *runtime.ServeMux, serv runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) return } - forward_Admin_DeleteService_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - - mux.Handle("GET", pattern_Admin_ListTransactions_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + mux.Handle(http.MethodGet, pattern_Admin_ListTransactions_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() var stream runtime.ServerTransportStream ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - var err error - var annotatedContext context.Context - annotatedContext, err = runtime.AnnotateIncomingContext(ctx, mux, req, "/adminrpc.Admin/ListTransactions", runtime.WithHTTPPathPattern("/api/admin/transactions")) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/adminrpc.Admin/ListTransactions", runtime.WithHTTPPathPattern("/api/admin/transactions")) if err != nil { runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) return @@ -575,20 +497,15 @@ func RegisterAdminHandlerServer(ctx context.Context, mux *runtime.ServeMux, serv runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) return } - forward_Admin_ListTransactions_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - - mux.Handle("GET", pattern_Admin_ListTokens_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + mux.Handle(http.MethodGet, pattern_Admin_ListTokens_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() var stream runtime.ServerTransportStream ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - var err error - var annotatedContext context.Context - annotatedContext, err = runtime.AnnotateIncomingContext(ctx, mux, req, "/adminrpc.Admin/ListTokens", runtime.WithHTTPPathPattern("/api/admin/tokens")) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/adminrpc.Admin/ListTokens", runtime.WithHTTPPathPattern("/api/admin/tokens")) if err != nil { runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) return @@ -600,20 +517,15 @@ func RegisterAdminHandlerServer(ctx context.Context, mux *runtime.ServeMux, serv runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) return } - forward_Admin_ListTokens_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - - mux.Handle("DELETE", pattern_Admin_RevokeToken_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + mux.Handle(http.MethodDelete, pattern_Admin_RevokeToken_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() var stream runtime.ServerTransportStream ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - var err error - var annotatedContext context.Context - annotatedContext, err = runtime.AnnotateIncomingContext(ctx, mux, req, "/adminrpc.Admin/RevokeToken", runtime.WithHTTPPathPattern("/api/admin/tokens/{token_id}")) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/adminrpc.Admin/RevokeToken", runtime.WithHTTPPathPattern("/api/admin/tokens/{token_id}")) if err != nil { runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) return @@ -625,20 +537,15 @@ func RegisterAdminHandlerServer(ctx context.Context, mux *runtime.ServeMux, serv runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) return } - forward_Admin_RevokeToken_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - - mux.Handle("GET", pattern_Admin_GetStats_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + mux.Handle(http.MethodGet, pattern_Admin_GetStats_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() var stream runtime.ServerTransportStream ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - var err error - var annotatedContext context.Context - annotatedContext, err = runtime.AnnotateIncomingContext(ctx, mux, req, "/adminrpc.Admin/GetStats", runtime.WithHTTPPathPattern("/api/admin/stats")) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/adminrpc.Admin/GetStats", runtime.WithHTTPPathPattern("/api/admin/stats")) if err != nil { runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) return @@ -650,9 +557,7 @@ func RegisterAdminHandlerServer(ctx context.Context, mux *runtime.ServeMux, serv runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) return } - forward_Admin_GetStats_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) return nil @@ -661,25 +566,24 @@ func RegisterAdminHandlerServer(ctx context.Context, mux *runtime.ServeMux, serv // RegisterAdminHandlerFromEndpoint is same as RegisterAdminHandler but // automatically dials to "endpoint" and closes the connection when "ctx" gets done. func RegisterAdminHandlerFromEndpoint(ctx context.Context, mux *runtime.ServeMux, endpoint string, opts []grpc.DialOption) (err error) { - conn, err := grpc.DialContext(ctx, endpoint, opts...) + conn, err := grpc.NewClient(endpoint, opts...) if err != nil { return err } defer func() { if err != nil { if cerr := conn.Close(); cerr != nil { - grpclog.Infof("Failed to close conn to %s: %v", endpoint, cerr) + grpclog.Errorf("Failed to close conn to %s: %v", endpoint, cerr) } return } go func() { <-ctx.Done() if cerr := conn.Close(); cerr != nil { - grpclog.Infof("Failed to close conn to %s: %v", endpoint, cerr) + grpclog.Errorf("Failed to close conn to %s: %v", endpoint, cerr) } }() }() - return RegisterAdminHandler(ctx, mux, conn) } @@ -693,16 +597,13 @@ func RegisterAdminHandler(ctx context.Context, mux *runtime.ServeMux, conn *grpc // to "mux". The handlers forward requests to the grpc endpoint over the given implementation of "AdminClient". // Note: the gRPC framework executes interceptors within the gRPC handler. If the passed in "AdminClient" // doesn't go through the normal gRPC flow (creating a gRPC client etc.) then it will be up to the passed in -// "AdminClient" to call the correct interceptors. +// "AdminClient" to call the correct interceptors. This client ignores the HTTP middlewares. func RegisterAdminHandlerClient(ctx context.Context, mux *runtime.ServeMux, client AdminClient) error { - - mux.Handle("GET", pattern_Admin_GetInfo_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + mux.Handle(http.MethodGet, pattern_Admin_GetInfo_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - var err error - var annotatedContext context.Context - annotatedContext, err = runtime.AnnotateContext(ctx, mux, req, "/adminrpc.Admin/GetInfo", runtime.WithHTTPPathPattern("/api/admin/info")) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/adminrpc.Admin/GetInfo", runtime.WithHTTPPathPattern("/api/admin/info")) if err != nil { runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) return @@ -713,18 +614,13 @@ func RegisterAdminHandlerClient(ctx context.Context, mux *runtime.ServeMux, clie runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) return } - forward_Admin_GetInfo_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - - mux.Handle("GET", pattern_Admin_GetHealth_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + mux.Handle(http.MethodGet, pattern_Admin_GetHealth_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - var err error - var annotatedContext context.Context - annotatedContext, err = runtime.AnnotateContext(ctx, mux, req, "/adminrpc.Admin/GetHealth", runtime.WithHTTPPathPattern("/api/admin/health")) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/adminrpc.Admin/GetHealth", runtime.WithHTTPPathPattern("/api/admin/health")) if err != nil { runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) return @@ -735,18 +631,13 @@ func RegisterAdminHandlerClient(ctx context.Context, mux *runtime.ServeMux, clie runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) return } - forward_Admin_GetHealth_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - - mux.Handle("GET", pattern_Admin_ListServices_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + mux.Handle(http.MethodGet, pattern_Admin_ListServices_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - var err error - var annotatedContext context.Context - annotatedContext, err = runtime.AnnotateContext(ctx, mux, req, "/adminrpc.Admin/ListServices", runtime.WithHTTPPathPattern("/api/admin/services")) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/adminrpc.Admin/ListServices", runtime.WithHTTPPathPattern("/api/admin/services")) if err != nil { runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) return @@ -757,18 +648,13 @@ func RegisterAdminHandlerClient(ctx context.Context, mux *runtime.ServeMux, clie runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) return } - forward_Admin_ListServices_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - - mux.Handle("POST", pattern_Admin_CreateService_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + mux.Handle(http.MethodPost, pattern_Admin_CreateService_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - var err error - var annotatedContext context.Context - annotatedContext, err = runtime.AnnotateContext(ctx, mux, req, "/adminrpc.Admin/CreateService", runtime.WithHTTPPathPattern("/api/admin/services")) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/adminrpc.Admin/CreateService", runtime.WithHTTPPathPattern("/api/admin/services")) if err != nil { runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) return @@ -779,18 +665,13 @@ func RegisterAdminHandlerClient(ctx context.Context, mux *runtime.ServeMux, clie runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) return } - forward_Admin_CreateService_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - - mux.Handle("PUT", pattern_Admin_UpdateService_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + mux.Handle(http.MethodPut, pattern_Admin_UpdateService_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - var err error - var annotatedContext context.Context - annotatedContext, err = runtime.AnnotateContext(ctx, mux, req, "/adminrpc.Admin/UpdateService", runtime.WithHTTPPathPattern("/api/admin/services/{name}")) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/adminrpc.Admin/UpdateService", runtime.WithHTTPPathPattern("/api/admin/services/{name}")) if err != nil { runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) return @@ -801,18 +682,13 @@ func RegisterAdminHandlerClient(ctx context.Context, mux *runtime.ServeMux, clie runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) return } - forward_Admin_UpdateService_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - - mux.Handle("DELETE", pattern_Admin_DeleteService_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + mux.Handle(http.MethodDelete, pattern_Admin_DeleteService_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - var err error - var annotatedContext context.Context - annotatedContext, err = runtime.AnnotateContext(ctx, mux, req, "/adminrpc.Admin/DeleteService", runtime.WithHTTPPathPattern("/api/admin/services/{name}")) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/adminrpc.Admin/DeleteService", runtime.WithHTTPPathPattern("/api/admin/services/{name}")) if err != nil { runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) return @@ -823,18 +699,13 @@ func RegisterAdminHandlerClient(ctx context.Context, mux *runtime.ServeMux, clie runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) return } - forward_Admin_DeleteService_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - - mux.Handle("GET", pattern_Admin_ListTransactions_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + mux.Handle(http.MethodGet, pattern_Admin_ListTransactions_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - var err error - var annotatedContext context.Context - annotatedContext, err = runtime.AnnotateContext(ctx, mux, req, "/adminrpc.Admin/ListTransactions", runtime.WithHTTPPathPattern("/api/admin/transactions")) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/adminrpc.Admin/ListTransactions", runtime.WithHTTPPathPattern("/api/admin/transactions")) if err != nil { runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) return @@ -845,18 +716,13 @@ func RegisterAdminHandlerClient(ctx context.Context, mux *runtime.ServeMux, clie runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) return } - forward_Admin_ListTransactions_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - - mux.Handle("GET", pattern_Admin_ListTokens_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + mux.Handle(http.MethodGet, pattern_Admin_ListTokens_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - var err error - var annotatedContext context.Context - annotatedContext, err = runtime.AnnotateContext(ctx, mux, req, "/adminrpc.Admin/ListTokens", runtime.WithHTTPPathPattern("/api/admin/tokens")) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/adminrpc.Admin/ListTokens", runtime.WithHTTPPathPattern("/api/admin/tokens")) if err != nil { runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) return @@ -867,18 +733,13 @@ func RegisterAdminHandlerClient(ctx context.Context, mux *runtime.ServeMux, clie runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) return } - forward_Admin_ListTokens_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - - mux.Handle("DELETE", pattern_Admin_RevokeToken_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + mux.Handle(http.MethodDelete, pattern_Admin_RevokeToken_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - var err error - var annotatedContext context.Context - annotatedContext, err = runtime.AnnotateContext(ctx, mux, req, "/adminrpc.Admin/RevokeToken", runtime.WithHTTPPathPattern("/api/admin/tokens/{token_id}")) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/adminrpc.Admin/RevokeToken", runtime.WithHTTPPathPattern("/api/admin/tokens/{token_id}")) if err != nil { runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) return @@ -889,18 +750,13 @@ func RegisterAdminHandlerClient(ctx context.Context, mux *runtime.ServeMux, clie runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) return } - forward_Admin_RevokeToken_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - - mux.Handle("GET", pattern_Admin_GetStats_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + mux.Handle(http.MethodGet, pattern_Admin_GetStats_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) - var err error - var annotatedContext context.Context - annotatedContext, err = runtime.AnnotateContext(ctx, mux, req, "/adminrpc.Admin/GetStats", runtime.WithHTTPPathPattern("/api/admin/stats")) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/adminrpc.Admin/GetStats", runtime.WithHTTPPathPattern("/api/admin/stats")) if err != nil { runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) return @@ -911,54 +767,33 @@ func RegisterAdminHandlerClient(ctx context.Context, mux *runtime.ServeMux, clie runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) return } - forward_Admin_GetStats_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) - }) - return nil } var ( - pattern_Admin_GetInfo_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "admin", "info"}, "")) - - pattern_Admin_GetHealth_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "admin", "health"}, "")) - - pattern_Admin_ListServices_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "admin", "services"}, "")) - - pattern_Admin_CreateService_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "admin", "services"}, "")) - - pattern_Admin_UpdateService_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 1, 5, 3}, []string{"api", "admin", "services", "name"}, "")) - - pattern_Admin_DeleteService_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 1, 5, 3}, []string{"api", "admin", "services", "name"}, "")) - + pattern_Admin_GetInfo_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "admin", "info"}, "")) + pattern_Admin_GetHealth_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "admin", "health"}, "")) + pattern_Admin_ListServices_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "admin", "services"}, "")) + pattern_Admin_CreateService_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "admin", "services"}, "")) + pattern_Admin_UpdateService_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 1, 5, 3}, []string{"api", "admin", "services", "name"}, "")) + pattern_Admin_DeleteService_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 1, 5, 3}, []string{"api", "admin", "services", "name"}, "")) pattern_Admin_ListTransactions_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "admin", "transactions"}, "")) - - pattern_Admin_ListTokens_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "admin", "tokens"}, "")) - - pattern_Admin_RevokeToken_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 1, 5, 3}, []string{"api", "admin", "tokens", "token_id"}, "")) - - pattern_Admin_GetStats_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "admin", "stats"}, "")) + pattern_Admin_ListTokens_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "admin", "tokens"}, "")) + pattern_Admin_RevokeToken_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 1, 5, 3}, []string{"api", "admin", "tokens", "token_id"}, "")) + pattern_Admin_GetStats_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "admin", "stats"}, "")) ) var ( - forward_Admin_GetInfo_0 = runtime.ForwardResponseMessage - - forward_Admin_GetHealth_0 = runtime.ForwardResponseMessage - - forward_Admin_ListServices_0 = runtime.ForwardResponseMessage - - forward_Admin_CreateService_0 = runtime.ForwardResponseMessage - - forward_Admin_UpdateService_0 = runtime.ForwardResponseMessage - - forward_Admin_DeleteService_0 = runtime.ForwardResponseMessage - + forward_Admin_GetInfo_0 = runtime.ForwardResponseMessage + forward_Admin_GetHealth_0 = runtime.ForwardResponseMessage + forward_Admin_ListServices_0 = runtime.ForwardResponseMessage + forward_Admin_CreateService_0 = runtime.ForwardResponseMessage + forward_Admin_UpdateService_0 = runtime.ForwardResponseMessage + forward_Admin_DeleteService_0 = runtime.ForwardResponseMessage forward_Admin_ListTransactions_0 = runtime.ForwardResponseMessage - - forward_Admin_ListTokens_0 = runtime.ForwardResponseMessage - - forward_Admin_RevokeToken_0 = runtime.ForwardResponseMessage - - forward_Admin_GetStats_0 = runtime.ForwardResponseMessage + forward_Admin_ListTokens_0 = runtime.ForwardResponseMessage + forward_Admin_RevokeToken_0 = runtime.ForwardResponseMessage + forward_Admin_GetStats_0 = runtime.ForwardResponseMessage ) diff --git a/adminrpc/admin.proto b/adminrpc/admin.proto index 0bedc4e0..af25ae45 100644 --- a/adminrpc/admin.proto +++ b/adminrpc/admin.proto @@ -34,6 +34,11 @@ message GetInfoResponse { // mpp_realm is the realm string used in MPP challenge headers. string mpp_realm = 6; + + // chain is the underlying blockchain the connected lnd is running on, + // as reported by lnd's GetInfo.chains[0].chain (e.g. "bitcoin", "sui"). + // Empty if lnd could not be queried at startup. + string chain = 7; } message GetHealthRequest {} diff --git a/adminrpc/admin.swagger.json b/adminrpc/admin.swagger.json index af9ab8b1..6035f3ac 100644 --- a/adminrpc/admin.swagger.json +++ b/adminrpc/admin.swagger.json @@ -169,32 +169,7 @@ "in": "body", "required": true, "schema": { - "type": "object", - "properties": { - "address": { - "type": "string" - }, - "protocol": { - "type": "string" - }, - "host_regexp": { - "type": "string" - }, - "path_regexp": { - "type": "string" - }, - "price": { - "type": "string", - "format": "int64" - }, - "auth": { - "type": "string" - }, - "auth_scheme": { - "$ref": "#/definitions/adminrpcAuthScheme", - "description": "auth_scheme specifies which payment auth scheme(s) to use. When not\nset, the existing auth_scheme is preserved (not reset to L402)." - } - } + "$ref": "#/definitions/AdminUpdateServiceBody" } } ], @@ -371,6 +346,34 @@ } }, "definitions": { + "AdminUpdateServiceBody": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "protocol": { + "type": "string" + }, + "host_regexp": { + "type": "string" + }, + "path_regexp": { + "type": "string" + }, + "price": { + "type": "string", + "format": "int64" + }, + "auth": { + "type": "string" + }, + "auth_scheme": { + "$ref": "#/definitions/adminrpcAuthScheme", + "description": "auth_scheme specifies which payment auth scheme(s) to use. When not\nset, the existing auth_scheme is preserved (not reset to L402)." + } + } + }, "adminrpcAuthScheme": { "type": "string", "enum": [ @@ -451,6 +454,10 @@ "mpp_realm": { "type": "string", "description": "mpp_realm is the realm string used in MPP challenge headers." + }, + "chain": { + "type": "string", + "description": "chain is the underlying blockchain the connected lnd is running on,\nas reported by lnd's GetInfo.chains[0].chain (e.g. \"bitcoin\", \"sui\").\nEmpty if lnd could not be queried at startup." } } }, diff --git a/adminrpc/admin_grpc.pb.go b/adminrpc/admin_grpc.pb.go index a0570b70..c057193b 100644 --- a/adminrpc/admin_grpc.pb.go +++ b/adminrpc/admin_grpc.pb.go @@ -1,4 +1,8 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.1 +// - protoc v7.34.1 +// source: admin.proto package adminrpc @@ -11,8 +15,21 @@ import ( // This is a compile-time assertion to ensure that this generated file // is compatible with the grpc package it is being compiled against. -// Requires gRPC-Go v1.32.0 or later. -const _ = grpc.SupportPackageIsVersion7 +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + Admin_GetInfo_FullMethodName = "/adminrpc.Admin/GetInfo" + Admin_GetHealth_FullMethodName = "/adminrpc.Admin/GetHealth" + Admin_ListServices_FullMethodName = "/adminrpc.Admin/ListServices" + Admin_CreateService_FullMethodName = "/adminrpc.Admin/CreateService" + Admin_UpdateService_FullMethodName = "/adminrpc.Admin/UpdateService" + Admin_DeleteService_FullMethodName = "/adminrpc.Admin/DeleteService" + Admin_ListTransactions_FullMethodName = "/adminrpc.Admin/ListTransactions" + Admin_ListTokens_FullMethodName = "/adminrpc.Admin/ListTokens" + Admin_RevokeToken_FullMethodName = "/adminrpc.Admin/RevokeToken" + Admin_GetStats_FullMethodName = "/adminrpc.Admin/GetStats" +) // AdminClient is the client API for Admin service. // @@ -39,8 +56,9 @@ func NewAdminClient(cc grpc.ClientConnInterface) AdminClient { } func (c *adminClient) GetInfo(ctx context.Context, in *GetInfoRequest, opts ...grpc.CallOption) (*GetInfoResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(GetInfoResponse) - err := c.cc.Invoke(ctx, "/adminrpc.Admin/GetInfo", in, out, opts...) + err := c.cc.Invoke(ctx, Admin_GetInfo_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -48,8 +66,9 @@ func (c *adminClient) GetInfo(ctx context.Context, in *GetInfoRequest, opts ...g } func (c *adminClient) GetHealth(ctx context.Context, in *GetHealthRequest, opts ...grpc.CallOption) (*GetHealthResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(GetHealthResponse) - err := c.cc.Invoke(ctx, "/adminrpc.Admin/GetHealth", in, out, opts...) + err := c.cc.Invoke(ctx, Admin_GetHealth_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -57,8 +76,9 @@ func (c *adminClient) GetHealth(ctx context.Context, in *GetHealthRequest, opts } func (c *adminClient) ListServices(ctx context.Context, in *ListServicesRequest, opts ...grpc.CallOption) (*ListServicesResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(ListServicesResponse) - err := c.cc.Invoke(ctx, "/adminrpc.Admin/ListServices", in, out, opts...) + err := c.cc.Invoke(ctx, Admin_ListServices_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -66,8 +86,9 @@ func (c *adminClient) ListServices(ctx context.Context, in *ListServicesRequest, } func (c *adminClient) CreateService(ctx context.Context, in *CreateServiceRequest, opts ...grpc.CallOption) (*Service, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(Service) - err := c.cc.Invoke(ctx, "/adminrpc.Admin/CreateService", in, out, opts...) + err := c.cc.Invoke(ctx, Admin_CreateService_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -75,8 +96,9 @@ func (c *adminClient) CreateService(ctx context.Context, in *CreateServiceReques } func (c *adminClient) UpdateService(ctx context.Context, in *UpdateServiceRequest, opts ...grpc.CallOption) (*Service, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(Service) - err := c.cc.Invoke(ctx, "/adminrpc.Admin/UpdateService", in, out, opts...) + err := c.cc.Invoke(ctx, Admin_UpdateService_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -84,8 +106,9 @@ func (c *adminClient) UpdateService(ctx context.Context, in *UpdateServiceReques } func (c *adminClient) DeleteService(ctx context.Context, in *DeleteServiceRequest, opts ...grpc.CallOption) (*DeleteServiceResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(DeleteServiceResponse) - err := c.cc.Invoke(ctx, "/adminrpc.Admin/DeleteService", in, out, opts...) + err := c.cc.Invoke(ctx, Admin_DeleteService_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -93,8 +116,9 @@ func (c *adminClient) DeleteService(ctx context.Context, in *DeleteServiceReques } func (c *adminClient) ListTransactions(ctx context.Context, in *ListTransactionsRequest, opts ...grpc.CallOption) (*ListTransactionsResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(ListTransactionsResponse) - err := c.cc.Invoke(ctx, "/adminrpc.Admin/ListTransactions", in, out, opts...) + err := c.cc.Invoke(ctx, Admin_ListTransactions_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -102,8 +126,9 @@ func (c *adminClient) ListTransactions(ctx context.Context, in *ListTransactions } func (c *adminClient) ListTokens(ctx context.Context, in *ListTokensRequest, opts ...grpc.CallOption) (*ListTokensResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(ListTokensResponse) - err := c.cc.Invoke(ctx, "/adminrpc.Admin/ListTokens", in, out, opts...) + err := c.cc.Invoke(ctx, Admin_ListTokens_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -111,8 +136,9 @@ func (c *adminClient) ListTokens(ctx context.Context, in *ListTokensRequest, opt } func (c *adminClient) RevokeToken(ctx context.Context, in *RevokeTokenRequest, opts ...grpc.CallOption) (*RevokeTokenResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(RevokeTokenResponse) - err := c.cc.Invoke(ctx, "/adminrpc.Admin/RevokeToken", in, out, opts...) + err := c.cc.Invoke(ctx, Admin_RevokeToken_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -120,8 +146,9 @@ func (c *adminClient) RevokeToken(ctx context.Context, in *RevokeTokenRequest, o } func (c *adminClient) GetStats(ctx context.Context, in *GetStatsRequest, opts ...grpc.CallOption) (*GetStatsResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(GetStatsResponse) - err := c.cc.Invoke(ctx, "/adminrpc.Admin/GetStats", in, out, opts...) + err := c.cc.Invoke(ctx, Admin_GetStats_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -130,7 +157,7 @@ func (c *adminClient) GetStats(ctx context.Context, in *GetStatsRequest, opts .. // AdminServer is the server API for Admin service. // All implementations must embed UnimplementedAdminServer -// for forward compatibility +// for forward compatibility. type AdminServer interface { GetInfo(context.Context, *GetInfoRequest) (*GetInfoResponse, error) GetHealth(context.Context, *GetHealthRequest) (*GetHealthResponse, error) @@ -145,41 +172,45 @@ type AdminServer interface { mustEmbedUnimplementedAdminServer() } -// UnimplementedAdminServer must be embedded to have forward compatible implementations. -type UnimplementedAdminServer struct { -} +// UnimplementedAdminServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedAdminServer struct{} func (UnimplementedAdminServer) GetInfo(context.Context, *GetInfoRequest) (*GetInfoResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method GetInfo not implemented") + return nil, status.Error(codes.Unimplemented, "method GetInfo not implemented") } func (UnimplementedAdminServer) GetHealth(context.Context, *GetHealthRequest) (*GetHealthResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method GetHealth not implemented") + return nil, status.Error(codes.Unimplemented, "method GetHealth not implemented") } func (UnimplementedAdminServer) ListServices(context.Context, *ListServicesRequest) (*ListServicesResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method ListServices not implemented") + return nil, status.Error(codes.Unimplemented, "method ListServices not implemented") } func (UnimplementedAdminServer) CreateService(context.Context, *CreateServiceRequest) (*Service, error) { - return nil, status.Errorf(codes.Unimplemented, "method CreateService not implemented") + return nil, status.Error(codes.Unimplemented, "method CreateService not implemented") } func (UnimplementedAdminServer) UpdateService(context.Context, *UpdateServiceRequest) (*Service, error) { - return nil, status.Errorf(codes.Unimplemented, "method UpdateService not implemented") + return nil, status.Error(codes.Unimplemented, "method UpdateService not implemented") } func (UnimplementedAdminServer) DeleteService(context.Context, *DeleteServiceRequest) (*DeleteServiceResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method DeleteService not implemented") + return nil, status.Error(codes.Unimplemented, "method DeleteService not implemented") } func (UnimplementedAdminServer) ListTransactions(context.Context, *ListTransactionsRequest) (*ListTransactionsResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method ListTransactions not implemented") + return nil, status.Error(codes.Unimplemented, "method ListTransactions not implemented") } func (UnimplementedAdminServer) ListTokens(context.Context, *ListTokensRequest) (*ListTokensResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method ListTokens not implemented") + return nil, status.Error(codes.Unimplemented, "method ListTokens not implemented") } func (UnimplementedAdminServer) RevokeToken(context.Context, *RevokeTokenRequest) (*RevokeTokenResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method RevokeToken not implemented") + return nil, status.Error(codes.Unimplemented, "method RevokeToken not implemented") } func (UnimplementedAdminServer) GetStats(context.Context, *GetStatsRequest) (*GetStatsResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method GetStats not implemented") + return nil, status.Error(codes.Unimplemented, "method GetStats not implemented") } func (UnimplementedAdminServer) mustEmbedUnimplementedAdminServer() {} +func (UnimplementedAdminServer) testEmbeddedByValue() {} // UnsafeAdminServer may be embedded to opt out of forward compatibility for this service. // Use of this interface is not recommended, as added methods to AdminServer will @@ -189,6 +220,13 @@ type UnsafeAdminServer interface { } func RegisterAdminServer(s grpc.ServiceRegistrar, srv AdminServer) { + // If the following call panics, it indicates UnimplementedAdminServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } s.RegisterService(&Admin_ServiceDesc, srv) } @@ -202,7 +240,7 @@ func _Admin_GetInfo_Handler(srv interface{}, ctx context.Context, dec func(inter } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/adminrpc.Admin/GetInfo", + FullMethod: Admin_GetInfo_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(AdminServer).GetInfo(ctx, req.(*GetInfoRequest)) @@ -220,7 +258,7 @@ func _Admin_GetHealth_Handler(srv interface{}, ctx context.Context, dec func(int } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/adminrpc.Admin/GetHealth", + FullMethod: Admin_GetHealth_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(AdminServer).GetHealth(ctx, req.(*GetHealthRequest)) @@ -238,7 +276,7 @@ func _Admin_ListServices_Handler(srv interface{}, ctx context.Context, dec func( } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/adminrpc.Admin/ListServices", + FullMethod: Admin_ListServices_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(AdminServer).ListServices(ctx, req.(*ListServicesRequest)) @@ -256,7 +294,7 @@ func _Admin_CreateService_Handler(srv interface{}, ctx context.Context, dec func } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/adminrpc.Admin/CreateService", + FullMethod: Admin_CreateService_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(AdminServer).CreateService(ctx, req.(*CreateServiceRequest)) @@ -274,7 +312,7 @@ func _Admin_UpdateService_Handler(srv interface{}, ctx context.Context, dec func } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/adminrpc.Admin/UpdateService", + FullMethod: Admin_UpdateService_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(AdminServer).UpdateService(ctx, req.(*UpdateServiceRequest)) @@ -292,7 +330,7 @@ func _Admin_DeleteService_Handler(srv interface{}, ctx context.Context, dec func } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/adminrpc.Admin/DeleteService", + FullMethod: Admin_DeleteService_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(AdminServer).DeleteService(ctx, req.(*DeleteServiceRequest)) @@ -310,7 +348,7 @@ func _Admin_ListTransactions_Handler(srv interface{}, ctx context.Context, dec f } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/adminrpc.Admin/ListTransactions", + FullMethod: Admin_ListTransactions_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(AdminServer).ListTransactions(ctx, req.(*ListTransactionsRequest)) @@ -328,7 +366,7 @@ func _Admin_ListTokens_Handler(srv interface{}, ctx context.Context, dec func(in } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/adminrpc.Admin/ListTokens", + FullMethod: Admin_ListTokens_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(AdminServer).ListTokens(ctx, req.(*ListTokensRequest)) @@ -346,7 +384,7 @@ func _Admin_RevokeToken_Handler(srv interface{}, ctx context.Context, dec func(i } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/adminrpc.Admin/RevokeToken", + FullMethod: Admin_RevokeToken_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(AdminServer).RevokeToken(ctx, req.(*RevokeTokenRequest)) @@ -364,7 +402,7 @@ func _Admin_GetStats_Handler(srv interface{}, ctx context.Context, dec func(inte } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: "/adminrpc.Admin/GetStats", + FullMethod: Admin_GetStats_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { return srv.(AdminServer).GetStats(ctx, req.(*GetStatsRequest)) diff --git a/aperture.go b/aperture.go index 38ec3774..2ae25d89 100644 --- a/aperture.go +++ b/aperture.go @@ -201,6 +201,11 @@ type Aperture struct { proxyCleanup func() adminCleanup func() + // lndChain is the blockchain name (e.g. "bitcoin", "sui") reported + // by the connected lnd at startup. Cached so the admin API can + // expose it without issuing a fresh GetInfo on every request. + lndChain string + wg sync.WaitGroup quit chan struct{} } @@ -371,6 +376,42 @@ func (a *Aperture) Start(errChan chan error, shutdown <-chan struct{}) error { return err } + // Query lnd's chain so the admin API can expose it + // (dashboard uses this to pick "SUI" vs "sats" as + // the unit label). GetInfo requires info:read, which + // invoice.macaroon lacks, so we open a separate + // read-only client just for this call. Best-effort: + // if readonly.macaroon is missing or the call fails, + // we proceed with an empty chain and log a warning. + readClient, roErr := lndclient.NewBasicClient( + authCfg.LndHost, authCfg.TLSPath, + authCfg.MacDir, authCfg.Network, + lndclient.MacFilename("readonly.macaroon"), + ) + if roErr != nil { + log.Warnf("skip chain detection: cannot "+ + "open readonly lnd client: %v", roErr) + } else { + infoCtx, cancelInfo := context.WithTimeout( + context.Background(), 5*time.Second, + ) + infoResp, infoErr := readClient.GetInfo( + infoCtx, &lnrpc.GetInfoRequest{}, + ) + cancelInfo() + if infoErr != nil { + log.Warnf("unable to query lnd "+ + "GetInfo for chain "+ + "detection: %v", infoErr) + } else if len(infoResp.Chains) > 0 { + a.lndChain = infoResp.Chains[0].Chain + log.Infof("Connected lnd reports "+ + "chain=%q network=%q", + a.lndChain, + infoResp.Chains[0].Network) + } + } + a.challenger, err = challenger.NewLndChallenger( client, a.cfg.InvoiceBatchSize, genInvoiceReq, context.Background, errChan, a.cfg.StrictVerify, @@ -394,6 +435,7 @@ func (a *Aperture) Start(errChan chan error, shutdown <-chan struct{}) error { adminPriority, adminFallback, adminCleanup, err := createAdminServer( a.cfg, txnStore, secretStore, svcStore, + a.lndChain, svcHolder.get, func(s []*proxy.Service) error { if err := a.UpdateServices(s); err != nil { @@ -976,6 +1018,7 @@ func createAdminServer(cfg *Config, txnStore *aperturedb.L402TransactionsStore, secretStore mint.SecretStore, svcStore *aperturedb.ServicesStore, + lndChain string, getServices func() []*proxy.Service, updateServices func([]*proxy.Service) error) ( []proxy.LocalService, []proxy.LocalService, func(), error) { @@ -1052,6 +1095,7 @@ func createAdminServer(cfg *Config, MPPEnabled: cfg.Authenticator.EnableMPP, SessionsEnabled: cfg.Authenticator.EnableSessions, MPPRealm: cfg.Authenticator.MPPRealm, + Chain: lndChain, }) adminGRPC := grpc.NewServer(serverOpts...) diff --git a/dashboard/app/page.tsx b/dashboard/app/page.tsx index 376b4bbe..79985b8b 100644 --- a/dashboard/app/page.tsx +++ b/dashboard/app/page.tsx @@ -2,7 +2,8 @@ import { useState, useMemo, useCallback } from "react"; import Link from "next/link"; -import { useStats, useServices, useTransactions } from "@/lib/api"; +import { useStats, useServices, useTransactions, useInfo } from "@/lib/api"; +import { formatAmount, unitLabel } from "@/lib/currency"; import styled from "@emotion/styled"; const sfp = { @@ -178,6 +179,9 @@ export default function DashboardPage() { const [dateFrom, setDateFrom] = useState(""); const [dateTo, setDateTo] = useState(""); + const { data: info } = useInfo(); + const chain = info?.chain; + const { data: stats, isLoading: statsLoading, @@ -296,10 +300,10 @@ export default function DashboardPage() { statsLoading ? "..." : stats - ? stats.total_revenue_sats.toLocaleString() + ? formatAmount(stats.total_revenue_sats, chain).value : "\u2014" } - suffix="sats" + suffix={unitLabel(chain)} /> Revenue Over Time {transactions ? ( - + ) : ( Loading... )} @@ -359,7 +363,7 @@ export default function DashboardPage() { {statsLoading ? ( Loading... ) : ( - + )} @@ -414,7 +418,7 @@ export default function DashboardPage() { gap: 16, }} > - {svc.price} sats + {formatAmount(svc.price, chain).value} {unitLabel(chain)} {rev > 0 && ( {rev.toLocaleString()} earned )} diff --git a/dashboard/app/services/detail/page.tsx b/dashboard/app/services/detail/page.tsx index 327595ef..3f09a37a 100644 --- a/dashboard/app/services/detail/page.tsx +++ b/dashboard/app/services/detail/page.tsx @@ -15,6 +15,7 @@ import styled from "@emotion/styled"; import { toast } from "@/components/Toast"; import type { AuthScheme } from "@/lib/types"; import { authSchemeLabels } from "@/lib/types"; +import { formatAmount, unitLabel, baseUnitLabel } from "@/lib/currency"; import Button from "@/components/Button"; import StatTile from "@/components/StatTile"; import EmptyState from "@/components/EmptyState"; @@ -301,6 +302,7 @@ function ServiceDetailContent() { limit: 50, }); const { data: info, error: infoError, mutate: mutateInfo } = useInfo(); + const chain = info?.chain; const { data: stats } = useStats(); const [editingPrice, setEditingPrice] = useState(false); @@ -337,7 +339,7 @@ function ServiceDetailContent() { setSaving(true); try { await updateService(decodedName, { price }); - toast(`Price updated to ${price} sats`); + toast(`Price updated to ${formatAmount(price, chain).value} ${unitLabel(chain)}`); } catch (e: unknown) { toast(e instanceof Error ? e.message : "Failed to update price", "error"); } @@ -609,14 +611,14 @@ function ServiceDetailContent() { @@ -686,7 +688,7 @@ function ServiceDetailContent() { /> ) : ( - {svc.price.toLocaleString()} sats + {formatAmount(svc.price, chain).value} {unitLabel(chain)} )} @@ -814,7 +816,7 @@ function ServiceDetailContent() { {tx.id} - {tx.price_sats.toLocaleString()} sats + {formatAmount(tx.price_sats, chain).value} {unitLabel(chain)} diff --git a/dashboard/app/services/page.tsx b/dashboard/app/services/page.tsx index 26643b2c..e7651af5 100644 --- a/dashboard/app/services/page.tsx +++ b/dashboard/app/services/page.tsx @@ -13,6 +13,7 @@ import { toast } from "@/components/Toast"; import type { ServiceCreateRequest, AuthScheme } from "@/lib/types"; import { authSchemeLabels } from "@/lib/types"; import { useInfo } from "@/lib/api"; +import { formatAmount, unitLabel, baseUnitLabel } from "@/lib/currency"; import Button from "@/components/Button"; import PageHeader from "@/components/PageHeader"; import EmptyState from "@/components/EmptyState"; @@ -239,6 +240,8 @@ export default function ServicesPage() { error: servicesError, mutate: refreshServices, } = useServices(); + const { data: info } = useInfo(); + const chain = info?.chain; const { sorted, sortField, sortDir, onSort } = useSort(services, "name"); const [editingPrice, setEditingPrice] = useState(null); const [priceValue, setPriceValue] = useState(""); @@ -254,7 +257,7 @@ export default function ServicesPage() { setSaving(true); try { await updateService(name, { price }); - toast(`Price updated to ${price} sats`); + toast(`Price updated to ${formatAmount(price, chain).value} ${unitLabel(chain)}`); } catch (e: unknown) { toast( e instanceof Error ? e.message : "Failed to update price", @@ -435,8 +438,8 @@ export default function ServicesPage() {
- {svc.price.toLocaleString()} sats + {formatAmount(svc.price, chain).value} {unitLabel(chain)} )} diff --git a/dashboard/app/transactions/page.tsx b/dashboard/app/transactions/page.tsx index cafb5880..1e8a14ab 100644 --- a/dashboard/app/transactions/page.tsx +++ b/dashboard/app/transactions/page.tsx @@ -2,7 +2,8 @@ import { Fragment, useState, useCallback, useMemo } from "react"; import { useRouter } from "next/navigation"; -import { useTransactions } from "@/lib/api"; +import { useTransactions, useInfo } from "@/lib/api"; +import { formatAmount, unitLabel } from "@/lib/currency"; import styled from "@emotion/styled"; import type { TransactionParams } from "@/lib/types"; import ActivityChart from "@/components/ActivityChart"; @@ -277,6 +278,8 @@ export default function TransactionsPage() { error, mutate, } = useTransactions(params); + const { data: info } = useInfo(); + const chain = info?.chain; const { sorted, sortField, sortDir, onSort } = useSort( transactions, "id", @@ -316,7 +319,7 @@ export default function TransactionsPage() { const headers = [ "ID", "Service", - "Amount (sats)", + `Amount (${unitLabel(chain)})`, "State", "Payment Hash", "Created", @@ -411,7 +414,7 @@ export default function TransactionsPage() { Activity {transactions ? ( - + ) : ( Loading... )} @@ -531,7 +534,7 @@ export default function TransactionsPage() { {tx.id} {tx.service_name} - {tx.price_sats.toLocaleString()} sats + {formatAmount(tx.price_sats, chain).value} {unitLabel(chain)} diff --git a/dashboard/components/ActivityChart.tsx b/dashboard/components/ActivityChart.tsx index 0dc520ce..864c79c3 100644 --- a/dashboard/components/ActivityChart.tsx +++ b/dashboard/components/ActivityChart.tsx @@ -11,6 +11,7 @@ import { bisector } from "d3-array"; import { useTooltip, useTooltipInPortal } from "@visx/tooltip"; import { theme } from "@/lib/theme"; import type { Transaction } from "@/lib/types"; +import { formatAmount, unitLabel } from "@/lib/currency"; interface Bucket { time: Date; @@ -66,10 +67,12 @@ function Chart({ data, width, height, + chain, }: { data: Bucket[]; width: number; height: number; + chain?: string; }) { const { showTooltip, @@ -263,7 +266,7 @@ function Chart({ })}
- {tooltipData.sats.toLocaleString()} sats + {formatAmount(tooltipData.sats, chain).value} {unitLabel(chain)}
)} @@ -273,9 +276,10 @@ function Chart({ interface Props { transactions: Transaction[]; + chain?: string; } -export default function ActivityChart({ transactions }: Props) { +export default function ActivityChart({ transactions, chain }: Props) { const data = bucketTransactions(transactions); if (data.length === 0) return null; @@ -285,7 +289,7 @@ export default function ActivityChart({ transactions }: Props) { {({ width, height }) => width > 0 && height > 0 ? ( - + ) : null } diff --git a/dashboard/components/RevenueChart.tsx b/dashboard/components/RevenueChart.tsx index 4eecbd7a..8d1490e6 100644 --- a/dashboard/components/RevenueChart.tsx +++ b/dashboard/components/RevenueChart.tsx @@ -10,17 +10,20 @@ import { LinearGradient } from "@visx/gradient"; import { useTooltip, useTooltipInPortal } from "@visx/tooltip"; import { theme } from "@/lib/theme"; import type { ServiceRevenueItem } from "@/lib/types"; +import { formatAmount, unitLabel } from "@/lib/currency"; const margin = { top: 10, right: 40, bottom: 30, left: 120 }; interface Props { data: ServiceRevenueItem[]; + chain?: string; } function Chart({ data, width, height, + chain, }: Props & { width: number; height: number }) { const { showTooltip, @@ -122,7 +125,7 @@ function Chart({ fontFamily: "Open Sans, sans-serif", textAnchor: "middle", }} - label="sats" + label={unitLabel(chain)} labelProps={{ fill: "#848a99", fontSize: 11, @@ -151,16 +154,16 @@ function Chart({ }} > - {tooltipData.total_revenue_sats.toLocaleString()} + {formatAmount(tooltipData.total_revenue_sats, chain).value} {" "} - sats + {unitLabel(chain)} )} ); } -export default function RevenueChart({ data }: Props) { +export default function RevenueChart({ data, chain }: Props) { if (!data.length) { return (
{({ width, height }) => width > 0 && height > 0 ? ( - + ) : null } diff --git a/dashboard/lib/currency.ts b/dashboard/lib/currency.ts new file mode 100644 index 00000000..22c8f71a --- /dev/null +++ b/dashboard/lib/currency.ts @@ -0,0 +1,107 @@ +/** + * Chain-aware currency formatting. + * + * Prism stores all amounts in lnd's native base unit (an int64). On + * bitcoin-backed lnd that unit is the satoshi (1 BTC = 10^8 sats). On + * the Sui-adapted lnd, the adapter maps `btcutil.Amount` (int64) to + * MIST (1 SUI = 10^9 MIST). So the same numeric column in the admin + * API means different currencies depending on the `chain` field from + * admin GetInfo. + * + * Display policy: + * - bitcoin: show the raw integer + "sats" (native unit is already + * small enough for readable micropayments). + * - sui: convert MIST → SUI with up to 9 decimals, strip trailing + * zeros, and render as " SUI" (MIST is too small for a human + * label, SUI reads naturally since SUI is not a high-value coin). + * - unknown / empty chain: fall back to "sats" (bitcoin default). + */ + +export type ChainKind = "bitcoin" | "sui" | "unknown"; + +const MIST_PER_SUI = 1_000_000_000; + +/** Normalize the raw chain string from admin GetInfo. */ +export function chainKind(chain?: string | null): ChainKind { + switch ((chain || "").toLowerCase()) { + case "sui": + return "sui"; + case "bitcoin": + case "btc": + return "bitcoin"; + default: + return "unknown"; + } +} + +/** Short unit label for DISPLAY contexts (already scaled by formatAmount): + * - bitcoin → "sats" + * - sui → "SUI" (display is in SUI, not MIST) + */ +export function unitLabel(chain?: string | null): string { + return chainKind(chain) === "sui" ? "SUI" : "sats"; +} + +/** Base-unit label for FORM inputs, where the user types a raw integer: + * - bitcoin → "sats" (1 sat = 10^-8 BTC; the raw stored integer) + * - sui → "MIST" (1 MIST = 10^-9 SUI; the raw stored integer) + * Keep the form honest — show what the number actually represents, so + * there's no hidden 10^9 factor. Display contexts elsewhere scale into + * SUI for readability. + */ +export function baseUnitLabel(chain?: string | null): string { + return chainKind(chain) === "sui" ? "MIST" : "sats"; +} + +/** + * Format a raw base-unit amount for display. Accepts number or string + * (admin API returns sats/mist as strings because the proto type is + * int64). Returns value + unit split so the caller can style them + * independently (e.g. grey-out the unit suffix in a KPI card). + */ +export function formatAmount( + amount: number | string | null | undefined, + chain?: string | null, +): { value: string; unit: string } { + const raw = toNumber(amount); + const unit = unitLabel(chain); + + if (chainKind(chain) !== "sui") { + return { value: formatInt(raw), unit }; + } + + // MIST → SUI, up to 9 decimals, trimmed. + const sui = raw / MIST_PER_SUI; + return { value: formatSui(sui), unit }; +} + +/** Convenience one-liner: "100 sats" or "0.000001 SUI". */ +export function formatAmountString( + amount: number | string | null | undefined, + chain?: string | null, +): string { + const { value, unit } = formatAmount(amount, chain); + return `${value} ${unit}`; +} + +function toNumber(v: number | string | null | undefined): number { + if (typeof v === "number") return Number.isFinite(v) ? v : 0; + if (typeof v === "string") { + const n = Number(v); + return Number.isFinite(n) ? n : 0; + } + return 0; +} + +function formatInt(n: number): string { + return Math.round(n).toLocaleString("en-US"); +} + +function formatSui(n: number): string { + if (n === 0) return "0"; + // Up to 9 decimals, trim trailing zeros. Avoid scientific notation + // for very small amounts by using toFixed first. + const fixed = n.toFixed(9); + const trimmed = fixed.replace(/\.?0+$/, ""); + return trimmed === "" || trimmed === "-" ? "0" : trimmed; +} diff --git a/dashboard/lib/types.ts b/dashboard/lib/types.ts index 48366ef1..85772672 100644 --- a/dashboard/lib/types.ts +++ b/dashboard/lib/types.ts @@ -69,4 +69,8 @@ export interface InfoResponse { mpp_enabled: boolean; sessions_enabled: boolean; mpp_realm: string; + /** Blockchain the connected lnd is on (e.g. "bitcoin", "sui"). May be + * empty if lnd was unreachable at prism startup. Drives the unit + * label shown in the UI (SUI vs sats). */ + chain?: string; } From 25ec3f8e39e04303f99e1fade7fc23373d7900f4 Mon Sep 17 00:00:00 2001 From: ai-chen2050 <1033467071@qq.com> Date: Thu, 23 Apr 2026 15:53:05 +0800 Subject: [PATCH 08/32] docs,test: size default prices for Sui micropayments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump sample config prices from sat-scale (0/1/1000) to MIST-scale (10^6–10^7) so the defaults are meaningful on Sui where 1 MIST is 10^-9 SUI. Comment each price with the SUI equivalent so a reader can tell what the integer represents without doing the math. sample-conf.yaml service1 price: 0 → 10_000_000 MIST (0.01 SUI) service2 price: 1 → 1_000_000 MIST (0.001 SUI) mixed-api price: 1000 → 10_000_000 MIST (0.01 SUI) scripts/itest_prism_sui.sh itest-service price: 10 → 10_000_000 MIST Also reword the price comment on service1 from "value in satoshis" to an explicit chain-base-unit explanation (sats for bitcoin, MIST for sui) so the YAML is self-documenting after the chain-aware display change. Sizing rationale: at ~$1/SUI, 0.01 SUI is roughly $0.01 per request — a reasonable micropayment default. Users can scale up/down per service. Co-Authored-By: Claude Opus 4.7 (1M context) --- sample-conf.yaml | 20 ++++++++++++++++---- scripts/itest_prism_sui.sh | 7 ++++++- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/sample-conf.yaml b/sample-conf.yaml index b7016d17..24db5126 100644 --- a/sample-conf.yaml +++ b/sample-conf.yaml @@ -238,9 +238,18 @@ services: # 31557600 = 1 year. timeout: 31557600 - # The L402 value in satoshis for the service. It is ignored if + # Price in the connected chain's base unit: + # - bitcoin: satoshis (1 sat = 10^-8 BTC) + # - sui: MIST (1 MIST = 10^-9 SUI; 1 SUI = 1_000_000_000 MIST) + # The dashboard scales for display (shows SUI or sats depending on + # the chain advertised by the connected lnd). Ignored if # dynamicprice.enabled is set to true. - price: 0 + # + # Examples: + # 1_000_000 → 0.001 SUI (~$0.001 at $1/SUI) or 10^6 sats + # 10_000_000 → 0.01 SUI (~$0.01 per request) + # 100_000_000 → 0.1 SUI + price: 10000000 # A list of regular expressions for path that are free of charge. authwhitelistpaths: @@ -299,7 +308,8 @@ services: protocol: https constraints: "valid_until": "2020-01-01" - price: 1 + # 1_000_000 MIST = 0.001 SUI (cheap per-call endpoint). + price: 1000000 - name: "service3" hostregexp: "service3.com:8083" @@ -323,7 +333,9 @@ services: address: "127.0.0.1:9999" protocol: http timeout: 31557600 # L402 token valid for 1 year - price: 1000 # default price for paid paths, in satoshis + # 10_000_000 MIST = 0.01 SUI (reasonable micropayment on Sui at ~$1). + # On bitcoin this would be 10^7 sats; tune per chain. + price: 10000000 # Free paths — no L402 challenge, request is proxied straight through. # Use for health checks, docs, and any public read-only endpoint. diff --git a/scripts/itest_prism_sui.sh b/scripts/itest_prism_sui.sh index 5b0a606f..54b9e410 100755 --- a/scripts/itest_prism_sui.sh +++ b/scripts/itest_prism_sui.sh @@ -125,7 +125,12 @@ services: address: "127.0.0.1:9999" protocol: http timeout: 3600 - price: 10 + # 10_000_000 MIST = 0.01 SUI; sized so the dashboard / admin API + # show a human-readable value instead of nano-units. On a bitcoin + # backend this would be 10^7 sats (~$9 at $90k/BTC), so tune before + # running against mainnet. Itests use regtest / sui devnet so value + # doesn't matter economically. + price: 10000000 hashmail: enabled: false From b375c6300320ca03b72b192b8ce3cdb78ed154f9 Mon Sep 17 00:00:00 2001 From: ai-chen2050 <1033467071@qq.com> Date: Thu, 23 Apr 2026 16:10:15 +0800 Subject: [PATCH 09/32] test: add manual_pay_mpp.sh for the Payment HTTP Auth (MPP) flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sibling to manual_pay_through_prism.sh, but exercises the MPP scheme (draft-httpauth-payment-00) instead of L402. Useful when prism is configured with `authenticator.enablempp: true` and a service whose `authscheme` is `mpp` or `l402+mpp` — the script: 1. Fires an unauthenticated request and verifies that the 402 response now carries three WWW-Authenticate challenges (LSAT, L402, Payment). 2. Parses the `Payment` challenge's auth-params, base64url-decodes the `request=` field to extract the BOLT11 invoice and payment hash. 3. Pays the MPP invoice from bob (second lnd node). 4. Builds the Authorization credential: echoes all challenge params unchanged and attaches a `payload.preimage` proof, base64url- encodes the JSON envelope, and sends it as `Authorization: Payment `. 5. Verifies prism accepts the credential (HTTP 200) and returns a `Payment-Receipt` header, which the script decodes to show status / method / reference / challengeId. Preflight step auto-flips the target service's `auth_scheme` to `AUTH_SCHEME_L402_MPP` via the admin REST API if it isn't already set, so the test is idempotent against whatever current state the service store is in. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/manual_pay_mpp.sh | 299 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 299 insertions(+) create mode 100755 scripts/manual_pay_mpp.sh diff --git a/scripts/manual_pay_mpp.sh b/scripts/manual_pay_mpp.sh new file mode 100755 index 00000000..7713b629 --- /dev/null +++ b/scripts/manual_pay_mpp.sh @@ -0,0 +1,299 @@ +#!/bin/bash +# manual_pay_mpp.sh +# +# Exercises the Payment HTTP Authentication (MPP) flow end-to-end through +# a running Prism on :8080. Sibling to manual_pay_through_prism.sh which +# does the L402 flow. Assumes both L402 and MPP are enabled (config has +# `authenticator.enablempp: true`) and the target service uses +# `authscheme: "l402+mpp"` (or `"mpp"`). +# +# Flow: +# 1. Unauthenticated request → 402 with *three* WWW-Authenticate headers +# (LSAT, L402, Payment). Parse the Payment one. +# 2. Decode the `request` field (base64url JSON) to extract the BOLT11 +# invoice, payment hash, and echo parameters. +# 3. Pay the MPP invoice from bob. +# 4. Build a Payment credential: echo all challenge params + payload +# { "preimage": "" }, base64url-encode without padding, send as +# `Authorization: Payment `. +# 5. Verify prism accepts the credential and forwards to the backend. +# +# Prereqs identical to manual_pay_through_prism.sh: +# * Prism on :8080 with MPP enabled +# * Alice LND on :10009, Bob LND on :10010, open alice↔bob channel +# * Demo backend on :9998 (./scripts/serve_demo_backend.sh) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +PRISM_HOST="${PRISM_HOST:-127.0.0.1:8080}" +PRISM_BASEDIR="${PRISM_BASEDIR:-$REPO_DIR/.prism}" +SERVICE_HOST="${SERVICE_HOST:-service1.com}" +PATH_SUFFIX="${PATH_SUFFIX:-/probe}" + +NETWORK="${NETWORK:-devnet}" +ALICE_DIR="${ALICE_DIR:-/tmp/lnd-sui-test/alice}" +BOB_DIR="${BOB_DIR:-/tmp/lnd-sui-test/bob}" +ALICE_RPC="${ALICE_RPC:-127.0.0.1:10009}" +BOB_RPC="${BOB_RPC:-127.0.0.1:10010}" +LND_REPO="${LND_REPO:-/Users/blake/work/nagara/code/chain/loka-payment/lnd}" +LNCLI="${LNCLI:-$LND_REPO/lncli-debug}" + +if [ -t 1 ]; then + G=$'\033[32m'; R=$'\033[31m'; Y=$'\033[33m'; C=$'\033[36m'; N=$'\033[0m' +else + G=""; R=""; Y=""; C=""; N="" +fi +step() { echo; echo "${C}━━━ $* ━━━${N}"; } +pass() { echo " ${G}✓${N} $*"; } +warn() { echo " ${Y}~${N} $*"; } +fail() { echo " ${R}✗${N} $*"; exit 1; } + +alice_cli() { + "$LNCLI" --lnddir="$ALICE_DIR" --rpcserver="$ALICE_RPC" \ + --macaroonpath="$ALICE_DIR/data/chain/sui/$NETWORK/admin.macaroon" \ + "$@" +} +bob_cli() { + "$LNCLI" --lnddir="$BOB_DIR" --rpcserver="$BOB_RPC" \ + --macaroonpath="$BOB_DIR/data/chain/sui/$NETWORK/admin.macaroon" \ + "$@" +} + +# base64url encode without padding (RFC 4648 §5), reading from stdin. +b64url_enc() { + python3 -c ' +import base64, sys +sys.stdout.write(base64.urlsafe_b64encode(sys.stdin.buffer.read()).decode().rstrip("=")) +' +} +# base64url decode (accepts with or without padding). +b64url_dec() { + python3 -c ' +import base64, sys +s = sys.stdin.read().strip() +pad = "=" * (-len(s) % 4) +sys.stdout.buffer.write(base64.urlsafe_b64decode(s + pad)) +' +} + +# --- 1. Preflight --------------------------------------------------------- + +step "[1/8] Preflight" + +for dep in curl jq xxd nc python3; do + command -v "$dep" >/dev/null 2>&1 || fail "missing dependency: $dep" +done +[ -x "$LNCLI" ] || fail "lncli not found at $LNCLI" + +nc -z "${PRISM_HOST%:*}" "${PRISM_HOST##*:}" \ + || fail "prism not listening on $PRISM_HOST" +nc -z "${ALICE_RPC%:*}" "${ALICE_RPC##*:}" \ + || fail "alice lnd not reachable at $ALICE_RPC" +nc -z "${BOB_RPC%:*}" "${BOB_RPC##*:}" \ + || fail "bob lnd not reachable at $BOB_RPC" + +ADMIN_MAC="$PRISM_BASEDIR/admin.macaroon" +[ -f "$ADMIN_MAC" ] || fail "admin macaroon not found at $ADMIN_MAC" +ADMIN_MAC_HEX=$(xxd -ps -c 10000 "$ADMIN_MAC") + +# Verify MPP is actually enabled. +info=$(curl -sk -H "Grpc-Metadata-Macaroon: $ADMIN_MAC_HEX" \ + "https://$PRISM_HOST/api/admin/info") +mpp_on=$(echo "$info" | jq -r '.mpp_enabled // false') +[ "$mpp_on" = "true" ] \ + || fail "admin /info reports mpp_enabled=false — set authenticator.enablempp: true and restart prism" +pass "prism reachable, MPP enabled (realm=$(echo "$info" | jq -r '.mpp_realm'))" + +# Verify the target service accepts MPP. +svc=$(curl -sk -H "Grpc-Metadata-Macaroon: $ADMIN_MAC_HEX" \ + "https://$PRISM_HOST/api/admin/services" \ + | jq -c --arg h "$SERVICE_HOST" \ + '.services[] | select(.host_regexp | test($h; "x") | not | not)') +scheme=$(echo "$svc" | jq -r '.auth_scheme // empty') +case "$scheme" in + AUTH_SCHEME_MPP|AUTH_SCHEME_L402_MPP) ;; + *) + warn "service for host $SERVICE_HOST has auth_scheme=$scheme" + warn "flipping it to AUTH_SCHEME_L402_MPP so this test can proceed" + svc_name=$(echo "$svc" | jq -r '.name') + curl -sk -X PUT \ + -H "Grpc-Metadata-Macaroon: $ADMIN_MAC_HEX" \ + -H "Content-Type: application/json" \ + -d '{"auth_scheme":"AUTH_SCHEME_L402_MPP"}' \ + "https://$PRISM_HOST/api/admin/services/$svc_name" \ + >/dev/null + pass "$svc_name → AUTH_SCHEME_L402_MPP" + ;; +esac + +# --- 2. Unauthenticated request (expect 402 with Payment challenge) ----- + +step "[2/8] Unauthenticated GET $PATH_SUFFIX (Host: $SERVICE_HOST)" + +HDR=$(mktemp); BODY=$(mktemp) +trap 'rm -f "$HDR" "$BODY"' EXIT + +code=$(curl -sk -o "$BODY" -D "$HDR" -w '%{http_code}' \ + -H "Host: $SERVICE_HOST" \ + "https://$PRISM_HOST$PATH_SUFFIX") +[ "$code" = "402" ] || fail "expected 402, got $code. body: $(head -c 200 "$BODY")" +pass "prism challenged with 402" + +# Count the WWW-Authenticate headers. +wwa_count=$(grep -ci '^www-authenticate:' "$HDR" || true) +pass "$wwa_count WWW-Authenticate challenge(s) present" + +# --- 3. Parse the Payment challenge ------------------------------------- + +step "[3/8] Parse Payment challenge" + +# The header is multi-line (curl folds long headers), so we need to match +# the whole line starting with "www-authenticate: Payment " and all its +# continuations. Easiest: re-dump curl with -sS and match on the raw. +pay_line=$(awk ' + /^[Ww]{3}-[Aa]uthenticate: Payment /{buf=$0; next} + buf && /^[ \t]/{buf=buf $0; next} + buf{print buf; buf=""} + END{if (buf) print buf} +' "$HDR") +[ -n "$pay_line" ] || fail "no Payment WWW-Authenticate header found" + +extract_auth_param() { + # extract_auth_param → value (without surrounding quotes) + echo "$1" | sed -n "s/.*$2=\"\\([^\"]*\\)\".*/\\1/p" +} + +chal_id=$(extract_auth_param "$pay_line" "id") +chal_realm=$(extract_auth_param "$pay_line" "realm") +chal_method=$(extract_auth_param "$pay_line" "method") +chal_intent=$(extract_auth_param "$pay_line" "intent") +chal_request=$(extract_auth_param "$pay_line" "request") +chal_expires=$(extract_auth_param "$pay_line" "expires") + +for v in "$chal_id" "$chal_realm" "$chal_method" "$chal_intent" "$chal_request"; do + [ -n "$v" ] || fail "failed to parse challenge fields from: $pay_line" +done + +echo " id: $chal_id" +echo " realm: $chal_realm" +echo " method: $chal_method" +echo " intent: $chal_intent" +echo " expires: $chal_expires" + +# Decode the request field (base64url JSON). +req_json=$(echo "$chal_request" | b64url_dec) +echo "$req_json" | jq . | sed 's/^/ /' + +invoice=$(echo "$req_json" | jq -r '.methodDetails.invoice') +phash=$(echo "$req_json" | jq -r '.methodDetails.paymentHash') +amount=$(echo "$req_json" | jq -r '.amount') +pass "MPP invoice: ${invoice:0:40}..." +pass "payment_hash: $phash" +pass "amount: $amount base units" + +# --- 4. Pay invoice with bob -------------------------------------------- + +step "[4/8] Pay with bob" + +pay_json=$(bob_cli payinvoice --force --json "$invoice" 2>&1) || { + echo "$pay_json" | sed 's/^/ /' + fail "bob payinvoice failed" +} +final=$(echo "$pay_json" | jq -s '.[-1]' 2>/dev/null) || final="$pay_json" +preimage=$(echo "$final" | jq -r '.payment_preimage // empty') +status=$(echo "$final" | jq -r '.status // empty') + +[ -n "$preimage" ] && [ "$preimage" != "null" ] \ + || { echo "$final" | sed 's/^/ /'; fail "no preimage returned"; } + +echo " status: $status" +echo " preimage: $preimage" +pass "invoice settled on lightning" + +# --- 5. Build the Payment credential ------------------------------------ + +step "[5/8] Build Authorization: Payment credential" + +# Echo back *all* challenge params exactly, plus payload with the preimage. +# This is what mpp.ParseCredential expects. +cred_json=$(jq -nc \ + --arg id "$chal_id" \ + --arg realm "$chal_realm" \ + --arg method "$chal_method" \ + --arg intent "$chal_intent" \ + --arg request "$chal_request" \ + --arg expires "$chal_expires" \ + --arg preimage "$preimage" \ + '{ + challenge: ( + {id: $id, realm: $realm, method: $method, intent: $intent, request: $request} + + ( if $expires == "" then {} else {expires: $expires} end ) + ), + payload: {preimage: $preimage} + }') + +echo " credential JSON (pretty, request truncated):" +echo "$cred_json" | jq '{ + challenge: (.challenge | {id, realm, method, intent, expires, request: ((.request // "")[:32] + "...")}), + payload +}' | sed 's/^/ /' + +cred_b64=$(printf '%s' "$cred_json" | b64url_enc) +auth_header="Payment $cred_b64" +pass "Authorization header built (${#cred_b64} b64url chars)" + +# --- 6. Replay with Payment credential ---------------------------------- + +step "[6/8] Replay request with Payment credential" + +sleep 2 # let prism's SubscribeInvoices see settlement + +code2=$(curl -sk -o "$BODY" -D "$HDR" -w '%{http_code}' \ + -H "Host: $SERVICE_HOST" \ + -H "Authorization: $auth_header" \ + "https://$PRISM_HOST$PATH_SUFFIX") + +echo " HTTP $code2" +case "$code2" in + 401|402) echo " body: $(head -c 300 "$BODY")"; fail "auth rejected" ;; + 200|201|204) pass "MPP auth accepted — backend reached" ;; + *) pass "MPP auth accepted (backend returned $code2 — expected for dummy backends)" ;; +esac + +# --- 7. Check for Payment-Receipt header -------------------------------- + +step "[7/8] Look for Payment-Receipt" + +receipt=$(grep -i '^payment-receipt:' "$HDR" | head -1 | tr -d '\r' || true) +if [ -n "$receipt" ]; then + pass "Payment-Receipt header present" + # Format: "payment-receipt: " — no scheme prefix. + rct_b64=$(echo "$receipt" \ + | sed -n 's/^[Pp]ayment-[Rr]eceipt: *\([A-Za-z0-9_-]*\).*/\1/p') + if [ -n "$rct_b64" ]; then + echo " decoded receipt:" + echo "$rct_b64" | b64url_dec | jq . | sed 's/^/ /' \ + || warn "could not decode receipt (invalid base64 or JSON)" + else + warn "could not extract base64 from receipt line" + fi +else + warn "no Payment-Receipt header (server may not issue one for charge intent)" +fi + +# --- 8. Admin API --------------------------------------------------------- + +step "[8/8] admin transactions (filter by service)" + +svc_name=$(echo "$svc" | jq -r '.name') +curl -sk -H "Grpc-Metadata-Macaroon: $ADMIN_MAC_HEX" \ + "https://$PRISM_HOST/api/admin/transactions?limit=3&service=$svc_name" \ + | jq '.transactions // . | .[:3]' | sed 's/^/ /' + +echo +echo "${G}━━━ MPP flow complete ━━━${N}" +echo "Try the L402 flow on the same service:" +echo " ./scripts/manual_pay_through_prism.sh" From f2b8400469fabfaadafa1a6626910a0af66ce11b Mon Sep 17 00:00:00 2001 From: ai-chen2050 <1033467071@qq.com> Date: Thu, 23 Apr 2026 17:20:57 +0800 Subject: [PATCH 10/32] test(mpp): add session walk-through + fix charge-intent challenge picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/manual_pay_mpp_session.sh (new) Drives the MPP session lifecycle end-to-end against a running prism: open (pay 0.2 SUI deposit + return invoice) → bearer × N (drain the session balance, no Lightning calls) → close (prism pays the client's amountless ReturnInvoice with the unspent balance) → verifies the refund actually landed on bob via lookupinvoice, and that a bearer replay after close is rejected. scripts/manual_pay_mpp.sh (fixed) When sessions are enabled alongside the charge intent, the 402 response carries two Payment challenges (intent=charge AND intent=session). The previous header picker grabbed the first one naively and sometimes landed on the session challenge, which has a different request shape (depositInvoice instead of methodDetails. invoice) and caused the test to crash. Now we collect every Payment challenge line and filter to intent="charge" so the test is robust whether sessions are on or not. auth/mpp_session_authenticator.go networkToChainParams silently fell back to MainNetParams for any network name outside the bitcoin set (mainnet/testnet/regtest/ signet). Sui-adapted lnd identifies its networks as "devnet" and "localnet" but still encodes BOLT11 invoices with the bitcoin regtest HRP ("lnbcrt..."), so zpay32 rejected return invoices with "unknown multiplier t" during session open. Map devnet/localnet to RegressionNetParams; add a simnet case for completeness. Without this fix, MPP sessions could not be opened on any Sui deployment. Co-Authored-By: Claude Opus 4.7 (1M context) --- auth/mpp_session_authenticator.go | 10 +- scripts/manual_pay_mpp.sh | 15 +- scripts/manual_pay_mpp_session.sh | 294 ++++++++++++++++++++++++++++++ 3 files changed, 312 insertions(+), 7 deletions(-) create mode 100755 scripts/manual_pay_mpp_session.sh diff --git a/auth/mpp_session_authenticator.go b/auth/mpp_session_authenticator.go index 0a6fcc5d..88661ae0 100644 --- a/auth/mpp_session_authenticator.go +++ b/auth/mpp_session_authenticator.go @@ -800,14 +800,22 @@ func (a *MPPSessionAuthenticator) ReceiptHeader(header *http.Header, // networkToChainParams converts a network name string to the corresponding // btcd chain parameters for BOLT11 invoice decoding. +// +// Sui-adapted lnd exposes non-Bitcoin network identifiers ("devnet", +// "localnet") but still emits BOLT11 invoices with the Bitcoin regtest +// HRP ("lnbcrt...") because the underlying invoice encoder is reused +// unchanged. Map those identifiers to RegressionNetParams so zpay32 +// can decode the return invoice clients send back on session open. func networkToChainParams(network string) *chaincfg.Params { switch network { case "mainnet": return &chaincfg.MainNetParams case "testnet": return &chaincfg.TestNet3Params - case "regtest": + case "regtest", "devnet", "localnet": return &chaincfg.RegressionNetParams + case "simnet": + return &chaincfg.SimNetParams case "signet": return &chaincfg.SigNetParams default: diff --git a/scripts/manual_pay_mpp.sh b/scripts/manual_pay_mpp.sh index 7713b629..b03859c6 100755 --- a/scripts/manual_pay_mpp.sh +++ b/scripts/manual_pay_mpp.sh @@ -150,16 +150,19 @@ pass "$wwa_count WWW-Authenticate challenge(s) present" step "[3/8] Parse Payment challenge" -# The header is multi-line (curl folds long headers), so we need to match -# the whole line starting with "www-authenticate: Payment " and all its -# continuations. Easiest: re-dump curl with -sS and match on the raw. +# The header is multi-line (curl folds long headers). Collect *all* lines +# starting with "www-authenticate: Payment " plus their continuations, +# then keep only the one with intent="charge" — when MPP sessions are +# enabled, prism returns both a charge-intent AND a session-intent +# challenge; picking the first one naively lands on the wrong scheme. pay_line=$(awk ' - /^[Ww]{3}-[Aa]uthenticate: Payment /{buf=$0; next} + /^[Ww]{3}-[Aa]uthenticate: Payment /{if (buf) print buf; buf=$0; next} buf && /^[ \t]/{buf=buf $0; next} buf{print buf; buf=""} END{if (buf) print buf} -' "$HDR") -[ -n "$pay_line" ] || fail "no Payment WWW-Authenticate header found" +' "$HDR" | grep 'intent="charge"' | head -1) + +[ -n "$pay_line" ] || fail "no Payment WWW-Authenticate challenge with intent=charge found — did the target service opt out of MPP, or is only session intent active?" extract_auth_param() { # extract_auth_param → value (without surrounding quotes) diff --git a/scripts/manual_pay_mpp_session.sh b/scripts/manual_pay_mpp_session.sh new file mode 100755 index 00000000..6c42efcb --- /dev/null +++ b/scripts/manual_pay_mpp_session.sh @@ -0,0 +1,294 @@ +#!/bin/bash +# manual_pay_mpp_session.sh +# +# Walks all four MPP session actions end-to-end against a running Prism: +# open → pay a deposit invoice, receive a session id (no fresh +# charge per request afterwards, balance is debited instead). +# bearer → two authenticated requests that silently draw down the +# balance; no new Lightning payment is needed. +# topUp → pay a second deposit invoice to extend the session. +# close → terminate the session; server pays the client's amountless +# ReturnInvoice with the leftover balance, then issues a +# SessionReceipt that reports refundSats / refundStatus. +# +# Requires prism started with: +# authenticator.enablempp: true +# authenticator.enablesessions: true +# and the target service set to `authscheme: "l402+mpp"` or `"mpp"`. +# The per-service deposit is (price * sessiondepositmultiplier); with +# the default multiplier of 20 and price=10_000_000 MIST you pay 0.2 +# SUI up front and can burn through it across ~20 requests. +# +# Sibling to manual_pay_mpp.sh (one-shot charge intent). + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +PRISM_HOST="${PRISM_HOST:-127.0.0.1:8080}" +PRISM_BASEDIR="${PRISM_BASEDIR:-$REPO_DIR/.prism}" +SERVICE_HOST="${SERVICE_HOST:-service1.com}" +PATH_SUFFIX="${PATH_SUFFIX:-/probe}" + +NETWORK="${NETWORK:-devnet}" +ALICE_DIR="${ALICE_DIR:-/tmp/lnd-sui-test/alice}" +BOB_DIR="${BOB_DIR:-/tmp/lnd-sui-test/bob}" +ALICE_RPC="${ALICE_RPC:-127.0.0.1:10009}" +BOB_RPC="${BOB_RPC:-127.0.0.1:10010}" +LND_REPO="${LND_REPO:-/Users/blake/work/nagara/code/chain/loka-payment/lnd}" +LNCLI="${LNCLI:-$LND_REPO/lncli-debug}" + +# Number of bearer calls to make between open and close. +BEARER_CALLS="${BEARER_CALLS:-2}" + +if [ -t 1 ]; then + G=$'\033[32m'; R=$'\033[31m'; Y=$'\033[33m'; C=$'\033[36m'; B=$'\033[34m'; N=$'\033[0m' +else + G=""; R=""; Y=""; C=""; B=""; N="" +fi +step() { echo; echo "${C}━━━ $* ━━━${N}"; } +pass() { echo " ${G}✓${N} $*"; } +warn() { echo " ${Y}~${N} $*"; } +fail() { echo " ${R}✗${N} $*"; exit 1; } +info() { echo " ${B}→${N} $*"; } + +alice_cli() { + "$LNCLI" --lnddir="$ALICE_DIR" --rpcserver="$ALICE_RPC" \ + --macaroonpath="$ALICE_DIR/data/chain/sui/$NETWORK/admin.macaroon" \ + "$@" +} +bob_cli() { + "$LNCLI" --lnddir="$BOB_DIR" --rpcserver="$BOB_RPC" \ + --macaroonpath="$BOB_DIR/data/chain/sui/$NETWORK/admin.macaroon" \ + "$@" +} + +b64url_enc() { + python3 -c 'import base64,sys; sys.stdout.write(base64.urlsafe_b64encode(sys.stdin.buffer.read()).decode().rstrip("="))' +} +b64url_dec() { + python3 -c 'import base64,sys; s=sys.stdin.read().strip(); pad="="*(-len(s)%4); sys.stdout.buffer.write(base64.urlsafe_b64decode(s+pad))' +} + +# Pick the Payment challenge with the given intent (session or charge) +# from a response header file and emit: idrealmmethodintentrequestexpires +pick_challenge() { + local hdr="$1" want_intent="$2" + awk ' + /^[Ww]{3}-[Aa]uthenticate: Payment /{buf=$0; next} + buf && /^[ \t]/{buf=buf $0; next} + buf{print buf; buf=""} + END{if (buf) print buf} + ' "$hdr" | while read -r line; do + intent=$(echo "$line" | sed -n 's/.*intent="\([^"]*\)".*/\1/p') + if [ "$intent" = "$want_intent" ]; then + id=$(echo "$line" | sed -n 's/.*id="\([^"]*\)".*/\1/p') + realm=$(echo "$line" | sed -n 's/.*realm="\([^"]*\)".*/\1/p') + method=$(echo "$line" | sed -n 's/.*method="\([^"]*\)".*/\1/p') + request=$(echo "$line" | sed -n 's/.*request="\([^"]*\)".*/\1/p') + expires=$(echo "$line" | sed -n 's/.*expires="\([^"]*\)".*/\1/p') + printf '%s\t%s\t%s\t%s\t%s\t%s\n' \ + "$id" "$realm" "$method" "$intent" "$request" "$expires" + return + fi + done +} + +# Build a Payment credential envelope: base64url(JSON({challenge, payload})) +build_cred() { + local id="$1" realm="$2" method="$3" intent="$4" request="$5" expires="$6" payload="$7" + jq -nc \ + --arg id "$id" --arg realm "$realm" --arg method "$method" \ + --arg intent "$intent" --arg request "$request" --arg expires "$expires" \ + --argjson payload "$payload" \ + '{ + challenge: ( + {id: $id, realm: $realm, method: $method, intent: $intent, request: $request} + + ( if $expires == "" then {} else {expires: $expires} end ) + ), + payload: $payload + }' | b64url_enc +} + +HDR=$(mktemp); BODY=$(mktemp); HDR2=$(mktemp); BODY2=$(mktemp) +trap 'rm -f "$HDR" "$BODY" "$HDR2" "$BODY2"' EXIT + +# --- 1. Preflight ------------------------------------------------------- + +step "[1/7] Preflight" + +for dep in curl jq xxd nc python3; do + command -v "$dep" >/dev/null 2>&1 || fail "missing dependency: $dep" +done +[ -x "$LNCLI" ] || fail "lncli not found at $LNCLI" + +ADMIN_MAC="$PRISM_BASEDIR/admin.macaroon" +[ -f "$ADMIN_MAC" ] || fail "admin macaroon not found at $ADMIN_MAC" +ADMIN_MAC_HEX=$(xxd -ps -c 10000 "$ADMIN_MAC") + +info=$(curl -sk -H "Grpc-Metadata-Macaroon: $ADMIN_MAC_HEX" \ + "https://$PRISM_HOST/api/admin/info") +[ "$(echo "$info" | jq -r '.mpp_enabled')" = "true" ] \ + || fail "MPP not enabled — set authenticator.enablempp: true" +[ "$(echo "$info" | jq -r '.sessions_enabled')" = "true" ] \ + || fail "MPP sessions not enabled — set authenticator.enablesessions: true and restart" +pass "MPP + sessions enabled (realm=$(echo "$info" | jq -r '.mpp_realm'))" + +# --- 2. Trigger a 402 and isolate the session challenge ---------------- + +step "[2/7] Fetch session challenge" + +code=$(curl -sk -o "$BODY" -D "$HDR" -w '%{http_code}' \ + -H "Host: $SERVICE_HOST" "https://$PRISM_HOST$PATH_SUFFIX") +[ "$code" = "402" ] || fail "expected 402, got $code" +pass "prism challenged with 402" + +chal=$(pick_challenge "$HDR" "session") +[ -n "$chal" ] \ + || fail "no intent=session challenge found. Is sessions_enabled=true? headers: $(grep -i authenticate "$HDR")" + +IFS=$'\t' read -r SESS_ID SESS_REALM SESS_METHOD SESS_INTENT SESS_REQ SESS_EXPIRES <<<"$chal" +echo " id: $SESS_ID" +echo " intent: $SESS_INTENT" +echo " expires: $SESS_EXPIRES" + +req_json=$(echo "$SESS_REQ" | b64url_dec) +deposit_invoice=$(echo "$req_json" | jq -r '.depositInvoice') +deposit_phash=$(echo "$req_json" | jq -r '.paymentHash') +deposit_amount=$(echo "$req_json" | jq -r '.depositAmount') +per_unit_amount=$(echo "$req_json" | jq -r '.amount') +idle_timeout=$(echo "$req_json" | jq -r '.idleTimeout') + +info "per-request amount: $per_unit_amount (= $(python3 -c "print($per_unit_amount/1e9)") SUI)" +info "deposit amount: $deposit_amount (= $(python3 -c "print($deposit_amount/1e9)") SUI)" +info "idle timeout: ${idle_timeout}s" +info "deposit paymentHash: $deposit_phash" + +# --- 3. Bob pays the deposit invoice, builds ReturnInvoice, OPEN ----- + +step "[3/7] Action: open (pay deposit + send returnInvoice)" + +# Bob creates an amountless BOLT11 invoice for future refund. +return_payreq=$(bob_cli addinvoice --amt 0 --memo "MPP session refund" \ + --expiry $((idle_timeout + 3600)) 2>&1 | jq -r '.payment_request') +[ -n "$return_payreq" ] && [ "$return_payreq" != "null" ] \ + || fail "bob could not create amountless refund invoice" +info "bob refund invoice: ${return_payreq:0:40}..." + +pay_json=$(bob_cli payinvoice --force --json "$deposit_invoice" 2>&1) \ + || { echo "$pay_json" | sed 's/^/ /'; fail "bob could not pay deposit"; } +open_preimage=$(echo "$pay_json" | jq -rs '.[-1] | .payment_preimage // empty') +[ -n "$open_preimage" ] && [ "$open_preimage" != "null" ] \ + || fail "deposit payment returned no preimage" +info "deposit preimage: $open_preimage" + +open_payload=$(jq -nc --arg pi "$open_preimage" --arg ri "$return_payreq" \ + '{action:"open", preimage:$pi, returnInvoice:$ri}') + +open_cred=$(build_cred "$SESS_ID" "$SESS_REALM" "$SESS_METHOD" \ + "$SESS_INTENT" "$SESS_REQ" "$SESS_EXPIRES" "$open_payload") + +sleep 2 +code=$(curl -sk -o "$BODY" -D "$HDR" -w '%{http_code}' \ + -H "Host: $SERVICE_HOST" \ + -H "Authorization: Payment $open_cred" \ + "https://$PRISM_HOST$PATH_SUFFIX") +[ "$code" = "200" ] || fail "open failed, got $code. body: $(head -c 300 "$BODY")" +pass "open accepted → HTTP 200" + +# The session id is the paymentHash of the deposit invoice. +SESSION_ID="$deposit_phash" +info "session id = $SESSION_ID" + +# --- 4. Action: bearer (no payment, server debits balance) ----------- + +step "[4/7] Action: bearer × $BEARER_CALLS (drain balance)" + +bearer_payload=$(jq -nc --arg sid "$SESSION_ID" --arg pi "$open_preimage" \ + '{action:"bearer", sessionId:$sid, preimage:$pi}') +bearer_cred=$(build_cred "$SESS_ID" "$SESS_REALM" "$SESS_METHOD" \ + "$SESS_INTENT" "$SESS_REQ" "$SESS_EXPIRES" "$bearer_payload") + +for i in $(seq 1 "$BEARER_CALLS"); do + code=$(curl -sk -o "$BODY" -D "$HDR" -w '%{http_code}' \ + -H "Host: $SERVICE_HOST" \ + -H "Authorization: Payment $bearer_cred" \ + "https://$PRISM_HOST$PATH_SUFFIX") + if [ "$code" = "200" ]; then + pass "bearer #$i → HTTP 200 (no Lightning payment)" + else + fail "bearer #$i → HTTP $code. body: $(head -c 300 "$BODY")" + fi +done + +# open does not debit balance — only bearer/topUp actions that actually +# forward to the backend consume from the session's remaining amount. +spent=$((per_unit_amount * BEARER_CALLS)) +remaining=$((deposit_amount - spent)) +info "expected spend: $spent base units (${BEARER_CALLS} × $per_unit_amount)" +info "expected refund: $remaining base units ($(python3 -c "print($remaining/1e9)") SUI)" + +# --- 5. Action: close (server refunds remaining) -------------------- + +step "[5/7] Action: close (server pays ReturnInvoice)" + +close_payload=$(jq -nc --arg sid "$SESSION_ID" --arg pi "$open_preimage" \ + '{action:"close", sessionId:$sid, preimage:$pi}') +close_cred=$(build_cred "$SESS_ID" "$SESS_REALM" "$SESS_METHOD" \ + "$SESS_INTENT" "$SESS_REQ" "$SESS_EXPIRES" "$close_payload") + +code=$(curl -sk -o "$BODY2" -D "$HDR2" -w '%{http_code}' \ + -H "Host: $SERVICE_HOST" \ + -H "Authorization: Payment $close_cred" \ + "https://$PRISM_HOST$PATH_SUFFIX") +[ "$code" = "200" ] || fail "close failed, got $code. body: $(head -c 300 "$BODY2")" +pass "close accepted → HTTP 200" + +# Decode the session receipt. +rct_line=$(grep -i '^payment-receipt:' "$HDR2" | head -1 | tr -d '\r' || true) +if [ -n "$rct_line" ]; then + rct_b64=$(echo "$rct_line" \ + | sed -n 's/^[Pp]ayment-[Rr]eceipt: *\([A-Za-z0-9_-]*\).*/\1/p') + echo " session receipt:" + echo "$rct_b64" | b64url_dec | jq . | sed 's/^/ /' \ + || warn "could not decode receipt" +else + warn "no Payment-Receipt header on close response" +fi + +# --- 6. Verify bob actually received the refund --------------------- + +step "[6/7] Verify refund landed on bob" + +sleep 2 +refund_state=$(bob_cli lookupinvoice "$(echo "$return_payreq" | \ + { read -r pr; alice_cli decodepayreq "$pr" | jq -r '.payment_hash'; })" 2>&1 \ + | jq -r '.state // empty') +if [ "$refund_state" = "SETTLED" ]; then + pass "bob's return invoice is SETTLED — refund received" + # Show the exact amount Bob received. + received=$(bob_cli lookupinvoice "$(alice_cli decodepayreq "$return_payreq" \ + | jq -r '.payment_hash')" 2>&1 | jq -r '.amt_paid_sat') + info "amount received by bob: $received base units ($(python3 -c "print($received/1e9)") SUI)" +else + warn "return invoice state: ${refund_state:-unknown} (refund may be async)" +fi + +# --- 7. Bearer after close should fail ------------------------------ + +step "[7/7] Bearer after close should be rejected" + +code=$(curl -sk -o "$BODY" -D "$HDR" -w '%{http_code}' \ + -H "Host: $SERVICE_HOST" \ + -H "Authorization: Payment $bearer_cred" \ + "https://$PRISM_HOST$PATH_SUFFIX") +case "$code" in + 200) warn "bearer after close returned 200 (session may linger briefly)" ;; + 401|402) pass "session no longer accepted (HTTP $code) — close finalized" ;; + *) warn "unexpected HTTP $code on post-close bearer" ;; +esac + +echo +echo "${G}━━━ MPP session flow complete ━━━${N}" +echo "Session lifecycle: open → $BEARER_CALLS × bearer → close (refund via ReturnInvoice)" From e5eb67a5407ed2f841c1289a87ca0a48a56d6184 Mon Sep 17 00:00:00 2001 From: ai-chen2050 <1033467071@qq.com> Date: Thu, 23 Apr 2026 18:02:47 +0800 Subject: [PATCH 11/32] feat(admin): reconcile canceled/expired invoices into expired state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every 402 challenge writes a pending row to the L402 transactions store, but only one of the sibling invoices issued for a dual-scheme service (L402 + MPP) gets paid — the rest stay pending forever, skewing admin reports and obscuring real business metrics. Wire up the challenger's invoice stream so irrelevant invoices (CANCELED, or past-expiry + unpaid) flip their matching transaction rows to a new "expired" state instead of accumulating. aperturedb/l402_transactions.go ExpireTransaction(ctx, hash) — mirrors SettleTransaction but sets state='expired' and leaves settled_at NULL so revenue reports that key off settled_at keep excluding these rows. challenger/lnd.go WithExpirationCallback(fn) option, symmetrical to WithSettlementCallback. Adds an expirationQueue + seenExpired dedupe map. Both the startup ListInvoices sweep and the live SubscribeInvoices stream now call the callback for invoices that invoiceIrrelevant() says are terminal and not settled. Stop() drains the new queue. aperture.go buildChallengerOpts registers WithExpirationCallback(ExpireTransaction) alongside the existing settlement callback. Known caveat: lnd's SubscribeInvoices stream doesn't publish CANCELED state changes (it only fires on add_index or settle_index bumps), so real-time expiry is only caught during the startup sweep that runs on every prism restart. A periodic background sweep or SubscribeSingleInvoice (requires invoicesrpc, not available on Sui-LND) could make this real- time; out of scope here. Co-Authored-By: Claude Opus 4.7 (1M context) --- aperture.go | 26 ++++++ aperturedb/l402_transactions.go | 47 +++++++++++ challenger/lnd.go | 139 +++++++++++++++++++++++++++----- 3 files changed, 194 insertions(+), 18 deletions(-) diff --git a/aperture.go b/aperture.go index 2ae25d89..c555ce3f 100644 --- a/aperture.go +++ b/aperture.go @@ -1001,6 +1001,32 @@ func buildChallengerOpts( } }, )) + + // When lnd observes an invoice reaching a terminal non- + // settled state (canceled or past-expiry + unpaid), flip + // any matching pending rows in our transaction store to + // "expired". Without this the abandoned sibling invoices + // that accompany every dual-scheme 402 (L402 + MPP, or + // L402 + MPP-charge + MPP-session) would pile up forever + // in pending state and skew admin reports. + opts = append(opts, challenger.WithExpirationCallback( + func(hash lntypes.Hash) { + ctx, cancel := context.WithTimeout( + context.Background(), + aperturedb.DefaultStoreTimeout, + ) + defer cancel() + + err := txnStore.ExpireTransaction( + ctx, hash[:], + ) + if err != nil { + log.Errorf("Error expiring "+ + "transaction (hash=%x): %v", + hash[:], err) + } + }, + )) } return opts diff --git a/aperturedb/l402_transactions.go b/aperturedb/l402_transactions.go index 08f2a9fc..162e32fa 100644 --- a/aperturedb/l402_transactions.go +++ b/aperturedb/l402_transactions.go @@ -303,6 +303,53 @@ func (s *L402TransactionsStore) SettleTransaction(ctx context.Context, return nil } +// ExpireTransaction marks all pending transactions with the given payment +// hash as expired. Used when the challenger observes that the underlying +// Lightning invoice was canceled or passed its expiry without being paid, +// so these rows don't accumulate as stale "pending" forever. +// +// Only affects rows still in "pending" state — a transaction that was +// already settled is left alone. The settled_at column is deliberately +// not touched (remains NULL for expired rows), so reports that join on +// settled_at continue to exclude them. +func (s *L402TransactionsStore) ExpireTransaction(ctx context.Context, + paymentHash []byte) error { + + var writeTxOpts L402TransactionsDBTxOptions + err := s.db.ExecTx(ctx, &writeTxOpts, func(tx L402TransactionsDB) error { + nRows, err := tx.UpdateL402TransactionState( + ctx, UpdateL402TxState{ + State: "expired", + SettledAt: sql.NullTime{}, + PaymentHash: paymentHash, + }, + ) + if err != nil { + return err + } + + if nRows == 0 { + // Expected when the invoice was never tracked (e.g. + // canceled before any challenge was recorded), or + // when the row was already settled/expired. + log.Tracef("ExpireTransaction affected 0 rows "+ + "(hash=%x)", paymentHash) + } else { + log.Debugf("ExpireTransaction marked %d row(s) as "+ + "expired (hash=%x)", nRows, paymentHash) + } + + return nil + }) + + if err != nil { + return fmt.Errorf("unable to expire L402 transaction "+ + "(hash=%x): %w", paymentHash, err) + } + + return nil +} + // ListTransactions returns a paginated list of all transactions. func (s *L402TransactionsStore) ListTransactions(ctx context.Context, limit, offset int32) ([]L402Transaction, error) { diff --git a/challenger/lnd.go b/challenger/lnd.go index bb5d2ebe..cddbf938 100644 --- a/challenger/lnd.go +++ b/challenger/lnd.go @@ -26,6 +26,16 @@ func WithSettlementCallback(fn func(hash lntypes.Hash)) LndChallengerOption { } } +// WithExpirationCallback sets a callback that will be invoked when an invoice +// is observed in a terminal non-settled state — either explicitly canceled or +// passed its expiry without being paid. Used to clean up pending rows from +// the transaction store so they don't accumulate forever. +func WithExpirationCallback(fn func(hash lntypes.Hash)) LndChallengerOption { + return func(l *LndChallenger) { + l.onExpired = fn + } +} + // LndChallenger is a challenger that uses an lnd backend to create new L402 // payment challenges. type LndChallenger struct { @@ -51,6 +61,21 @@ type LndChallenger struct { // settlement callbacks without blocking the invoice stream reader. settlementQueue *queue.ConcurrentQueue + // onExpired is an optional callback that is invoked when an invoice + // reaches a terminal non-settled state (canceled or expired). + onExpired func(hash lntypes.Hash) + + // expirationQueue is an unbounded concurrent queue used to serialize + // expiration callbacks without blocking the invoice stream reader. + expirationQueue *queue.ConcurrentQueue + + // seenExpired tracks hashes that have already been delivered to + // onExpired, so we don't re-deliver for the same invoice if lnd + // resends the same state (startup sweep + live stream overlap, or + // multiple CANCELED updates). + seenExpired map[lntypes.Hash]struct{} + seenExpiredMu sync.Mutex + errChan chan<- error quit chan struct{} @@ -104,11 +129,21 @@ func NewLndChallenger(client InvoiceClient, batchSize int, challenger.settlementQueue.Start() } + // Same for expiration, so onExpired doesn't block the stream reader. + if challenger.onExpired != nil { + challenger.expirationQueue = queue.NewConcurrentQueue(100) + challenger.expirationQueue.Start() + challenger.seenExpired = make(map[lntypes.Hash]struct{}) + } + err := challenger.Start() if err != nil { if challenger.settlementQueue != nil { challenger.settlementQueue.Stop() } + if challenger.expirationQueue != nil { + challenger.expirationQueue.Stop() + } return nil, fmt.Errorf("unable to start challenger: %w", err) } @@ -121,9 +156,9 @@ func NewLndChallenger(client InvoiceClient, batchSize int, // invoices on startup and a subscription to all subsequent invoice updates // is created. func (l *LndChallenger) Start() error { - // If we aren't doing strict verification and have no settlement - // callback, we can skip invoice tracking entirely. - if !l.strictVerify && l.onSettled == nil { + // If we aren't doing strict verification and have no settlement or + // expiration callback, we can skip invoice tracking entirely. + if !l.strictVerify && l.onSettled == nil && l.onExpired == nil { log.Infof("Skipping invoice state tracking strict_verify=%v", l.strictVerify) return nil @@ -136,14 +171,15 @@ func (l *LndChallenger) Start() error { addIndex := uint64(0) settleIndex := uint64(0) var startupSettled []lntypes.Hash + var startupExpired []lntypes.Hash log.Debugf("Starting LND challenger") - // When strict verification or settlement reconciliation is enabled, - // paginate through all existing invoices on startup. This lets us - // seed the state cache and reconcile any invoices that were settled - // while we were offline. - if l.strictVerify || l.onSettled != nil { + // When strict verification or settlement/expiration reconciliation is + // enabled, paginate through all existing invoices on startup. This lets + // us seed the state cache and reconcile any invoices whose state + // changed while we were offline. + if l.strictVerify || l.onSettled != nil || l.onExpired != nil { ctx := l.clientCtx() indexOffset := uint64(0) for { @@ -191,6 +227,19 @@ func (l *LndChallenger) Start() error { ) } + // Collect canceled / expired-unpaid invoices + // so the caller can clean up stale pending + // transaction rows that were created for + // these challenges. + if l.onExpired != nil && + invoice.State != lnrpc.Invoice_SETTLED && + invoiceIrrelevant(invoice) { + + startupExpired = append( + startupExpired, hash, + ) + } + if !l.strictVerify { continue } @@ -269,9 +318,53 @@ func (l *LndChallenger) Start() error { } } + // Symmetric worker for expirations. + if l.onExpired != nil { + l.wg.Add(1) + go func() { + defer l.wg.Done() + for { + select { + case item, ok := <-l.expirationQueue.ChanOut(): + if !ok { + return + } + hash, valid := item.(lntypes.Hash) + if !valid { + continue + } + l.onExpired(hash) + + case <-l.quit: + return + } + } + }() + + for _, hash := range startupExpired { + l.markSeenExpired(hash) + l.expirationQueue.ChanIn() <- hash + } + } + return nil } +// markSeenExpired records a hash in the dedupe set. Returns true if this +// is the first time we've seen it, false if it was already marked. +func (l *LndChallenger) markSeenExpired(hash lntypes.Hash) bool { + if l.seenExpired == nil { + return true + } + l.seenExpiredMu.Lock() + defer l.seenExpiredMu.Unlock() + if _, already := l.seenExpired[hash]; already { + return false + } + l.seenExpired[hash] = struct{}{} + return true +} + // readInvoiceStream reads the invoice update messages sent on the stream until // the stream is aborted or the challenger is shutting down. func (l *LndChallenger) readInvoiceStream( @@ -343,18 +436,19 @@ func (l *LndChallenger) readInvoiceStream( return } + irrelevant := invoiceIrrelevant(invoice) l.invoicesMtx.Lock() - if invoiceIrrelevant(invoice) { + if irrelevant { // Don't keep the state of canceled or expired invoices. delete(l.invoiceStates, hash) } else { l.invoiceStates[hash] = invoice.State } - // Determine if we need to enqueue a settlement callback - // before releasing the lock. - shouldEnqueue := invoice.State == lnrpc.Invoice_SETTLED && + // Determine which callbacks to fire, while holding the lock. + shouldSettle := invoice.State == lnrpc.Invoice_SETTLED && l.onSettled != nil + shouldExpire := !shouldSettle && irrelevant && l.onExpired != nil // Notify conditions that listen for invoice state updates, // then release the lock before enqueuing to avoid blocking @@ -362,16 +456,22 @@ func (l *LndChallenger) readInvoiceStream( l.invoicesCond.Broadcast() l.invoicesMtx.Unlock() - // Enqueue settlement outside the mutex. The - // ConcurrentQueue is unbounded, so this send will never - // block. - if shouldEnqueue { + // Enqueue outside the mutex. The ConcurrentQueue is + // unbounded, so these sends will never block. + if shouldSettle { select { case l.settlementQueue.ChanIn() <- hash: case <-l.quit: return } } + if shouldExpire && l.markSeenExpired(hash) { + select { + case l.expirationQueue.ChanIn() <- hash: + case <-l.quit: + return + } + } } } @@ -383,11 +483,14 @@ func (l *LndChallenger) Stop() { close(l.quit) l.wg.Wait() - // Stop the settlement queue after all goroutines have exited so - // that any pending items are drained. + // Stop the settlement and expiration queues after all goroutines + // have exited so that any pending items are drained. if l.settlementQueue != nil { l.settlementQueue.Stop() } + if l.expirationQueue != nil { + l.expirationQueue.Stop() + } } // NewChallenge creates a new L402 payment challenge, returning a payment From 8310b8c2ac7e2416db6a5e548bed6e61f8469ac6 Mon Sep 17 00:00:00 2001 From: ai-chen2050 <1033467071@qq.com> Date: Thu, 23 Apr 2026 18:03:18 +0800 Subject: [PATCH 12/32] feat(admin,dashboard): add /sessions endpoints + Sessions page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MPP prepaid session lifecycle (deposit / bearer / topUp / close) is currently invisible to the admin dashboard — the L402 transactions table only tracks charge-intent invoices, so a server running mostly sessions looks like it has zero revenue in /stats. Expose session activity through two new admin endpoints and a dedicated dashboard page. Backend adminrpc/admin.proto + rpc ListSessions(ListSessionsRequest) → ListSessionsResponse + rpc GetSessionStats(GetSessionStatsRequest) → GetSessionStatsResponse + MPPSession, ListSessionsRequest/Response, GetSessionStatsResponse messages. balance_sats field precomputed server-side so clients don't have to do the subtraction. adminrpc/admin.yaml REST routes: GET /api/admin/sessions, /api/admin/sessions/stats. aperturedb/sqlc/queries/mpp_sessions.sql ListMPPSessions (paginated, optional status filter), CountMPPSessions, GetMPPSessionAggregateStats. The aggregate query uses portable SUM(CASE WHEN) instead of COUNT(*) FILTER so it runs on both postgres and sqlite (sqlite doesn't support FILTER). aperturedb/mpp_sessions.go ListSessions(ctx, status, limit, offset) + GetStats(ctx) methods on MPPSessionsStore. MPPSessionsDB interface extended. admin/server.go ListSessions / GetSessionStats handlers. Return Unimplemented (501 over REST) when the server was started without sessions enabled. aperture.go mppSessionStore variable typed as *MPPSessionsStore (still satisfies auth.SessionStore). Threaded through createAdminServer into admin.ServerConfig.SessionStore. Dashboard proxy allowedQueryParams whitelist accepts "status". Frontend dashboard/lib/types.ts MPPSession, ListSessionsResponse, SessionStatsResponse interfaces. dashboard/lib/api.ts useSessions(), useSessionStats() SWR hooks. 501 response maps to null so the UI can render a clean "not enabled" state. dashboard/app/sessions/page.tsx (new) 4 KPI cards (open / total / revenue spent / open balance), open|closed|all filter tabs, paginated table. Amounts render via the existing chain-aware formatAmount helper, so bitcoin deployments show sats and sui deployments show SUI. dashboard/app/layout.tsx Sessions nav item appears only when info.mpp_enabled && info.sessions_enabled, so single-scheme deployments aren't cluttered. Co-Authored-By: Claude Opus 4.7 (1M context) --- admin/server.go | 107 ++++++ adminrpc/admin.pb.go | 458 +++++++++++++++++++++-- adminrpc/admin.pb.gw.go | 134 +++++++ adminrpc/admin.proto | 70 ++++ adminrpc/admin.swagger.json | 159 ++++++++ adminrpc/admin.yaml | 4 + adminrpc/admin_grpc.pb.go | 88 +++++ aperture.go | 12 +- aperturedb/mpp_sessions.go | 129 +++++++ aperturedb/sqlc/mpp_sessions.sql.go | 103 +++++ aperturedb/sqlc/querier.go | 6 + aperturedb/sqlc/queries/mpp_sessions.sql | 31 ++ dashboard/app/layout.tsx | 17 +- dashboard/app/sessions/page.tsx | 331 ++++++++++++++++ dashboard/lib/api.ts | 72 ++++ dashboard/lib/types.ts | 47 +++ 16 files changed, 1735 insertions(+), 33 deletions(-) create mode 100644 dashboard/app/sessions/page.tsx diff --git a/admin/server.go b/admin/server.go index d35f59ea..1b5906f7 100644 --- a/admin/server.go +++ b/admin/server.go @@ -122,6 +122,11 @@ type ServerConfig struct { // Populated from lnd GetInfo.chains[0].chain at aperture startup // (e.g. "bitcoin", "sui"). Empty if lnd could not be queried. Chain string + + // SessionStore exposes MPP prepaid session data to the admin API. + // Optional — nil when sessions are disabled; the ListSessions and + // GetSessionStats RPCs return Unimplemented in that case. + SessionStore *aperturedb.MPPSessionsStore } // Server implements the adminrpc.AdminServer gRPC interface. Thread safety @@ -1009,3 +1014,105 @@ func validateAuthLevel(s string) (string, error) { "'on', 'off', or 'freebie N'", s) } } + +// ListSessions returns paginated MPP prepaid sessions with an optional +// status filter ("open" | "closed" | ""). +// +// NOTE: This is part of the adminrpc.AdminServer interface. +func (s *Server) ListSessions(ctx context.Context, + req *adminrpc.ListSessionsRequest) (*adminrpc.ListSessionsResponse, + error) { + + if s.cfg.SessionStore == nil { + return nil, status.Error(codes.Unimplemented, + "MPP sessions not enabled on this server") + } + + // Validate status filter up front so we don't run a query that + // would return zero rows for a typo. Empty string means no filter. + switch req.Status { + case "", "open", "closed": + default: + return nil, status.Errorf(codes.InvalidArgument, + "status must be \"open\", \"closed\", or empty; got %q", + req.Status) + } + + limit := req.Limit + if limit <= 0 { + limit = defaultLimit + } + if limit > maxLimit { + limit = maxLimit + } + offset := req.Offset + if offset < 0 { + offset = 0 + } + + sessions, total, err := s.cfg.SessionStore.ListSessions( + ctx, req.Status, limit, offset, + ) + if err != nil { + return nil, status.Errorf(codes.Internal, + "list sessions: %v", err) + } + + resp := &adminrpc.ListSessionsResponse{ + Sessions: make([]*adminrpc.MPPSession, 0, len(sessions)), + Total: total, + } + for _, sess := range sessions { + resp.Sessions = append( + resp.Sessions, sessionToProto(sess), + ) + } + return resp, nil +} + +// GetSessionStats returns aggregate counters across all MPP sessions. +// +// NOTE: This is part of the adminrpc.AdminServer interface. +func (s *Server) GetSessionStats(ctx context.Context, + _ *adminrpc.GetSessionStatsRequest) ( + *adminrpc.GetSessionStatsResponse, error) { + + if s.cfg.SessionStore == nil { + return nil, status.Error(codes.Unimplemented, + "MPP sessions not enabled on this server") + } + + stats, err := s.cfg.SessionStore.GetStats(ctx) + if err != nil { + return nil, status.Errorf(codes.Internal, + "get session stats: %v", err) + } + + return &adminrpc.GetSessionStatsResponse{ + TotalSessions: stats.TotalSessions, + OpenSessions: stats.OpenSessions, + ClosedSessions: stats.ClosedSessions, + TotalDepositSats: stats.TotalDepositSats, + TotalSpentSats: stats.TotalSpentSats, + OpenBalanceSats: stats.OpenBalanceSats, + }, nil +} + +// sessionToProto converts an auth.Session to its wire representation. The +// balance_sats field is computed as deposit-spent so callers don't have to +// do it themselves; it's meaningful for open sessions (what's still owed +// back to the client) and equal to what was refunded on close for closed +// ones (well, ignoring route fees). +func sessionToProto(sess *auth.Session) *adminrpc.MPPSession { + return &adminrpc.MPPSession{ + SessionId: sess.SessionID, + PaymentHash: hex.EncodeToString(sess.PaymentHash[:]), + DepositSats: sess.DepositSats, + SpentSats: sess.SpentSats, + BalanceSats: sess.DepositSats - sess.SpentSats, + ReturnInvoice: sess.ReturnInvoice, + Status: sess.Status, + CreatedAt: sess.CreatedAt.UTC().Format(time.RFC3339), + UpdatedAt: sess.UpdatedAt.UTC().Format(time.RFC3339), + } +} diff --git a/adminrpc/admin.pb.go b/adminrpc/admin.pb.go index 3d6fec45..1bab3276 100644 --- a/adminrpc/admin.pb.go +++ b/adminrpc/admin.pb.go @@ -1358,6 +1358,362 @@ func (x *ServiceRevenue) GetTotalRevenueSats() int64 { return 0 } +// MPPSession mirrors the on-server session record for the admin API. +// Payment hash is hex-encoded. Amounts are in the chain's base unit. +type MPPSession struct { + state protoimpl.MessageState `protogen:"open.v1"` + SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + PaymentHash string `protobuf:"bytes,2,opt,name=payment_hash,json=paymentHash,proto3" json:"payment_hash,omitempty"` + DepositSats int64 `protobuf:"varint,3,opt,name=deposit_sats,json=depositSats,proto3" json:"deposit_sats,omitempty"` + SpentSats int64 `protobuf:"varint,4,opt,name=spent_sats,json=spentSats,proto3" json:"spent_sats,omitempty"` + // balance_sats is deposit_sats - spent_sats. Meaningful for open + // sessions; on closed ones this is what was refunded at close time. + BalanceSats int64 `protobuf:"varint,5,opt,name=balance_sats,json=balanceSats,proto3" json:"balance_sats,omitempty"` + ReturnInvoice string `protobuf:"bytes,6,opt,name=return_invoice,json=returnInvoice,proto3" json:"return_invoice,omitempty"` + // status is "open" or "closed". + Status string `protobuf:"bytes,7,opt,name=status,proto3" json:"status,omitempty"` + // created_at / updated_at are RFC 3339 timestamps. + CreatedAt string `protobuf:"bytes,8,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` + UpdatedAt string `protobuf:"bytes,9,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MPPSession) Reset() { + *x = MPPSession{} + mi := &file_admin_proto_msgTypes[21] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MPPSession) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MPPSession) ProtoMessage() {} + +func (x *MPPSession) ProtoReflect() protoreflect.Message { + mi := &file_admin_proto_msgTypes[21] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MPPSession.ProtoReflect.Descriptor instead. +func (*MPPSession) Descriptor() ([]byte, []int) { + return file_admin_proto_rawDescGZIP(), []int{21} +} + +func (x *MPPSession) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *MPPSession) GetPaymentHash() string { + if x != nil { + return x.PaymentHash + } + return "" +} + +func (x *MPPSession) GetDepositSats() int64 { + if x != nil { + return x.DepositSats + } + return 0 +} + +func (x *MPPSession) GetSpentSats() int64 { + if x != nil { + return x.SpentSats + } + return 0 +} + +func (x *MPPSession) GetBalanceSats() int64 { + if x != nil { + return x.BalanceSats + } + return 0 +} + +func (x *MPPSession) GetReturnInvoice() string { + if x != nil { + return x.ReturnInvoice + } + return "" +} + +func (x *MPPSession) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +func (x *MPPSession) GetCreatedAt() string { + if x != nil { + return x.CreatedAt + } + return "" +} + +func (x *MPPSession) GetUpdatedAt() string { + if x != nil { + return x.UpdatedAt + } + return "" +} + +type ListSessionsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // status filters by "open" or "closed". Empty returns all. + Status string `protobuf:"bytes,1,opt,name=status,proto3" json:"status,omitempty"` + // limit defaults to 50 when zero. Max is 1000. + Limit int32 `protobuf:"varint,2,opt,name=limit,proto3" json:"limit,omitempty"` + // offset is 0-based. + Offset int32 `protobuf:"varint,3,opt,name=offset,proto3" json:"offset,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListSessionsRequest) Reset() { + *x = ListSessionsRequest{} + mi := &file_admin_proto_msgTypes[22] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListSessionsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListSessionsRequest) ProtoMessage() {} + +func (x *ListSessionsRequest) ProtoReflect() protoreflect.Message { + mi := &file_admin_proto_msgTypes[22] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListSessionsRequest.ProtoReflect.Descriptor instead. +func (*ListSessionsRequest) Descriptor() ([]byte, []int) { + return file_admin_proto_rawDescGZIP(), []int{22} +} + +func (x *ListSessionsRequest) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +func (x *ListSessionsRequest) GetLimit() int32 { + if x != nil { + return x.Limit + } + return 0 +} + +func (x *ListSessionsRequest) GetOffset() int32 { + if x != nil { + return x.Offset + } + return 0 +} + +type ListSessionsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Sessions []*MPPSession `protobuf:"bytes,1,rep,name=sessions,proto3" json:"sessions,omitempty"` + // total is the count matching status filter, ignoring pagination. + Total int64 `protobuf:"varint,2,opt,name=total,proto3" json:"total,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListSessionsResponse) Reset() { + *x = ListSessionsResponse{} + mi := &file_admin_proto_msgTypes[23] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListSessionsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListSessionsResponse) ProtoMessage() {} + +func (x *ListSessionsResponse) ProtoReflect() protoreflect.Message { + mi := &file_admin_proto_msgTypes[23] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListSessionsResponse.ProtoReflect.Descriptor instead. +func (*ListSessionsResponse) Descriptor() ([]byte, []int) { + return file_admin_proto_rawDescGZIP(), []int{23} +} + +func (x *ListSessionsResponse) GetSessions() []*MPPSession { + if x != nil { + return x.Sessions + } + return nil +} + +func (x *ListSessionsResponse) GetTotal() int64 { + if x != nil { + return x.Total + } + return 0 +} + +type GetSessionStatsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetSessionStatsRequest) Reset() { + *x = GetSessionStatsRequest{} + mi := &file_admin_proto_msgTypes[24] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetSessionStatsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetSessionStatsRequest) ProtoMessage() {} + +func (x *GetSessionStatsRequest) ProtoReflect() protoreflect.Message { + mi := &file_admin_proto_msgTypes[24] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetSessionStatsRequest.ProtoReflect.Descriptor instead. +func (*GetSessionStatsRequest) Descriptor() ([]byte, []int) { + return file_admin_proto_rawDescGZIP(), []int{24} +} + +type GetSessionStatsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + TotalSessions int64 `protobuf:"varint,1,opt,name=total_sessions,json=totalSessions,proto3" json:"total_sessions,omitempty"` + OpenSessions int64 `protobuf:"varint,2,opt,name=open_sessions,json=openSessions,proto3" json:"open_sessions,omitempty"` + ClosedSessions int64 `protobuf:"varint,3,opt,name=closed_sessions,json=closedSessions,proto3" json:"closed_sessions,omitempty"` + // total_deposit_sats is the lifetime sum of deposits across all + // sessions (open + closed). + TotalDepositSats int64 `protobuf:"varint,4,opt,name=total_deposit_sats,json=totalDepositSats,proto3" json:"total_deposit_sats,omitempty"` + // total_spent_sats is the actual revenue — satoshis consumed by + // bearer requests. + TotalSpentSats int64 `protobuf:"varint,5,opt,name=total_spent_sats,json=totalSpentSats,proto3" json:"total_spent_sats,omitempty"` + // open_balance_sats is the sum of deposit_sats - spent_sats over + // sessions still in the open state (prepaid balance owed to clients). + OpenBalanceSats int64 `protobuf:"varint,6,opt,name=open_balance_sats,json=openBalanceSats,proto3" json:"open_balance_sats,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetSessionStatsResponse) Reset() { + *x = GetSessionStatsResponse{} + mi := &file_admin_proto_msgTypes[25] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetSessionStatsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetSessionStatsResponse) ProtoMessage() {} + +func (x *GetSessionStatsResponse) ProtoReflect() protoreflect.Message { + mi := &file_admin_proto_msgTypes[25] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetSessionStatsResponse.ProtoReflect.Descriptor instead. +func (*GetSessionStatsResponse) Descriptor() ([]byte, []int) { + return file_admin_proto_rawDescGZIP(), []int{25} +} + +func (x *GetSessionStatsResponse) GetTotalSessions() int64 { + if x != nil { + return x.TotalSessions + } + return 0 +} + +func (x *GetSessionStatsResponse) GetOpenSessions() int64 { + if x != nil { + return x.OpenSessions + } + return 0 +} + +func (x *GetSessionStatsResponse) GetClosedSessions() int64 { + if x != nil { + return x.ClosedSessions + } + return 0 +} + +func (x *GetSessionStatsResponse) GetTotalDepositSats() int64 { + if x != nil { + return x.TotalDepositSats + } + return 0 +} + +func (x *GetSessionStatsResponse) GetTotalSpentSats() int64 { + if x != nil { + return x.TotalSpentSats + } + return 0 +} + +func (x *GetSessionStatsResponse) GetOpenBalanceSats() int64 { + if x != nil { + return x.OpenBalanceSats + } + return 0 +} + var File_admin_proto protoreflect.FileDescriptor const file_admin_proto_rawDesc = "" + @@ -1466,12 +1822,42 @@ const file_admin_proto_rawDesc = "" + "\x11service_breakdown\x18\x03 \x03(\v2\x18.adminrpc.ServiceRevenueR\x10serviceBreakdown\"a\n" + "\x0eServiceRevenue\x12!\n" + "\fservice_name\x18\x01 \x01(\tR\vserviceName\x12,\n" + - "\x12total_revenue_sats\x18\x02 \x01(\x03R\x10totalRevenueSats*Q\n" + + "\x12total_revenue_sats\x18\x02 \x01(\x03R\x10totalRevenueSats\"\xb0\x02\n" + + "\n" + + "MPPSession\x12\x1d\n" + + "\n" + + "session_id\x18\x01 \x01(\tR\tsessionId\x12!\n" + + "\fpayment_hash\x18\x02 \x01(\tR\vpaymentHash\x12!\n" + + "\fdeposit_sats\x18\x03 \x01(\x03R\vdepositSats\x12\x1d\n" + + "\n" + + "spent_sats\x18\x04 \x01(\x03R\tspentSats\x12!\n" + + "\fbalance_sats\x18\x05 \x01(\x03R\vbalanceSats\x12%\n" + + "\x0ereturn_invoice\x18\x06 \x01(\tR\rreturnInvoice\x12\x16\n" + + "\x06status\x18\a \x01(\tR\x06status\x12\x1d\n" + + "\n" + + "created_at\x18\b \x01(\tR\tcreatedAt\x12\x1d\n" + + "\n" + + "updated_at\x18\t \x01(\tR\tupdatedAt\"[\n" + + "\x13ListSessionsRequest\x12\x16\n" + + "\x06status\x18\x01 \x01(\tR\x06status\x12\x14\n" + + "\x05limit\x18\x02 \x01(\x05R\x05limit\x12\x16\n" + + "\x06offset\x18\x03 \x01(\x05R\x06offset\"^\n" + + "\x14ListSessionsResponse\x120\n" + + "\bsessions\x18\x01 \x03(\v2\x14.adminrpc.MPPSessionR\bsessions\x12\x14\n" + + "\x05total\x18\x02 \x01(\x03R\x05total\"\x18\n" + + "\x16GetSessionStatsRequest\"\x92\x02\n" + + "\x17GetSessionStatsResponse\x12%\n" + + "\x0etotal_sessions\x18\x01 \x01(\x03R\rtotalSessions\x12#\n" + + "\ropen_sessions\x18\x02 \x01(\x03R\fopenSessions\x12'\n" + + "\x0fclosed_sessions\x18\x03 \x01(\x03R\x0eclosedSessions\x12,\n" + + "\x12total_deposit_sats\x18\x04 \x01(\x03R\x10totalDepositSats\x12(\n" + + "\x10total_spent_sats\x18\x05 \x01(\x03R\x0etotalSpentSats\x12*\n" + + "\x11open_balance_sats\x18\x06 \x01(\x03R\x0fopenBalanceSats*Q\n" + "\n" + "AuthScheme\x12\x14\n" + "\x10AUTH_SCHEME_L402\x10\x00\x12\x13\n" + "\x0fAUTH_SCHEME_MPP\x10\x01\x12\x18\n" + - "\x14AUTH_SCHEME_L402_MPP\x10\x022\xe9\x05\n" + + "\x14AUTH_SCHEME_L402_MPP\x10\x022\x90\a\n" + "\x05Admin\x12>\n" + "\aGetInfo\x12\x18.adminrpc.GetInfoRequest\x1a\x19.adminrpc.GetInfoResponse\x12D\n" + "\tGetHealth\x12\x1a.adminrpc.GetHealthRequest\x1a\x1b.adminrpc.GetHealthResponse\x12M\n" + @@ -1483,7 +1869,9 @@ const file_admin_proto_rawDesc = "" + "\n" + "ListTokens\x12\x1b.adminrpc.ListTokensRequest\x1a\x1c.adminrpc.ListTokensResponse\x12J\n" + "\vRevokeToken\x12\x1c.adminrpc.RevokeTokenRequest\x1a\x1d.adminrpc.RevokeTokenResponse\x12A\n" + - "\bGetStats\x12\x19.adminrpc.GetStatsRequest\x1a\x1a.adminrpc.GetStatsResponseB,Z*github.com/lightninglabs/aperture/adminrpcb\x06proto3" + "\bGetStats\x12\x19.adminrpc.GetStatsRequest\x1a\x1a.adminrpc.GetStatsResponse\x12M\n" + + "\fListSessions\x12\x1d.adminrpc.ListSessionsRequest\x1a\x1e.adminrpc.ListSessionsResponse\x12V\n" + + "\x0fGetSessionStats\x12 .adminrpc.GetSessionStatsRequest\x1a!.adminrpc.GetSessionStatsResponseB,Z*github.com/lightninglabs/aperture/adminrpcb\x06proto3" var ( file_admin_proto_rawDescOnce sync.Once @@ -1498,7 +1886,7 @@ func file_admin_proto_rawDescGZIP() []byte { } var file_admin_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_admin_proto_msgTypes = make([]protoimpl.MessageInfo, 21) +var file_admin_proto_msgTypes = make([]protoimpl.MessageInfo, 26) var file_admin_proto_goTypes = []any{ (AuthScheme)(0), // 0: adminrpc.AuthScheme (*GetInfoRequest)(nil), // 1: adminrpc.GetInfoRequest @@ -1522,6 +1910,11 @@ var file_admin_proto_goTypes = []any{ (*GetStatsRequest)(nil), // 19: adminrpc.GetStatsRequest (*GetStatsResponse)(nil), // 20: adminrpc.GetStatsResponse (*ServiceRevenue)(nil), // 21: adminrpc.ServiceRevenue + (*MPPSession)(nil), // 22: adminrpc.MPPSession + (*ListSessionsRequest)(nil), // 23: adminrpc.ListSessionsRequest + (*ListSessionsResponse)(nil), // 24: adminrpc.ListSessionsResponse + (*GetSessionStatsRequest)(nil), // 25: adminrpc.GetSessionStatsRequest + (*GetSessionStatsResponse)(nil), // 26: adminrpc.GetSessionStatsResponse } var file_admin_proto_depIdxs = []int32{ 7, // 0: adminrpc.ListServicesResponse.services:type_name -> adminrpc.Service @@ -1531,31 +1924,36 @@ var file_admin_proto_depIdxs = []int32{ 14, // 4: adminrpc.ListTransactionsResponse.transactions:type_name -> adminrpc.Transaction 14, // 5: adminrpc.ListTokensResponse.tokens:type_name -> adminrpc.Transaction 21, // 6: adminrpc.GetStatsResponse.service_breakdown:type_name -> adminrpc.ServiceRevenue - 1, // 7: adminrpc.Admin.GetInfo:input_type -> adminrpc.GetInfoRequest - 3, // 8: adminrpc.Admin.GetHealth:input_type -> adminrpc.GetHealthRequest - 5, // 9: adminrpc.Admin.ListServices:input_type -> adminrpc.ListServicesRequest - 8, // 10: adminrpc.Admin.CreateService:input_type -> adminrpc.CreateServiceRequest - 9, // 11: adminrpc.Admin.UpdateService:input_type -> adminrpc.UpdateServiceRequest - 10, // 12: adminrpc.Admin.DeleteService:input_type -> adminrpc.DeleteServiceRequest - 12, // 13: adminrpc.Admin.ListTransactions:input_type -> adminrpc.ListTransactionsRequest - 15, // 14: adminrpc.Admin.ListTokens:input_type -> adminrpc.ListTokensRequest - 17, // 15: adminrpc.Admin.RevokeToken:input_type -> adminrpc.RevokeTokenRequest - 19, // 16: adminrpc.Admin.GetStats:input_type -> adminrpc.GetStatsRequest - 2, // 17: adminrpc.Admin.GetInfo:output_type -> adminrpc.GetInfoResponse - 4, // 18: adminrpc.Admin.GetHealth:output_type -> adminrpc.GetHealthResponse - 6, // 19: adminrpc.Admin.ListServices:output_type -> adminrpc.ListServicesResponse - 7, // 20: adminrpc.Admin.CreateService:output_type -> adminrpc.Service - 7, // 21: adminrpc.Admin.UpdateService:output_type -> adminrpc.Service - 11, // 22: adminrpc.Admin.DeleteService:output_type -> adminrpc.DeleteServiceResponse - 13, // 23: adminrpc.Admin.ListTransactions:output_type -> adminrpc.ListTransactionsResponse - 16, // 24: adminrpc.Admin.ListTokens:output_type -> adminrpc.ListTokensResponse - 18, // 25: adminrpc.Admin.RevokeToken:output_type -> adminrpc.RevokeTokenResponse - 20, // 26: adminrpc.Admin.GetStats:output_type -> adminrpc.GetStatsResponse - 17, // [17:27] is the sub-list for method output_type - 7, // [7:17] is the sub-list for method input_type - 7, // [7:7] is the sub-list for extension type_name - 7, // [7:7] is the sub-list for extension extendee - 0, // [0:7] is the sub-list for field type_name + 22, // 7: adminrpc.ListSessionsResponse.sessions:type_name -> adminrpc.MPPSession + 1, // 8: adminrpc.Admin.GetInfo:input_type -> adminrpc.GetInfoRequest + 3, // 9: adminrpc.Admin.GetHealth:input_type -> adminrpc.GetHealthRequest + 5, // 10: adminrpc.Admin.ListServices:input_type -> adminrpc.ListServicesRequest + 8, // 11: adminrpc.Admin.CreateService:input_type -> adminrpc.CreateServiceRequest + 9, // 12: adminrpc.Admin.UpdateService:input_type -> adminrpc.UpdateServiceRequest + 10, // 13: adminrpc.Admin.DeleteService:input_type -> adminrpc.DeleteServiceRequest + 12, // 14: adminrpc.Admin.ListTransactions:input_type -> adminrpc.ListTransactionsRequest + 15, // 15: adminrpc.Admin.ListTokens:input_type -> adminrpc.ListTokensRequest + 17, // 16: adminrpc.Admin.RevokeToken:input_type -> adminrpc.RevokeTokenRequest + 19, // 17: adminrpc.Admin.GetStats:input_type -> adminrpc.GetStatsRequest + 23, // 18: adminrpc.Admin.ListSessions:input_type -> adminrpc.ListSessionsRequest + 25, // 19: adminrpc.Admin.GetSessionStats:input_type -> adminrpc.GetSessionStatsRequest + 2, // 20: adminrpc.Admin.GetInfo:output_type -> adminrpc.GetInfoResponse + 4, // 21: adminrpc.Admin.GetHealth:output_type -> adminrpc.GetHealthResponse + 6, // 22: adminrpc.Admin.ListServices:output_type -> adminrpc.ListServicesResponse + 7, // 23: adminrpc.Admin.CreateService:output_type -> adminrpc.Service + 7, // 24: adminrpc.Admin.UpdateService:output_type -> adminrpc.Service + 11, // 25: adminrpc.Admin.DeleteService:output_type -> adminrpc.DeleteServiceResponse + 13, // 26: adminrpc.Admin.ListTransactions:output_type -> adminrpc.ListTransactionsResponse + 16, // 27: adminrpc.Admin.ListTokens:output_type -> adminrpc.ListTokensResponse + 18, // 28: adminrpc.Admin.RevokeToken:output_type -> adminrpc.RevokeTokenResponse + 20, // 29: adminrpc.Admin.GetStats:output_type -> adminrpc.GetStatsResponse + 24, // 30: adminrpc.Admin.ListSessions:output_type -> adminrpc.ListSessionsResponse + 26, // 31: adminrpc.Admin.GetSessionStats:output_type -> adminrpc.GetSessionStatsResponse + 20, // [20:32] is the sub-list for method output_type + 8, // [8:20] is the sub-list for method input_type + 8, // [8:8] is the sub-list for extension type_name + 8, // [8:8] is the sub-list for extension extendee + 0, // [0:8] is the sub-list for field type_name } func init() { file_admin_proto_init() } @@ -1570,7 +1968,7 @@ func file_admin_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_admin_proto_rawDesc), len(file_admin_proto_rawDesc)), NumEnums: 1, - NumMessages: 21, + NumMessages: 26, NumExtensions: 0, NumServices: 1, }, diff --git a/adminrpc/admin.pb.gw.go b/adminrpc/admin.pb.gw.go index 5eec9604..92681bd1 100644 --- a/adminrpc/admin.pb.gw.go +++ b/adminrpc/admin.pb.gw.go @@ -353,6 +353,62 @@ func local_request_Admin_GetStats_0(ctx context.Context, marshaler runtime.Marsh return msg, metadata, err } +var filter_Admin_ListSessions_0 = &utilities.DoubleArray{Encoding: map[string]int{}, Base: []int(nil), Check: []int(nil)} + +func request_Admin_ListSessions_0(ctx context.Context, marshaler runtime.Marshaler, client AdminClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq ListSessionsRequest + metadata runtime.ServerMetadata + ) + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } + if err := req.ParseForm(); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_Admin_ListSessions_0); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := client.ListSessions(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_Admin_ListSessions_0(ctx context.Context, marshaler runtime.Marshaler, server AdminServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq ListSessionsRequest + metadata runtime.ServerMetadata + ) + if err := req.ParseForm(); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_Admin_ListSessions_0); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + msg, err := server.ListSessions(ctx, &protoReq) + return msg, metadata, err +} + +func request_Admin_GetSessionStats_0(ctx context.Context, marshaler runtime.Marshaler, client AdminClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq GetSessionStatsRequest + metadata runtime.ServerMetadata + ) + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } + msg, err := client.GetSessionStats(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_Admin_GetSessionStats_0(ctx context.Context, marshaler runtime.Marshaler, server AdminServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq GetSessionStatsRequest + metadata runtime.ServerMetadata + ) + msg, err := server.GetSessionStats(ctx, &protoReq) + return msg, metadata, err +} + // RegisterAdminHandlerServer registers the http handlers for service Admin to "mux". // UnaryRPC :call AdminServer directly. // StreamingRPC :currently unsupported pending https://github.com/grpc/grpc-go/issues/906. @@ -559,6 +615,46 @@ func RegisterAdminHandlerServer(ctx context.Context, mux *runtime.ServeMux, serv } forward_Admin_GetStats_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) }) + mux.Handle(http.MethodGet, pattern_Admin_ListSessions_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/adminrpc.Admin/ListSessions", runtime.WithHTTPPathPattern("/api/admin/sessions")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_Admin_ListSessions_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_Admin_ListSessions_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodGet, pattern_Admin_GetSessionStats_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/adminrpc.Admin/GetSessionStats", runtime.WithHTTPPathPattern("/api/admin/sessions/stats")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_Admin_GetSessionStats_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_Admin_GetSessionStats_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) return nil } @@ -769,6 +865,40 @@ func RegisterAdminHandlerClient(ctx context.Context, mux *runtime.ServeMux, clie } forward_Admin_GetStats_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) }) + mux.Handle(http.MethodGet, pattern_Admin_ListSessions_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/adminrpc.Admin/ListSessions", runtime.WithHTTPPathPattern("/api/admin/sessions")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_Admin_ListSessions_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_Admin_ListSessions_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodGet, pattern_Admin_GetSessionStats_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/adminrpc.Admin/GetSessionStats", runtime.WithHTTPPathPattern("/api/admin/sessions/stats")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_Admin_GetSessionStats_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_Admin_GetSessionStats_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) return nil } @@ -783,6 +913,8 @@ var ( pattern_Admin_ListTokens_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "admin", "tokens"}, "")) pattern_Admin_RevokeToken_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 1, 5, 3}, []string{"api", "admin", "tokens", "token_id"}, "")) pattern_Admin_GetStats_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "admin", "stats"}, "")) + pattern_Admin_ListSessions_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "admin", "sessions"}, "")) + pattern_Admin_GetSessionStats_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3}, []string{"api", "admin", "sessions", "stats"}, "")) ) var ( @@ -796,4 +928,6 @@ var ( forward_Admin_ListTokens_0 = runtime.ForwardResponseMessage forward_Admin_RevokeToken_0 = runtime.ForwardResponseMessage forward_Admin_GetStats_0 = runtime.ForwardResponseMessage + forward_Admin_ListSessions_0 = runtime.ForwardResponseMessage + forward_Admin_GetSessionStats_0 = runtime.ForwardResponseMessage ) diff --git a/adminrpc/admin.proto b/adminrpc/admin.proto index af25ae45..9808468a 100644 --- a/adminrpc/admin.proto +++ b/adminrpc/admin.proto @@ -16,6 +16,16 @@ service Admin { rpc ListTokens(ListTokensRequest) returns (ListTokensResponse); rpc RevokeToken(RevokeTokenRequest) returns (RevokeTokenResponse); rpc GetStats(GetStatsRequest) returns (GetStatsResponse); + + // ListSessions returns MPP prepaid sessions (open + closed). Amounts + // are in the chain's base unit — clients pair with GetInfo.chain to + // decide display units. + rpc ListSessions(ListSessionsRequest) returns (ListSessionsResponse); + + // GetSessionStats returns aggregate counters across all MPP sessions + // (count by status, total deposits/spent, open balance). Complements + // GetStats which only covers L402 charge-intent transactions. + rpc GetSessionStats(GetSessionStatsRequest) returns (GetSessionStatsResponse); } message GetInfoRequest {} @@ -164,3 +174,63 @@ message ServiceRevenue { string service_name = 1; int64 total_revenue_sats = 2; } + +// MPPSession mirrors the on-server session record for the admin API. +// Payment hash is hex-encoded. Amounts are in the chain's base unit. +message MPPSession { + string session_id = 1; + string payment_hash = 2; + int64 deposit_sats = 3; + int64 spent_sats = 4; + + // balance_sats is deposit_sats - spent_sats. Meaningful for open + // sessions; on closed ones this is what was refunded at close time. + int64 balance_sats = 5; + + string return_invoice = 6; + + // status is "open" or "closed". + string status = 7; + + // created_at / updated_at are RFC 3339 timestamps. + string created_at = 8; + string updated_at = 9; +} + +message ListSessionsRequest { + // status filters by "open" or "closed". Empty returns all. + string status = 1; + + // limit defaults to 50 when zero. Max is 1000. + int32 limit = 2; + + // offset is 0-based. + int32 offset = 3; +} + +message ListSessionsResponse { + repeated MPPSession sessions = 1; + + // total is the count matching status filter, ignoring pagination. + int64 total = 2; +} + +message GetSessionStatsRequest {} + +message GetSessionStatsResponse { + int64 total_sessions = 1; + int64 open_sessions = 2; + int64 closed_sessions = 3; + + // total_deposit_sats is the lifetime sum of deposits across all + // sessions (open + closed). + int64 total_deposit_sats = 4; + + // total_spent_sats is the actual revenue — satoshis consumed by + // bearer requests. + int64 total_spent_sats = 5; + + // open_balance_sats is the sum of deposit_sats - spent_sats over + // sessions still in the open state (prepaid balance owed to clients). + int64 open_balance_sats = 6; +} diff --git a/adminrpc/admin.swagger.json b/adminrpc/admin.swagger.json index 6035f3ac..b2cd47bd 100644 --- a/adminrpc/admin.swagger.json +++ b/adminrpc/admin.swagger.json @@ -178,6 +178,77 @@ ] } }, + "/api/admin/sessions": { + "get": { + "summary": "ListSessions returns MPP prepaid sessions (open + closed). Amounts\nare in the chain's base unit — clients pair with GetInfo.chain to\ndecide display units.", + "operationId": "Admin_ListSessions", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/adminrpcListSessionsResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "parameters": [ + { + "name": "status", + "description": "status filters by \"open\" or \"closed\". Empty returns all.", + "in": "query", + "required": false, + "type": "string" + }, + { + "name": "limit", + "description": "limit defaults to 50 when zero. Max is 1000.", + "in": "query", + "required": false, + "type": "integer", + "format": "int32" + }, + { + "name": "offset", + "description": "offset is 0-based.", + "in": "query", + "required": false, + "type": "integer", + "format": "int32" + } + ], + "tags": [ + "Admin" + ] + } + }, + "/api/admin/sessions/stats": { + "get": { + "summary": "GetSessionStats returns aggregate counters across all MPP sessions\n(count by status, total deposits/spent, open balance). Complements\nGetStats which only covers L402 charge-intent transactions.", + "operationId": "Admin_GetSessionStats", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/adminrpcGetSessionStatsResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "tags": [ + "Admin" + ] + } + }, "/api/admin/stats": { "get": { "operationId": "Admin_GetStats", @@ -461,6 +532,38 @@ } } }, + "adminrpcGetSessionStatsResponse": { + "type": "object", + "properties": { + "total_sessions": { + "type": "string", + "format": "int64" + }, + "open_sessions": { + "type": "string", + "format": "int64" + }, + "closed_sessions": { + "type": "string", + "format": "int64" + }, + "total_deposit_sats": { + "type": "string", + "format": "int64", + "description": "total_deposit_sats is the lifetime sum of deposits across all\nsessions (open + closed)." + }, + "total_spent_sats": { + "type": "string", + "format": "int64", + "description": "total_spent_sats is the actual revenue — satoshis consumed by\nbearer requests." + }, + "open_balance_sats": { + "type": "string", + "format": "int64", + "description": "open_balance_sats is the sum of deposit_sats - spent_sats over\nsessions still in the open state (prepaid balance owed to clients)." + } + } + }, "adminrpcGetStatsResponse": { "type": "object", "properties": { @@ -493,6 +596,23 @@ } } }, + "adminrpcListSessionsResponse": { + "type": "object", + "properties": { + "sessions": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/adminrpcMPPSession" + } + }, + "total": { + "type": "string", + "format": "int64", + "description": "total is the count matching status filter, ignoring pagination." + } + } + }, "adminrpcListTokensResponse": { "type": "object", "properties": { @@ -525,6 +645,45 @@ } } }, + "adminrpcMPPSession": { + "type": "object", + "properties": { + "session_id": { + "type": "string" + }, + "payment_hash": { + "type": "string" + }, + "deposit_sats": { + "type": "string", + "format": "int64" + }, + "spent_sats": { + "type": "string", + "format": "int64" + }, + "balance_sats": { + "type": "string", + "format": "int64", + "description": "balance_sats is deposit_sats - spent_sats. Meaningful for open\nsessions; on closed ones this is what was refunded at close time." + }, + "return_invoice": { + "type": "string" + }, + "status": { + "type": "string", + "description": "status is \"open\" or \"closed\"." + }, + "created_at": { + "type": "string", + "description": "created_at / updated_at are RFC 3339 timestamps." + }, + "updated_at": { + "type": "string" + } + }, + "description": "MPPSession mirrors the on-server session record for the admin API.\nPayment hash is hex-encoded. Amounts are in the chain's base unit." + }, "adminrpcRevokeTokenResponse": { "type": "object", "properties": { diff --git a/adminrpc/admin.yaml b/adminrpc/admin.yaml index bfca641a..29613537 100644 --- a/adminrpc/admin.yaml +++ b/adminrpc/admin.yaml @@ -25,3 +25,7 @@ http: delete: "/api/admin/tokens/{token_id}" - selector: adminrpc.Admin.GetStats get: "/api/admin/stats" + - selector: adminrpc.Admin.ListSessions + get: "/api/admin/sessions" + - selector: adminrpc.Admin.GetSessionStats + get: "/api/admin/sessions/stats" diff --git a/adminrpc/admin_grpc.pb.go b/adminrpc/admin_grpc.pb.go index c057193b..6798bcca 100644 --- a/adminrpc/admin_grpc.pb.go +++ b/adminrpc/admin_grpc.pb.go @@ -29,6 +29,8 @@ const ( Admin_ListTokens_FullMethodName = "/adminrpc.Admin/ListTokens" Admin_RevokeToken_FullMethodName = "/adminrpc.Admin/RevokeToken" Admin_GetStats_FullMethodName = "/adminrpc.Admin/GetStats" + Admin_ListSessions_FullMethodName = "/adminrpc.Admin/ListSessions" + Admin_GetSessionStats_FullMethodName = "/adminrpc.Admin/GetSessionStats" ) // AdminClient is the client API for Admin service. @@ -45,6 +47,14 @@ type AdminClient interface { ListTokens(ctx context.Context, in *ListTokensRequest, opts ...grpc.CallOption) (*ListTokensResponse, error) RevokeToken(ctx context.Context, in *RevokeTokenRequest, opts ...grpc.CallOption) (*RevokeTokenResponse, error) GetStats(ctx context.Context, in *GetStatsRequest, opts ...grpc.CallOption) (*GetStatsResponse, error) + // ListSessions returns MPP prepaid sessions (open + closed). Amounts + // are in the chain's base unit — clients pair with GetInfo.chain to + // decide display units. + ListSessions(ctx context.Context, in *ListSessionsRequest, opts ...grpc.CallOption) (*ListSessionsResponse, error) + // GetSessionStats returns aggregate counters across all MPP sessions + // (count by status, total deposits/spent, open balance). Complements + // GetStats which only covers L402 charge-intent transactions. + GetSessionStats(ctx context.Context, in *GetSessionStatsRequest, opts ...grpc.CallOption) (*GetSessionStatsResponse, error) } type adminClient struct { @@ -155,6 +165,26 @@ func (c *adminClient) GetStats(ctx context.Context, in *GetStatsRequest, opts .. return out, nil } +func (c *adminClient) ListSessions(ctx context.Context, in *ListSessionsRequest, opts ...grpc.CallOption) (*ListSessionsResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListSessionsResponse) + err := c.cc.Invoke(ctx, Admin_ListSessions_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *adminClient) GetSessionStats(ctx context.Context, in *GetSessionStatsRequest, opts ...grpc.CallOption) (*GetSessionStatsResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetSessionStatsResponse) + err := c.cc.Invoke(ctx, Admin_GetSessionStats_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // AdminServer is the server API for Admin service. // All implementations must embed UnimplementedAdminServer // for forward compatibility. @@ -169,6 +199,14 @@ type AdminServer interface { ListTokens(context.Context, *ListTokensRequest) (*ListTokensResponse, error) RevokeToken(context.Context, *RevokeTokenRequest) (*RevokeTokenResponse, error) GetStats(context.Context, *GetStatsRequest) (*GetStatsResponse, error) + // ListSessions returns MPP prepaid sessions (open + closed). Amounts + // are in the chain's base unit — clients pair with GetInfo.chain to + // decide display units. + ListSessions(context.Context, *ListSessionsRequest) (*ListSessionsResponse, error) + // GetSessionStats returns aggregate counters across all MPP sessions + // (count by status, total deposits/spent, open balance). Complements + // GetStats which only covers L402 charge-intent transactions. + GetSessionStats(context.Context, *GetSessionStatsRequest) (*GetSessionStatsResponse, error) mustEmbedUnimplementedAdminServer() } @@ -209,6 +247,12 @@ func (UnimplementedAdminServer) RevokeToken(context.Context, *RevokeTokenRequest func (UnimplementedAdminServer) GetStats(context.Context, *GetStatsRequest) (*GetStatsResponse, error) { return nil, status.Error(codes.Unimplemented, "method GetStats not implemented") } +func (UnimplementedAdminServer) ListSessions(context.Context, *ListSessionsRequest) (*ListSessionsResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListSessions not implemented") +} +func (UnimplementedAdminServer) GetSessionStats(context.Context, *GetSessionStatsRequest) (*GetSessionStatsResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetSessionStats not implemented") +} func (UnimplementedAdminServer) mustEmbedUnimplementedAdminServer() {} func (UnimplementedAdminServer) testEmbeddedByValue() {} @@ -410,6 +454,42 @@ func _Admin_GetStats_Handler(srv interface{}, ctx context.Context, dec func(inte return interceptor(ctx, in, info, handler) } +func _Admin_ListSessions_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListSessionsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AdminServer).ListSessions(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Admin_ListSessions_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AdminServer).ListSessions(ctx, req.(*ListSessionsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _Admin_GetSessionStats_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetSessionStatsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AdminServer).GetSessionStats(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Admin_GetSessionStats_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AdminServer).GetSessionStats(ctx, req.(*GetSessionStatsRequest)) + } + return interceptor(ctx, in, info, handler) +} + // Admin_ServiceDesc is the grpc.ServiceDesc for Admin service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -457,6 +537,14 @@ var Admin_ServiceDesc = grpc.ServiceDesc{ MethodName: "GetStats", Handler: _Admin_GetStats_Handler, }, + { + MethodName: "ListSessions", + Handler: _Admin_ListSessions_Handler, + }, + { + MethodName: "GetSessionStats", + Handler: _Admin_GetSessionStats_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "admin.proto", diff --git a/aperture.go b/aperture.go index c555ce3f..dc8e5b87 100644 --- a/aperture.go +++ b/aperture.go @@ -248,13 +248,16 @@ func (a *Aperture) Start(errChan chan error, shutdown <-chan struct{}) error { }() } + // mppSessionStore is held as the concrete *MPPSessionsStore so the + // admin API can surface session lists / stats. It still satisfies + // the auth.SessionStore interface used by the MPP authenticator. var ( secretStore mint.SecretStore onionStore tor.OnionStore lncStore lnc.Store txnStore *aperturedb.L402TransactionsStore svcStore *aperturedb.ServicesStore - mppSessionStore auth.SessionStore + mppSessionStore *aperturedb.MPPSessionsStore ) // Connect to the chosen database backend. @@ -435,6 +438,7 @@ func (a *Aperture) Start(errChan chan error, shutdown <-chan struct{}) error { adminPriority, adminFallback, adminCleanup, err := createAdminServer( a.cfg, txnStore, secretStore, svcStore, + mppSessionStore, a.lndChain, svcHolder.get, func(s []*proxy.Service) error { @@ -1044,6 +1048,7 @@ func createAdminServer(cfg *Config, txnStore *aperturedb.L402TransactionsStore, secretStore mint.SecretStore, svcStore *aperturedb.ServicesStore, + sessionStore *aperturedb.MPPSessionsStore, lndChain string, getServices func() []*proxy.Service, updateServices func([]*proxy.Service) error) ( @@ -1122,6 +1127,7 @@ func createAdminServer(cfg *Config, SessionsEnabled: cfg.Authenticator.EnableSessions, MPPRealm: cfg.Authenticator.MPPRealm, Chain: lndChain, + SessionStore: sessionStore, }) adminGRPC := grpc.NewServer(serverOpts...) @@ -1395,6 +1401,10 @@ func createAdminServer(cfg *Config, "name": func(v string) bool { return safeQueryValue(v, 128) }, + // MPP session list filter: "open" | "closed" | "". + "status": func(v string) bool { + return safeQueryValue(v, 16) + }, } safeSegment := regexp.MustCompile(`^[\w-]+$`) diff --git a/aperturedb/mpp_sessions.go b/aperturedb/mpp_sessions.go index 3f04a08f..f223a92d 100644 --- a/aperturedb/mpp_sessions.go +++ b/aperturedb/mpp_sessions.go @@ -45,6 +45,21 @@ type MPPSessionsDB interface { // returns the remaining balance (deposit_sats - spent_sats). CloseMPPSessionReturningBalance(ctx context.Context, arg sqlc.CloseMPPSessionReturningBalanceParams) (int64, error) + + // ListMPPSessions returns sessions filtered by optional status, most + // recent first, with paginated results. + ListMPPSessions(ctx context.Context, + arg sqlc.ListMPPSessionsParams) ([]sqlc.MppSession, error) + + // CountMPPSessions returns the total number of sessions matching the + // optional status filter. Used to paginate the list endpoint. + CountMPPSessions(ctx context.Context, + filterStatus interface{}) (int64, error) + + // GetMPPSessionAggregateStats returns aggregate counters across all + // sessions (deposits, spent, remaining balance on open sessions). + GetMPPSessionAggregateStats(ctx context.Context) ( + sqlc.GetMPPSessionAggregateStatsRow, error) } // MPPSessionsTxOptions defines the set of db txn options the @@ -325,3 +340,117 @@ func (s *MPPSessionsStore) CloseSessionAndGetBalance(ctx context.Context, return remainingBalance, nil } + +// MPPSessionStats aggregates counters across all MPP sessions for the admin +// dashboard. Amounts are in the chain's base unit (sats for bitcoin, MIST +// for sui) — the UI is responsible for display scaling via admin GetInfo. +type MPPSessionStats struct { + // TotalSessions is the total number of sessions ever created. + TotalSessions int64 + + // OpenSessions is the current number of sessions in the "open" state + // (i.e., still accepting bearer / topUp requests). + OpenSessions int64 + + // ClosedSessions is the number of sessions that have been explicitly + // closed (refund attempted, no further activity accepted). + ClosedSessions int64 + + // TotalDepositSats is the sum of deposit_sats across all sessions — + // every satoshi ever locked up via open or topUp, regardless of + // whether it was later spent or refunded. + TotalDepositSats int64 + + // TotalSpentSats is the sum of spent_sats across all sessions — how + // much has been consumed by bearer requests (your actual revenue). + TotalSpentSats int64 + + // OpenBalanceSats is the sum of (deposit - spent) over the sessions + // still in the "open" state — prepaid balance the server currently + // owes back to clients on close. + OpenBalanceSats int64 +} + +// ListSessions returns sessions sorted most-recent-first. An empty +// statusFilter returns both open and closed sessions; passing "open" or +// "closed" narrows the result set. The returned total is the full count +// (not limit-bounded) for pagination UI. +func (s *MPPSessionsStore) ListSessions(ctx context.Context, + statusFilter string, limit, offset int32) ( + []*auth.Session, int64, error) { + + var ( + sessions []*auth.Session + total int64 + ) + + readOpts := NewMPPSessionsReadTx() + err := s.db.ExecTx(ctx, &readOpts, func(tx MPPSessionsDB) error { + rows, err := tx.ListMPPSessions(ctx, sqlc.ListMPPSessionsParams{ + FilterStatus: statusFilter, + RowLimit: limit, + RowOffset: offset, + }) + if err != nil { + return err + } + + sessions = make([]*auth.Session, 0, len(rows)) + for _, row := range rows { + hash, err := lntypes.MakeHash(row.PaymentHash) + if err != nil { + return fmt.Errorf("invalid payment hash in "+ + "row id=%d: %w", row.ID, err) + } + sessions = append(sessions, &auth.Session{ + SessionID: row.SessionID, + PaymentHash: hash, + DepositSats: row.DepositSats, + SpentSats: row.SpentSats, + ReturnInvoice: row.ReturnInvoice, + Status: row.Status, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + }) + } + + total, err = tx.CountMPPSessions(ctx, statusFilter) + return err + }) + + if err != nil { + return nil, 0, fmt.Errorf("unable to list MPP sessions: %w", + err) + } + + return sessions, total, nil +} + +// GetStats returns aggregate counters across all sessions. +func (s *MPPSessionsStore) GetStats(ctx context.Context) ( + *MPPSessionStats, error) { + + var stats MPPSessionStats + + readOpts := NewMPPSessionsReadTx() + err := s.db.ExecTx(ctx, &readOpts, func(tx MPPSessionsDB) error { + row, err := tx.GetMPPSessionAggregateStats(ctx) + if err != nil { + return err + } + stats.TotalSessions = row.TotalSessions + stats.OpenSessions = row.OpenSessions + stats.ClosedSessions = row.ClosedSessions + stats.TotalDepositSats = row.TotalDepositSats + stats.TotalSpentSats = row.TotalSpentSats + stats.OpenBalanceSats = row.OpenBalanceSats + return nil + }) + + if err != nil { + return nil, fmt.Errorf("unable to get MPP session stats: %w", + err) + } + + return &stats, nil +} diff --git a/aperturedb/sqlc/mpp_sessions.sql.go b/aperturedb/sqlc/mpp_sessions.sql.go index 695ddeaf..76e420d0 100644 --- a/aperturedb/sqlc/mpp_sessions.sql.go +++ b/aperturedb/sqlc/mpp_sessions.sql.go @@ -45,6 +45,62 @@ func (q *Queries) CloseMPPSessionReturningBalance(ctx context.Context, arg Close return column_1, err } +const countMPPSessions = `-- name: CountMPPSessions :one +SELECT count(*) +FROM mpp_sessions +WHERE ($1 = '' OR status = $1) +` + +func (q *Queries) CountMPPSessions(ctx context.Context, filterStatus interface{}) (int64, error) { + row := q.db.QueryRowContext(ctx, countMPPSessions, filterStatus) + var count int64 + err := row.Scan(&count) + return count, err +} + +const getMPPSessionAggregateStats = `-- name: GetMPPSessionAggregateStats :one +SELECT + CAST(COUNT(*) AS BIGINT) AS total_sessions, + CAST(COALESCE(SUM( + CASE WHEN status = 'open' THEN 1 ELSE 0 END + ), 0) AS BIGINT) AS open_sessions, + CAST(COALESCE(SUM( + CASE WHEN status = 'closed' THEN 1 ELSE 0 END + ), 0) AS BIGINT) AS closed_sessions, + CAST(COALESCE(SUM(deposit_sats), 0) AS BIGINT) AS total_deposit_sats, + CAST(COALESCE(SUM(spent_sats), 0) AS BIGINT) AS total_spent_sats, + CAST(COALESCE(SUM( + CASE WHEN status = 'open' THEN deposit_sats - spent_sats ELSE 0 END + ), 0) AS BIGINT) AS open_balance_sats +FROM mpp_sessions +` + +type GetMPPSessionAggregateStatsRow struct { + TotalSessions int64 + OpenSessions int64 + ClosedSessions int64 + TotalDepositSats int64 + TotalSpentSats int64 + OpenBalanceSats int64 +} + +// Uses only portable SQL (no COUNT FILTER) so the same query runs against +// both postgres and sqlite. Casts to BIGINT to give sqlc a consistent +// int64 type on both drivers. +func (q *Queries) GetMPPSessionAggregateStats(ctx context.Context) (GetMPPSessionAggregateStatsRow, error) { + row := q.db.QueryRowContext(ctx, getMPPSessionAggregateStats) + var i GetMPPSessionAggregateStatsRow + err := row.Scan( + &i.TotalSessions, + &i.OpenSessions, + &i.ClosedSessions, + &i.TotalDepositSats, + &i.TotalSpentSats, + &i.OpenBalanceSats, + ) + return i, err +} + const getMPPSessionByID = `-- name: GetMPPSessionByID :one SELECT id, session_id, payment_hash, deposit_sats, spent_sats, return_invoice, status, created_at, updated_at FROM mpp_sessions @@ -104,6 +160,53 @@ func (q *Queries) InsertMPPSession(ctx context.Context, arg InsertMPPSessionPara return id, err } +const listMPPSessions = `-- name: ListMPPSessions :many +SELECT id, session_id, payment_hash, deposit_sats, spent_sats, return_invoice, status, created_at, updated_at +FROM mpp_sessions +WHERE ($1 = '' OR status = $1) +ORDER BY created_at DESC +LIMIT $3 OFFSET $2 +` + +type ListMPPSessionsParams struct { + FilterStatus interface{} + RowOffset int32 + RowLimit int32 +} + +func (q *Queries) ListMPPSessions(ctx context.Context, arg ListMPPSessionsParams) ([]MppSession, error) { + rows, err := q.db.QueryContext(ctx, listMPPSessions, arg.FilterStatus, arg.RowOffset, arg.RowLimit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []MppSession + for rows.Next() { + var i MppSession + if err := rows.Scan( + &i.ID, + &i.SessionID, + &i.PaymentHash, + &i.DepositSats, + &i.SpentSats, + &i.ReturnInvoice, + &i.Status, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const updateMPPSessionDeposit = `-- name: UpdateMPPSessionDeposit :execresult UPDATE mpp_sessions SET deposit_sats = deposit_sats + $1, updated_at = $2 diff --git a/aperturedb/sqlc/querier.go b/aperturedb/sqlc/querier.go index 031ea98e..f6ba9369 100644 --- a/aperturedb/sqlc/querier.go +++ b/aperturedb/sqlc/querier.go @@ -16,6 +16,7 @@ type Querier interface { CountL402TransactionsByDateRange(ctx context.Context, arg CountL402TransactionsByDateRangeParams) (int64, error) CountL402TransactionsByService(ctx context.Context, serviceName string) (int64, error) CountL402TransactionsFiltered(ctx context.Context, arg CountL402TransactionsFilteredParams) (int64, error) + CountMPPSessions(ctx context.Context, filterStatus interface{}) (int64, error) DeleteL402TransactionByTokenID(ctx context.Context, tokenID []byte) (int64, error) DeleteOnionPrivateKey(ctx context.Context) error DeleteSecretByHash(ctx context.Context, hash []byte) (int64, error) @@ -27,6 +28,10 @@ type Querier interface { GetL402TotalRevenueByDateRange(ctx context.Context, arg GetL402TotalRevenueByDateRangeParams) (int64, error) GetL402TransactionByIdentifierHash(ctx context.Context, identifierHash []byte) (L402Transaction, error) GetL402TransactionsByPaymentHash(ctx context.Context, paymentHash []byte) ([]L402Transaction, error) + // Uses only portable SQL (no COUNT FILTER) so the same query runs against + // both postgres and sqlite. Casts to BIGINT to give sqlc a consistent + // int64 type on both drivers. + GetMPPSessionAggregateStats(ctx context.Context) (GetMPPSessionAggregateStatsRow, error) GetMPPSessionByID(ctx context.Context, sessionID string) (MppSession, error) GetSecretByHash(ctx context.Context, hash []byte) ([]byte, error) GetSession(ctx context.Context, passphraseEntropy []byte) (LncSession, error) @@ -39,6 +44,7 @@ type Querier interface { ListL402TransactionsByService(ctx context.Context, arg ListL402TransactionsByServiceParams) ([]L402Transaction, error) ListL402TransactionsByState(ctx context.Context, arg ListL402TransactionsByStateParams) ([]L402Transaction, error) ListL402TransactionsFiltered(ctx context.Context, arg ListL402TransactionsFilteredParams) ([]L402Transaction, error) + ListMPPSessions(ctx context.Context, arg ListMPPSessionsParams) ([]MppSession, error) ListServices(ctx context.Context) ([]Service, error) SelectOnionPrivateKey(ctx context.Context) ([]byte, error) SetExpiry(ctx context.Context, arg SetExpiryParams) error diff --git a/aperturedb/sqlc/queries/mpp_sessions.sql b/aperturedb/sqlc/queries/mpp_sessions.sql index f7eaa911..a19b2e54 100644 --- a/aperturedb/sqlc/queries/mpp_sessions.sql +++ b/aperturedb/sqlc/queries/mpp_sessions.sql @@ -33,3 +33,34 @@ UPDATE mpp_sessions SET status = 'closed', updated_at = $1 WHERE session_id = $2 AND status = 'open' RETURNING CAST(deposit_sats - spent_sats AS BIGINT); + +-- name: ListMPPSessions :many +SELECT * +FROM mpp_sessions +WHERE (sqlc.arg(filter_status) = '' OR status = sqlc.arg(filter_status)) +ORDER BY created_at DESC +LIMIT sqlc.arg(row_limit) OFFSET sqlc.arg(row_offset); + +-- name: CountMPPSessions :one +SELECT count(*) +FROM mpp_sessions +WHERE (sqlc.arg(filter_status) = '' OR status = sqlc.arg(filter_status)); + +-- name: GetMPPSessionAggregateStats :one +-- Uses only portable SQL (no COUNT FILTER) so the same query runs against +-- both postgres and sqlite. Casts to BIGINT to give sqlc a consistent +-- int64 type on both drivers. +SELECT + CAST(COUNT(*) AS BIGINT) AS total_sessions, + CAST(COALESCE(SUM( + CASE WHEN status = 'open' THEN 1 ELSE 0 END + ), 0) AS BIGINT) AS open_sessions, + CAST(COALESCE(SUM( + CASE WHEN status = 'closed' THEN 1 ELSE 0 END + ), 0) AS BIGINT) AS closed_sessions, + CAST(COALESCE(SUM(deposit_sats), 0) AS BIGINT) AS total_deposit_sats, + CAST(COALESCE(SUM(spent_sats), 0) AS BIGINT) AS total_spent_sats, + CAST(COALESCE(SUM( + CASE WHEN status = 'open' THEN deposit_sats - spent_sats ELSE 0 END + ), 0) AS BIGINT) AS open_balance_sats +FROM mpp_sessions; diff --git a/dashboard/app/layout.tsx b/dashboard/app/layout.tsx index b677f4b2..c3834efa 100644 --- a/dashboard/app/layout.tsx +++ b/dashboard/app/layout.tsx @@ -9,12 +9,14 @@ import ThemeProvider from "@/components/ThemeProvider"; import { ToastContainer } from "@/components/Toast"; import { useInfo } from "@/lib/api"; -const navItems = [ +const baseNavItems: { href: string; label: string }[] = [ { href: "/", label: "Dashboard" }, { href: "/services", label: "Services" }, { href: "/transactions", label: "Transactions" }, ]; +const sessionsNavItem = { href: "/sessions", label: "Sessions" }; + const sfp = { shouldForwardProp: (prop: string) => !prop.startsWith("$"), }; @@ -153,6 +155,17 @@ function LayoutInner({ children }: { children: React.ReactNode }) { [pathname] ); + // Only show the Sessions tab when the server has MPP prepaid sessions + // enabled — otherwise /sessions returns 501 and the page would render + // an empty state for no reason. + const navItems = useMemo(() => { + const items = [...baseNavItems]; + if (info?.mpp_enabled && info?.sessions_enabled) { + items.push(sessionsNavItem); + } + return items; + }, [info?.mpp_enabled, info?.sessions_enabled]); + const links = useMemo( () => navItems.map((item) => ( @@ -164,7 +177,7 @@ function LayoutInner({ children }: { children: React.ReactNode }) { {item.label} )), - [isActive] + [isActive, navItems] ); const { Body, Nav, Brand, NetworkBadge, NavLinks, Status, StatusDot, Main } = diff --git a/dashboard/app/sessions/page.tsx b/dashboard/app/sessions/page.tsx new file mode 100644 index 00000000..309bca47 --- /dev/null +++ b/dashboard/app/sessions/page.tsx @@ -0,0 +1,331 @@ +"use client"; + +import { useState, useMemo } from "react"; +import styled from "@emotion/styled"; +import { useSessions, useSessionStats, useInfo } from "@/lib/api"; +import { formatAmount, unitLabel } from "@/lib/currency"; +import PageHeader from "@/components/PageHeader"; +import StatTile from "@/components/StatTile"; +import EmptyState from "@/components/EmptyState"; +import ErrorBanner from "@/components/ErrorBanner"; + +const PAGE_SIZE = 20; + +const sfp = { + shouldForwardProp: (prop: string) => !prop.startsWith("$"), +}; + +const Styled = { + StatGrid: styled.div` + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 16px; + margin-bottom: 24px; + `, + Filters: styled.div` + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 16px; + font-size: 13px; + color: ${(p) => p.theme.colors.white}; + `, + FilterBtn: styled.button<{ $active?: boolean }>` + background: ${(p) => + p.$active + ? "rgba(93, 95, 239, 0.18)" + : "rgba(245, 245, 245, 0.04)"}; + border: 1px solid + ${(p) => (p.$active ? p.theme.colors.purple : "transparent")}; + color: ${(p) => (p.$active ? p.theme.colors.white : "#848a99")}; + padding: 6px 12px; + border-radius: 4px; + font-size: 13px; + cursor: pointer; + transition: all 0.15s ease; + &:hover { + color: ${(p) => p.theme.colors.white}; + } + `, + TableWrap: styled.div` + background-color: #1d253a; + border-radius: 8px; + overflow: hidden; + `, + Table: styled.table` + width: 100%; + border-collapse: collapse; + font-size: 13px; + font-variant-numeric: tabular-nums; + `, + Th: styled.th` + padding: 12px 16px; + text-align: left; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: #848a99; + border-bottom: 1px solid #252f4a; + `, + Td: styled.td` + padding: 12px 16px; + border-bottom: 1px solid #252f4a; + color: ${(p) => p.theme.colors.offWhite}; + `, + MonoCell: styled.td` + padding: 12px 16px; + border-bottom: 1px solid #252f4a; + color: #848a99; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; + `, + StatusBadge: styled.span<{ $open?: boolean }>` + display: inline-block; + padding: 2px 8px; + border-radius: 10px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.3px; + color: ${(p) => (p.$open ? "#4ade80" : "#848a99")}; + background: ${(p) => + p.$open ? "rgba(74, 222, 128, 0.12)" : "rgba(132, 138, 153, 0.15)"}; + `, + Pagination: styled.div` + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 16px; + color: #848a99; + font-size: 13px; + `, + PageBtn: styled.button` + background: rgba(245, 245, 245, 0.04); + color: ${(p) => p.theme.colors.white}; + border: none; + padding: 6px 14px; + border-radius: 4px; + font-size: 13px; + cursor: pointer; + &:disabled { + opacity: 0.35; + cursor: default; + } + &:not(:disabled):hover { + background: rgba(245, 245, 245, 0.08); + } + `, + Hint: styled.p` + color: #848a99; + font-size: 13px; + margin: 24px 0; + line-height: 1.6; + `, +}; + +export default function SessionsPage() { + const [status, setStatus] = useState(""); + const [offset, setOffset] = useState(0); + + const { data: info } = useInfo(); + const chain = info?.chain; + + const { + data: sessions, + isLoading, + error, + } = useSessions({ status: status || undefined, limit: PAGE_SIZE, offset }); + const { data: stats } = useSessionStats(); + + // Server responds 501 when sessions are disabled; the hook maps that + // to `null`. Show a clear explanation rather than a broken UI. + if (sessions === null || stats === null) { + return ( + <> + + + To enable sessions, set{" "} + authenticator.enablempp: true and{" "} + authenticator.enablesessions: true in your prism + config and restart. + + + ); + } + + const total = sessions?.total ?? 0; + const rows = sessions?.sessions ?? []; + + const prevDisabled = offset === 0; + const nextDisabled = offset + PAGE_SIZE >= total; + + const statCards = useMemo( + () => [ + { + label: "Open Sessions", + value: stats ? String(stats.open_sessions) : "\u2014", + }, + { + label: "Total Sessions", + value: stats ? String(stats.total_sessions) : "\u2014", + }, + { + label: "Revenue (spent)", + value: stats + ? formatAmount(stats.total_spent_sats, chain).value + : "\u2014", + suffix: unitLabel(chain), + }, + { + label: "Open Balance (owed)", + value: stats + ? formatAmount(stats.open_balance_sats, chain).value + : "\u2014", + suffix: unitLabel(chain), + }, + ], + [stats, chain] + ); + + return ( + <> + + + + {statCards.map((s) => ( + + ))} + + + {error ? : null} + + + Filter: + { + setStatus(""); + setOffset(0); + }} + > + All + + { + setStatus("open"); + setOffset(0); + }} + > + Open + + { + setStatus("closed"); + setOffset(0); + }} + > + Closed + + + + {isLoading ? ( + + ) : rows.length === 0 ? ( + + ) : ( + <> + + + + + Session ID + Status + Deposit + Spent + Balance + Created + + + + {rows.map((s) => ( + + + {s.session_id.slice(0, 16)}… + + + + {s.status} + + + + {formatAmount(s.deposit_sats, chain).value}{" "} + {unitLabel(chain)} + + + {formatAmount(s.spent_sats, chain).value}{" "} + {unitLabel(chain)} + + + {formatAmount(s.balance_sats, chain).value}{" "} + {unitLabel(chain)} + + + {new Date(s.created_at).toLocaleString()} + + + ))} + + + + + + + Showing {offset + 1}– + {Math.min(offset + PAGE_SIZE, total)} of {total} + +
+ + setOffset(Math.max(0, offset - PAGE_SIZE)) + } + > + Prev + + setOffset(offset + PAGE_SIZE)} + > + Next + +
+
+ + )} + + ); +} diff --git a/dashboard/lib/api.ts b/dashboard/lib/api.ts index 374ba2b6..63295365 100644 --- a/dashboard/lib/api.ts +++ b/dashboard/lib/api.ts @@ -7,6 +7,10 @@ import type { TransactionParams, InfoResponse, AuthScheme, + ListSessionsParams, + ListSessionsResponse, + MPPSession, + SessionStatsResponse, } from "./types"; async function fetcher(path: string): Promise { @@ -150,3 +154,71 @@ export async function deleteService(name: string) { await mutate(SERVICES_KEY); return res.json(); } + +/** + * useSessions fetches the MPP prepaid session list. Returns null if + * sessions are disabled on the server (the endpoint responds 501). + */ +export function useSessions(params: ListSessionsParams = {}) { + const sp = new URLSearchParams(); + if (params.limit) sp.set("limit", String(params.limit)); + if (params.offset) sp.set("offset", String(params.offset)); + if (params.status) sp.set("status", params.status); + const qs = sp.toString(); + const key = `/api/proxy/sessions${qs ? `?${qs}` : ""}`; + + return useSWR( + key, + async (path: string) => { + const res = await fetch(path); + if (res.status === 501) return null; // sessions disabled + if (!res.ok) throw new Error(`API error: ${res.status}`); + const raw = (await res.json()) as { + sessions?: Array>; + total?: number | string; + }; + return { + sessions: (raw.sessions ?? []).map( + (s): MPPSession => ({ + session_id: String(s.session_id ?? ""), + payment_hash: String(s.payment_hash ?? ""), + deposit_sats: Number(s.deposit_sats ?? 0), + spent_sats: Number(s.spent_sats ?? 0), + balance_sats: Number(s.balance_sats ?? 0), + return_invoice: String(s.return_invoice ?? ""), + status: String(s.status ?? ""), + created_at: String(s.created_at ?? ""), + updated_at: String(s.updated_at ?? ""), + }) + ), + total: Number(raw.total ?? 0), + }; + }, + { refreshInterval: 30_000 } + ); +} + +/** + * useSessionStats fetches aggregate counters across MPP sessions. Returns + * null if sessions are disabled on the server. + */ +export function useSessionStats() { + return useSWR( + "/api/proxy/sessions/stats", + async (path: string) => { + const res = await fetch(path); + if (res.status === 501) return null; + if (!res.ok) throw new Error(`API error: ${res.status}`); + const r = (await res.json()) as Record; + return { + total_sessions: Number(r.total_sessions ?? 0), + open_sessions: Number(r.open_sessions ?? 0), + closed_sessions: Number(r.closed_sessions ?? 0), + total_deposit_sats: Number(r.total_deposit_sats ?? 0), + total_spent_sats: Number(r.total_spent_sats ?? 0), + open_balance_sats: Number(r.open_balance_sats ?? 0), + }; + }, + { refreshInterval: 30_000 } + ); +} diff --git a/dashboard/lib/types.ts b/dashboard/lib/types.ts index 85772672..6c429dbf 100644 --- a/dashboard/lib/types.ts +++ b/dashboard/lib/types.ts @@ -74,3 +74,50 @@ export interface InfoResponse { * label shown in the UI (SUI vs sats). */ chain?: string; } + +/** + * MPP prepaid session snapshot returned from GET /api/admin/sessions. + * + * All *_sats fields are in the chain's base unit — satoshis for bitcoin, + * MIST for sui. The UI layer pairs these with InfoResponse.chain to + * decide display formatting (see lib/currency.ts). + */ +export interface MPPSession { + session_id: string; + payment_hash: string; + deposit_sats: number; + spent_sats: number; + /** deposit_sats - spent_sats. Remaining prepaid balance on an open + * session; equal to what was refunded at close time on a closed one. */ + balance_sats: number; + return_invoice: string; + /** "open" or "closed". */ + status: string; + created_at: string; + updated_at: string; +} + +export interface ListSessionsParams { + /** "open" | "closed" | undefined (no filter). */ + status?: string; + limit?: number; + offset?: number; +} + +export interface ListSessionsResponse { + sessions: MPPSession[]; + /** Count matching status filter, ignoring pagination. */ + total: number; +} + +export interface SessionStatsResponse { + total_sessions: number; + open_sessions: number; + closed_sessions: number; + /** Lifetime sum of deposits across all sessions. */ + total_deposit_sats: number; + /** Actual revenue — satoshis/MIST consumed by bearer requests. */ + total_spent_sats: number; + /** Prepaid balance still owed to clients on currently open sessions. */ + open_balance_sats: number; +} From c44e028d90847c0beadffd8b15de0712af47553f Mon Sep 17 00:00:00 2001 From: ai-chen2050 <1033467071@qq.com> Date: Thu, 23 Apr 2026 18:03:44 +0800 Subject: [PATCH 13/32] docs(api): document sessions endpoints, expired state, chain field docs/admin-api.md * Endpoint table picks up /api/admin/sessions and /api/admin/sessions/stats. * Transactions docs call out the new "expired" state (reconciled on prism startup from lnd.ListInvoices), note filtering by state=expired is supported, and explain the pending-accumulation pattern caused by dual-scheme challenges. * New "Sessions (MPP prepaid)" section with: 501 behavior when sessions are disabled, explicit units callout (chain's base unit, sats for bitcoin / MIST for sui), list + stats examples with full response JSON, field-level notes (balance_sats is precomputed, total_spent_sats is real revenue, etc.), plus a "why a separate endpoint" rationale that points readers at the sum formula for full-payment visibility across both L402 charges and session debits. * Info endpoint row mentions the chain field so readers know they can tell bitcoin vs sui deployments apart from GetInfo alone. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/admin-api.md | 134 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 132 insertions(+), 2 deletions(-) diff --git a/docs/admin-api.md b/docs/admin-api.md index f315252c..24bf62aa 100644 --- a/docs/admin-api.md +++ b/docs/admin-api.md @@ -46,7 +46,7 @@ The admin REST API is served under the `/api/admin/` prefix via gRPC-gateway. | Method | Path | Auth | Description | |--------|------|------|-------------| | GET | `/api/admin/health` | No | Health check, returns `{"status": "ok"}` | -| GET | `/api/admin/info` | Yes | Server info (network, listen address, insecure flag, MPP config) | +| GET | `/api/admin/info` | Yes | Server info (network, listen address, insecure flag, MPP config, chain) | | GET | `/api/admin/services` | Yes | List all configured proxy services | | POST | `/api/admin/services` | Yes | Create a new service | | PUT | `/api/admin/services/{name}` | Yes | Update an existing service (partial update) | @@ -55,6 +55,8 @@ The admin REST API is served under the `/api/admin/` prefix via gRPC-gateway. | GET | `/api/admin/tokens` | Yes | List active L402 tokens (settled transactions) | | DELETE | `/api/admin/tokens/{token_id}` | Yes | Revoke an L402 token | | GET | `/api/admin/stats` | Yes | Revenue statistics with optional date range | +| GET | `/api/admin/sessions` | Yes | List MPP prepaid sessions (filterable, paginated) | +| GET | `/api/admin/sessions/stats` | Yes | Aggregate counters across all MPP sessions | ## Service Management @@ -122,6 +124,22 @@ curl -H "Grpc-Metadata-Macaroon: $ADMIN_MAC" \ "http://localhost:8081/api/admin/transactions?limit=20&offset=0&service=my-api&state=settled" ``` +**Transaction states**: + +| State | Meaning | +|-------|---------| +| `pending` | Challenge issued; underlying Lightning invoice still `OPEN`. | +| `settled` | Invoice was paid; `settled_at` is populated. | +| `expired` | Invoice reached a terminal non-settled state (`CANCELED` or past expiry + unpaid). Prism reconciles this on startup by scanning `lnd.ListInvoices`. Live-stream reconciliation is not triggered because `SubscribeInvoices` does not publish `CANCELED` events. | + +Prism records a `pending` row per 402 challenge. When a service accepts +multiple auth schemes simultaneously (`l402+mpp`), each 402 produces two +rows; the client only pays one, so the other stays unpaid and eventually +transitions to `expired` once lnd flags the invoice as `CANCELED` (default +`expiry` 24h). Filter by `state=settled` for accounting and business +metrics. + + **Query Parameters**: | Param | Description | @@ -129,7 +147,7 @@ curl -H "Grpc-Metadata-Macaroon: $ADMIN_MAC" \ | `limit` | Max results (1–1000, default 50) | | `offset` | Pagination offset | | `service` | Filter by service name | -| `state` | Filter by state: `pending` or `settled` | +| `state` | Filter by state: `pending`, `settled`, or `expired` | | `start_date` | Start of date range (RFC 3339) | | `end_date` | End of date range (RFC 3339) | @@ -172,6 +190,118 @@ Response: } ``` +## Sessions (MPP prepaid) + +These endpoints surface MPP prepaid session data (the one-shot charge-intent +flow is already covered by `/transactions`). Available only when the server +is started with both `authenticator.enablempp: true` and +`authenticator.enablesessions: true`; otherwise they return **HTTP 501 +Unimplemented**. + +> **Amount units.** All `*_sats` fields are in the connected chain's base +> unit — satoshis for bitcoin, MIST for sui (1 SUI = 10⁹ MIST). The naming +> keeps proto wire-compatibility with existing fields; pair with +> `GET /api/admin/info`'s `chain` field to decide display scaling. The +> embedded dashboard does this automatically via `lib/currency.ts`. + +### List sessions + +```bash +# All sessions, most recent first +curl -H "Grpc-Metadata-Macaroon: $ADMIN_MAC" \ + http://localhost:8081/api/admin/sessions + +# Only open sessions +curl -H "Grpc-Metadata-Macaroon: $ADMIN_MAC" \ + "http://localhost:8081/api/admin/sessions?status=open&limit=50" +``` + +Query parameters: + +| Parameter | Type | Description | +|-----------|------|-------------| +| `status` | string | `"open"`, `"closed"`, or empty (all). | +| `limit` | int | Page size. Defaults to 50. Max 1000. | +| `offset` | int | 0-based pagination offset. | + +Response: + +```json +{ + "sessions": [ + { + "session_id": "e0cf5388ba0b467b400377f16868fd11a155a18b32c51ff09eb95f88e66978f7", + "payment_hash": "e0cf5388ba0b467b400377f16868fd11a155a18b32c51ff09eb95f88e66978f7", + "deposit_sats": "200000000", + "spent_sats": "20000000", + "balance_sats": "180000000", + "return_invoice": "lnbcrt1p57nc3j...", + "status": "closed", + "created_at": "2026-04-23T09:11:17Z", + "updated_at": "2026-04-23T09:11:17Z" + } + ], + "total": "2" +} +``` + +Field notes: + +- `session_id` equals `payment_hash` of the deposit invoice (redundant but + kept for clarity). +- `balance_sats` is `deposit_sats - spent_sats`. For open sessions it's the + remaining prepaid credit; for closed sessions it's the amount that was + refunded to the client's ReturnInvoice at close time. +- `total` is the full match count ignoring `limit`/`offset`, for pagination + UIs. + +### Session statistics + +```bash +curl -H "Grpc-Metadata-Macaroon: $ADMIN_MAC" \ + http://localhost:8081/api/admin/sessions/stats +``` + +Response: + +```json +{ + "total_sessions": "12", + "open_sessions": "3", + "closed_sessions": "9", + "total_deposit_sats": "2400000000", + "total_spent_sats": "180000000", + "open_balance_sats": "420000000" +} +``` + +Field notes: + +- `total_spent_sats` is the real revenue (what clients consumed via bearer + requests). This is **not** reflected in `/api/admin/stats` — that endpoint + only aggregates L402 charge-intent transactions, not session debits. +- `open_balance_sats` is the sum of `deposit - spent` across sessions still + in the `open` state: the prepaid balance you currently owe back to + clients if every open session were closed right now. +- `total_deposit_sats` is lifetime deposits including amounts that have + since been refunded. Useful for volume metrics, not cash-basis accounting. + +### Why sessions are a separate endpoint + +Sessions are a different economic primitive from one-shot charges: + +- A `/transactions` row is created for every 402 challenge and transitions + to `settled` only if that specific invoice is paid. Sessions instead + track a running balance that debits per request without creating new + invoices. +- Session `open`/`close` do trigger individual Lightning payments (deposit + in, refund out) but those don't produce `/transactions` rows — they're + bookkept against the session record. + +So for full payment visibility on a server that uses both schemes, sum +`/api/admin/stats.total_revenue_sats` (L402/MPP charges) **plus** +`/api/admin/sessions/stats.total_spent_sats` (session debits). + ## gRPC The admin gRPC service is defined in `adminrpc/admin.proto`. Connect to the From c0b66e8bda1e8dbdd8ce5cf863dd96ad7015b123 Mon Sep 17 00:00:00 2001 From: ai-chen2050 <1033467071@qq.com> Date: Fri, 24 Apr 2026 10:41:04 +0800 Subject: [PATCH 14/32] =?UTF-8?q?test(scripts):=20rename=20manual=5Fpay=5F?= =?UTF-8?q?through=5Fprism.sh=20=E2=86=92=20manual=5Fpay=5Fl402.sh=20and?= =?UTF-8?q?=20surface=20payment=20amounts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename Sibling scripts follow the manual_pay_.sh convention (manual_pay_mpp.sh, manual_pay_mpp_session.sh). The L402 one was still named after its old "through prism" phrasing, which makes it harder to tell at a glance which protocol each script exercises. Rename it and propagate the new name to in-script docstrings and serve_demo_backend.sh / manual_pay_mpp.sh that cross-reference it. Amount visibility Both manual_pay_l402.sh and manual_pay_mpp_session.sh now print what bob is about to spend and, for sessions, what prism will refund, so the operator can see the full money trail before confirming — instead of inferring it from the decoded BOLT11 invoice and the server's depositMultiplier. The session script gains a "[2b/7] Amount plan" banner listing deposit, per-request cost, planned total spend (per-request × BEARER_CALLS), and expected refund, all rendered in the chain's display unit (SUI on sui-lnd, sats on bitcoin) via admin GetInfo. The l402 script adds a one-liner next to the decoded invoice showing the SUI equivalent alongside the raw MIST value. All numbers are still server-decided (services[0].price × sessiondepositmultiplier + BEARER_CALLS); the scripts just make them visible. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...ay_through_prism.sh => manual_pay_l402.sh} | 19 ++++++++--- scripts/manual_pay_mpp.sh | 6 ++-- scripts/manual_pay_mpp_session.sh | 34 +++++++++++++++++-- scripts/serve_demo_backend.sh | 8 ++--- 4 files changed, 54 insertions(+), 13 deletions(-) rename scripts/{manual_pay_through_prism.sh => manual_pay_l402.sh} (93%) diff --git a/scripts/manual_pay_through_prism.sh b/scripts/manual_pay_l402.sh similarity index 93% rename from scripts/manual_pay_through_prism.sh rename to scripts/manual_pay_l402.sh index 706dd1a0..64568633 100755 --- a/scripts/manual_pay_through_prism.sh +++ b/scripts/manual_pay_l402.sh @@ -1,5 +1,5 @@ #!/bin/bash -# manual_pay_through_prism.sh +# manual_pay_l402.sh # # Manual verification of the full L402 payment flow through Prism. # Uses your running prism on :8080 (per sample-conf-tmp.yaml) and drives @@ -17,9 +17,9 @@ # 4. Prism running: `prism --configfile=./sample-conf-tmp.yaml` # # Usage: -# ./scripts/manual_pay_through_prism.sh # default: service1 -# SERVICE_HOST=foo.com PATH_SUFFIX=/bar ./scripts/manual_pay_through_prism.sh -# PRISM_BASEDIR=/abs/path ./scripts/manual_pay_through_prism.sh +# ./scripts/manual_pay_l402.sh # default: service1 +# SERVICE_HOST=foo.com PATH_SUFFIX=/bar ./scripts/manual_pay_l402.sh +# PRISM_BASEDIR=/abs/path ./scripts/manual_pay_l402.sh set -euo pipefail @@ -161,6 +161,17 @@ echo "$decoded" | jq '{ amt=$(echo "$decoded" | jq -r '.num_satoshis') phash=$(echo "$decoded" | jq -r '.payment_hash') +# Render the cost in the chain's natural unit so the operator sees what +# they're about to spend. prism's admin GetInfo tells us which chain lnd +# is running on. +chain=$(curl -sk -H "Grpc-Metadata-Macaroon: $ADMIN_MAC_HEX" \ + "https://$PRISM_HOST/api/admin/info" | jq -r '.chain // ""') +if [ "$chain" = "sui" ]; then + echo " → bob will pay $amt MIST ($(python3 -c "print($amt/1e9)") SUI)" +else + echo " → bob will pay $amt sats" +fi + # --- 5. Pay with bob ---------------------------------------------------- step "[5/7] Pay the invoice from bob" diff --git a/scripts/manual_pay_mpp.sh b/scripts/manual_pay_mpp.sh index b03859c6..a714c9a9 100755 --- a/scripts/manual_pay_mpp.sh +++ b/scripts/manual_pay_mpp.sh @@ -2,7 +2,7 @@ # manual_pay_mpp.sh # # Exercises the Payment HTTP Authentication (MPP) flow end-to-end through -# a running Prism on :8080. Sibling to manual_pay_through_prism.sh which +# a running Prism on :8080. Sibling to manual_pay_l402.sh which # does the L402 flow. Assumes both L402 and MPP are enabled (config has # `authenticator.enablempp: true`) and the target service uses # `authscheme: "l402+mpp"` (or `"mpp"`). @@ -18,7 +18,7 @@ # `Authorization: Payment `. # 5. Verify prism accepts the credential and forwards to the backend. # -# Prereqs identical to manual_pay_through_prism.sh: +# Prereqs identical to manual_pay_l402.sh: # * Prism on :8080 with MPP enabled # * Alice LND on :10009, Bob LND on :10010, open alice↔bob channel # * Demo backend on :9998 (./scripts/serve_demo_backend.sh) @@ -299,4 +299,4 @@ curl -sk -H "Grpc-Metadata-Macaroon: $ADMIN_MAC_HEX" \ echo echo "${G}━━━ MPP flow complete ━━━${N}" echo "Try the L402 flow on the same service:" -echo " ./scripts/manual_pay_through_prism.sh" +echo " ./scripts/manual_pay_l402.sh" diff --git a/scripts/manual_pay_mpp_session.sh b/scripts/manual_pay_mpp_session.sh index 6c42efcb..7d341390 100755 --- a/scripts/manual_pay_mpp_session.sh +++ b/scripts/manual_pay_mpp_session.sh @@ -160,11 +160,41 @@ deposit_amount=$(echo "$req_json" | jq -r '.depositAmount') per_unit_amount=$(echo "$req_json" | jq -r '.amount') idle_timeout=$(echo "$req_json" | jq -r '.idleTimeout') -info "per-request amount: $per_unit_amount (= $(python3 -c "print($per_unit_amount/1e9)") SUI)" -info "deposit amount: $deposit_amount (= $(python3 -c "print($deposit_amount/1e9)") SUI)" info "idle timeout: ${idle_timeout}s" info "deposit paymentHash: $deposit_phash" +# --- 2b. Amount plan --------------------------------------------------- +# Every number shown here is decided by the SERVER (from the service's +# price × sessiondepositmultiplier), not set in this script. The script +# just pays whatever prism asks for and records what it asked for so the +# operator can see what's about to move. + +planned_spend=$((per_unit_amount * BEARER_CALLS)) +planned_refund=$((deposit_amount - planned_spend)) + +chain=$(curl -sk -H "Grpc-Metadata-Macaroon: $ADMIN_MAC_HEX" \ + "https://$PRISM_HOST/api/admin/info" | jq -r '.chain // ""') +unit="sats" +scale_div=1 +if [ "$chain" = "sui" ]; then + unit="SUI" + scale_div=1000000000 +fi +fmt_amount() { python3 -c "print($1/$scale_div)"; } + +step "[2b/7] Amount plan (all derived from prism config)" +printf " %-22s %12s base units = %12s %s\n" \ + "deposit (bob→prism):" "$deposit_amount" "$(fmt_amount "$deposit_amount")" "$unit" +printf " %-22s %12s base units = %12s %s\n" \ + "per bearer request:" "$per_unit_amount" "$(fmt_amount "$per_unit_amount")" "$unit" +printf " %-22s %12s base units = %12s %s (%d × per-request)\n" \ + "planned total spend:" "$planned_spend" "$(fmt_amount "$planned_spend")" "$unit" "$BEARER_CALLS" +printf " %-22s %12s base units = %12s %s (deposit − spend, prism→bob on close)\n" \ + "expected refund:" "$planned_refund" "$(fmt_amount "$planned_refund")" "$unit" +echo " ${Y}~${N} To change these: edit services[0].price +" +echo " authenticator.sessiondepositmultiplier in your config, or set" +echo " BEARER_CALLS= when invoking this script (currently $BEARER_CALLS)." + # --- 3. Bob pays the deposit invoice, builds ReturnInvoice, OPEN ----- step "[3/7] Action: open (pay deposit + send returnInvoice)" diff --git a/scripts/serve_demo_backend.sh b/scripts/serve_demo_backend.sh index 21d57907..81ea5436 100755 --- a/scripts/serve_demo_backend.sh +++ b/scripts/serve_demo_backend.sh @@ -3,7 +3,7 @@ # # Starts a tiny Python HTTP server on :9998 with a few fixture files, used # as a dummy backend for the L402 payment flow demo (see -# manual_pay_through_prism.sh). Point a Prism service at 127.0.0.1:9998 +# manual_pay_l402.sh). Point a Prism service at 127.0.0.1:9998 # with protocol=http and exercise the full 402 → pay → 200 round-trip. # # Example service1 stanza in sample-conf-tmp.yaml: @@ -24,7 +24,7 @@ # Default fixture content served: # GET / → index.html (HTML page) # GET /data.json → JSON payload -# GET /probe → probe.txt (matches manual_pay_through_prism.sh default) +# GET /probe → probe.txt (matches manual_pay_l402.sh default) # GET / → 404 (standard http.server behavior) set -euo pipefail @@ -83,7 +83,7 @@ if [ ! -f "$SERVE_DIR/data.json" ]; then JSON fi -# /probe is the default path used by scripts/manual_pay_through_prism.sh, +# /probe is the default path used by scripts/manual_pay_l402.sh, # so we ship a fixture file with no extension so GET /probe returns 200. if [ ! -f "$SERVE_DIR/probe" ]; then cat > "$SERVE_DIR/probe" <<'TEXT' @@ -104,7 +104,7 @@ echo " GET /probe → probe" echo echo "Ctrl-C to stop. Pair with:" echo " ./prism --configfile=./sample-conf-tmp.yaml # Prism on :8080" -echo " ./scripts/manual_pay_through_prism.sh # drives a paid request" +echo " ./scripts/manual_pay_l402.sh # drives a paid request" cd "$SERVE_DIR" exec python3 -m http.server "$PORT" --bind "$BIND" From a1413bc471a2f748f2e27380c6ee0a5b8a599544 Mon Sep 17 00:00:00 2001 From: ai-chen2050 <1033467071@qq.com> Date: Fri, 24 Apr 2026 10:41:22 +0800 Subject: [PATCH 15/32] feat(dashboard): combine L402 + session revenue on home page Total Revenue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Total Revenue KPI on the dashboard home was only summing l402_transactions (settled L402 and MPP-charge invoices). MPP prepaid session bearer debits — which can easily be the majority of revenue on a session-first deployment — live in the separate mpp_sessions table and were completely absent from the top-line number, silently under- reporting actual revenue. Pull both /api/admin/stats and /api/admin/sessions/stats on the home page and display the combined total. Under the main figure, show the breakdown as " L402 + session" so the operator can see where revenue is coming from. Degrades cleanly: * MPP sessions disabled → useSessionStats() returns null → only L402 revenue shown, no breakdown line (identical to pre-change behavior on single-scheme deployments). * Sessions enabled but no spend yet → breakdown line hidden to avoid " L402 + 0 session" clutter. Backend /api/admin/stats semantics unchanged — external consumers (Grafana scrapers, the admin CLI's stats command) keep seeing the original L402-only aggregate. The merge is strictly a frontend display choice. Co-Authored-By: Claude Opus 4.7 (1M context) --- dashboard/app/page.tsx | 52 ++++++++++++++++++++++++++++++++---------- 1 file changed, 40 insertions(+), 12 deletions(-) diff --git a/dashboard/app/page.tsx b/dashboard/app/page.tsx index 79985b8b..56e9f2b0 100644 --- a/dashboard/app/page.tsx +++ b/dashboard/app/page.tsx @@ -2,7 +2,13 @@ import { useState, useMemo, useCallback } from "react"; import Link from "next/link"; -import { useStats, useServices, useTransactions, useInfo } from "@/lib/api"; +import { + useStats, + useServices, + useTransactions, + useInfo, + useSessionStats, +} from "@/lib/api"; import { formatAmount, unitLabel } from "@/lib/currency"; import styled from "@emotion/styled"; @@ -188,6 +194,10 @@ export default function DashboardPage() { error: statsError, mutate: mutateStats, } = useStats(dateFrom || undefined, dateTo || undefined); + // useSessionStats returns null (not undefined) when the server has MPP + // sessions disabled, so check data !== null before including its revenue. + // No date filter on the server side today; the number is lifetime. + const { data: sessionStats } = useSessionStats(); const { data: services, isLoading: servicesLoading, @@ -294,17 +304,35 @@ export default function DashboardPage() { ) : ( <> - + {(() => { + // Combined revenue = settled L402/MPP-charge transactions + + // session bearer debits. The two figures come from separate + // backend tables (l402_transactions vs mpp_sessions) with + // separate endpoints; we merge client-side so the top-line + // number reflects real money taken, not just one of them. + const l402 = stats?.total_revenue_sats ?? 0; + const sess = sessionStats?.total_spent_sats ?? 0; + const combined = l402 + sess; + const hasSessions = sessionStats != null && sess > 0; + return ( + + ); + })()} Date: Fri, 24 Apr 2026 17:08:15 +0800 Subject: [PATCH 16/32] feat(challenger,proxy): per-service lnd routing for multi-merchant deployments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prism was designed (via its lightninglabs/aperture lineage) as a single- operator-multi-services gateway — one global lnd collects all payments, revenue is split off-band. That model makes the gateway operator a custodian of every merchant's funds, triggering VASP/MSB/CASP obligations in most jurisdictions as soon as third-party merchants are involved. Introduce an optional per-service `payment:` block that routes a service's invoices through the merchant's own lnd, so payments land directly in the merchant's wallet and the gateway never takes custody. The two modes coexist in one deployment: services without a payment block continue using the global lnd unchanged. mint/mint.go New ServiceAwareChallenger interface embedding the existing Challenger. MintL402 picks the most-expensive service's name and routes through NewChallengeForService when the challenger supports it, falling back to the plain NewChallenge path otherwise. auth/interface.go newChallengeFor helper shared by both MPP authenticators; encapsulates the type-assertion dance so call sites stay clean. auth/mpp_authenticator.go, auth/mpp_session_authenticator.go FreshChallengeHeader now threads serviceName through to the challenger so MPP charge and MPP session invoices also land in the right wallet. proxy/service.go New PaymentBackend struct (LndHost + TLSPath + MacPath); optional Payment field on Service. YAML field names line up with go-yaml's lowercased-field-name default so no yaml tags are needed. challenger/router.go (new) RouterChallenger implements both plain Challenger and ServiceAwareChallenger. Keeps a default challenger plus a per-service map; tracks hash→sub-challenger mapping so VerifyInvoiceStatus routes verify calls to the right lnd after restart, falling back to a scan across all sub-challengers for hashes lost from the in-memory map (e.g. invoices minted before the last process restart). EnsureDistinctMerchants sanity-checks that no two services share the same (lndhost, macpath) — would silently pool funds and defeat the isolation. aperture.go buildPerServiceChallengers walks the service config and constructs one LndChallenger per service with a payment block. When any service opts in, the aperture's challenger becomes a RouterChallenger; when none do, it keeps the plain LndChallenger for zero behavior change on legacy deployments. Merchants hand the gateway operator a minimum-privilege macaroon — invoices:read, invoices:write, info:read only — so the gateway has exactly the capability surface it needs (create/verify invoices, read chain for unit labelling) and nothing more. The worst case if that macaroon leaks: attacker can create fake invoices that nobody has to pay; zero value movable. This commit exposes the feature via YAML config only. Extending the admin API (proto + DB migration + prismcli + dashboard form) so merchants can be onboarded through /api/admin/services is a follow-up. .gitignore Keep the payment-architecture compliance writeup local, don't push. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 4 + aperture.go | 109 +++++++++++++++++- auth/interface.go | 14 +++ auth/mpp_authenticator.go | 10 +- auth/mpp_session_authenticator.go | 7 +- challenger/router.go | 179 ++++++++++++++++++++++++++++++ mint/mint.go | 66 ++++++++++- proxy/service.go | 35 ++++++ 8 files changed, 415 insertions(+), 9 deletions(-) create mode 100644 challenger/router.go diff --git a/.gitignore b/.gitignore index 77f935b7..8578e0c6 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,7 @@ cmd/prism/prism # dashboard build output (generated by npm run build / make build-dashboard) dashboard/out/ + +# Internal architecture / compliance notes — kept locally, not pushed +docs/payment-architecture-and-fiat-compliance.md +docs/payment-architecture-and-fiat-compliance.docx diff --git a/aperture.go b/aperture.go index dc8e5b87..b6fd0252 100644 --- a/aperture.go +++ b/aperture.go @@ -415,7 +415,7 @@ func (a *Aperture) Start(errChan chan error, shutdown <-chan struct{}) error { } } - a.challenger, err = challenger.NewLndChallenger( + defaultChal, err := challenger.NewLndChallenger( client, a.cfg.InvoiceBatchSize, genInvoiceReq, context.Background, errChan, a.cfg.StrictVerify, challengerOpts..., @@ -423,6 +423,38 @@ func (a *Aperture) Start(errChan chan error, shutdown <-chan struct{}) error { if err != nil { return err } + + // Build per-service challengers for any service that + // opted into its own lnd backend via a `payment:` + // block. This is how multi-merchant deployments keep + // each merchant's funds isolated — payments land in + // the merchant's own wallet, the gateway never takes + // custody. Services without a payment block fall + // through to defaultChal (legacy single-lnd mode). + perServiceChallengers, err := buildPerServiceChallengers( + a.cfg.Services, authCfg.Network, + a.cfg.InvoiceBatchSize, genInvoiceReq, + errChan, a.cfg.StrictVerify, + challengerOpts, + ) + if err != nil { + return fmt.Errorf("unable to build per-"+ + "service challengers: %w", err) + } + + if len(perServiceChallengers) == 0 { + // No per-service overrides → keep the single + // global challenger to minimise surface area. + a.challenger = defaultChal + } else { + log.Infof("Multi-merchant mode: %d service(s) "+ + "bound to dedicated lnd backends; the "+ + "rest fall back to the global lnd", + len(perServiceChallengers)) + a.challenger = challenger.NewRouterChallenger( + defaultChal, perServiceChallengers, + ) + } } } @@ -1036,6 +1068,81 @@ func buildChallengerOpts( return opts } +// buildPerServiceChallengers creates one LndChallenger per service that +// has an explicit `payment:` block in its config, each connected to the +// merchant's own lnd node. Services without a payment block are omitted +// from the result; the caller composes them under a RouterChallenger +// alongside the global default challenger. +// +// The per-service challenger uses the same invoice generator, batch +// size, strictVerify setting, and callbacks (settlement + expiration) +// as the global challenger, so admin transaction bookkeeping behaves +// identically across single-lnd and multi-merchant deployments. +func buildPerServiceChallengers(services []*proxy.Service, network string, + batchSize int, genInvoiceReq challenger.InvoiceRequestGenerator, + errChan chan<- error, strictVerify bool, + opts []challenger.LndChallengerOption) ( + map[string]challenger.Challenger, error) { + + out := make(map[string]challenger.Challenger) + collisions := make(map[string]*challenger.MerchantKey) + + for _, svc := range services { + if svc == nil || svc.Payment == nil { + continue + } + p := svc.Payment + if p.LndHost == "" || p.TLSPath == "" || p.MacPath == "" { + return nil, fmt.Errorf("service %q payment config "+ + "requires all of lndhost, tlspath, macpath", + svc.Name) + } + + client, err := lndclient.NewBasicClient( + p.LndHost, + p.TLSPath, + filepath.Dir(p.MacPath), + network, + lndclient.MacFilename(filepath.Base(p.MacPath)), + ) + if err != nil { + return nil, fmt.Errorf("service %q: cannot open "+ + "merchant lnd client at %s: %w", + svc.Name, p.LndHost, err) + } + + c, err := challenger.NewLndChallenger( + client, batchSize, genInvoiceReq, + context.Background, errChan, strictVerify, opts..., + ) + if err != nil { + return nil, fmt.Errorf("service %q: cannot start "+ + "challenger: %w", svc.Name, err) + } + + log.Infof("Service %q bound to merchant lnd at %s", + svc.Name, p.LndHost) + out[svc.Name] = c + collisions[svc.Name] = &challenger.MerchantKey{ + LndHost: p.LndHost, + MacPath: p.MacPath, + } + } + + // Fail fast if two services share the same (lndhost, macpath) — + // that would silently pool their funds on the same lnd and defeat + // the whole point of per-merchant isolation. + if err := challenger.EnsureDistinctMerchants(collisions); err != nil { + // Shut down anything we already started before bailing. + for _, c := range out { + c.Stop() + } + return nil, err + } + + return out, nil +} + // createAdminServer creates the admin gRPC server and REST gateway following // the same pattern as createHashMailServer. It returns two slices of local // services: priority services (prefix-matched handlers like the gRPC, REST, diff --git a/auth/interface.go b/auth/interface.go index f5b2e7fb..591f6ce6 100644 --- a/auth/interface.go +++ b/auth/interface.go @@ -18,6 +18,20 @@ const ( DefaultInvoiceLookupTimeout = 3 * time.Second ) +// newChallengeFor issues a new Lightning invoice via the given challenger. +// When the challenger implements ServiceAwareChallenger (multi-merchant +// deployment), the invoice is routed through the named service's lnd so +// payment lands in the merchant's own wallet. Otherwise falls back to the +// plain single-lnd path. +func newChallengeFor(c mint.Challenger, serviceName string, price int64) ( + string, lntypes.Hash, error) { + + if sac, ok := c.(mint.ServiceAwareChallenger); ok { + return sac.NewChallengeForService(serviceName, price) + } + return c.NewChallenge(price) +} + // Authenticator is the generic interface for validating client headers and // returning new challenge headers. type Authenticator interface { diff --git a/auth/mpp_authenticator.go b/auth/mpp_authenticator.go index 43033004..dbfe4529 100644 --- a/auth/mpp_authenticator.go +++ b/auth/mpp_authenticator.go @@ -188,9 +188,13 @@ func (a *MPPAuthenticator) Accept(header *http.Header, func (a *MPPAuthenticator) FreshChallengeHeader(serviceName string, servicePrice int64) (http.Header, error) { - // Create a new Lightning invoice. - paymentRequest, paymentHash, err := a.challenger.NewChallenge( - servicePrice, + // Create a new Lightning invoice. If the underlying challenger + // supports per-service routing (multi-merchant deployment with each + // service bound to its own lnd), issue the invoice against that + // merchant's lnd so the payment lands directly in their wallet — + // the gateway never takes custody. + paymentRequest, paymentHash, err := newChallengeFor( + a.challenger, serviceName, servicePrice, ) if err != nil { return nil, fmt.Errorf("MPP: failed to create invoice: %w", diff --git a/auth/mpp_session_authenticator.go b/auth/mpp_session_authenticator.go index 88661ae0..a845df02 100644 --- a/auth/mpp_session_authenticator.go +++ b/auth/mpp_session_authenticator.go @@ -669,9 +669,10 @@ func (a *MPPSessionAuthenticator) FreshChallengeHeader(serviceName string, } depositSats := servicePrice * mult - // Create a deposit invoice. - paymentRequest, paymentHash, err := a.challenger.NewChallenge( - depositSats, + // Create a deposit invoice, routing through the service's own lnd + // if the challenger supports multi-merchant dispatch. + paymentRequest, paymentHash, err := newChallengeFor( + a.challenger, serviceName, depositSats, ) if err != nil { return nil, fmt.Errorf("MPP Session: failed to create "+ diff --git a/challenger/router.go b/challenger/router.go new file mode 100644 index 00000000..ede8d23f --- /dev/null +++ b/challenger/router.go @@ -0,0 +1,179 @@ +package challenger + +import ( + "fmt" + "sync" + "time" + + "github.com/lightninglabs/aperture/mint" + "github.com/lightningnetwork/lnd/lnrpc" + "github.com/lightningnetwork/lnd/lntypes" +) + +// RouterChallenger dispatches challenge creation and invoice verification +// across multiple underlying Challengers, one per merchant-service. Used +// for multi-tenant deployments where each service's invoices must land +// in the merchant's own lnd wallet, so the gateway operator never takes +// custody. +// +// Services that don't have a per-service lnd configured fall through to +// the default (global) challenger. VerifyInvoiceStatus routes by payment +// hash — the router tracks hash→subChallenger mapping as invoices are +// minted, then dispatches lookups accordingly. Unknown hashes fall back +// to the default challenger as well (covers the case where prism is +// restarted and in-memory state is lost but the invoice still exists on +// the original lnd). +type RouterChallenger struct { + // defaultChallenger is the gateway operator's global lnd; used for + // services without per-service configuration, and as a fallback + // when VerifyInvoiceStatus is called with an unknown hash. + defaultChallenger Challenger + + // perService holds a challenger per configured service name. + perService map[string]Challenger + + // hashRoute records which sub-challenger created which invoice so + // VerifyInvoiceStatus can route lookups to the right lnd. The + // router mutex protects concurrent writes from the different + // mint/verify paths. + routeMu sync.RWMutex + hashRoute map[lntypes.Hash]Challenger +} + +// Compile-time interface checks: RouterChallenger satisfies both the +// plain Challenger contract and the ServiceAwareChallenger extension. +var _ Challenger = (*RouterChallenger)(nil) +var _ mint.ServiceAwareChallenger = (*RouterChallenger)(nil) + +// NewRouterChallenger builds a router that dispatches challenge and +// verify calls across multiple Challengers. `perService` maps service +// names to their dedicated challenger; services not present in the map +// use `defaultChallenger`. +func NewRouterChallenger(defaultChallenger Challenger, + perService map[string]Challenger) *RouterChallenger { + + if perService == nil { + perService = make(map[string]Challenger) + } + return &RouterChallenger{ + defaultChallenger: defaultChallenger, + perService: perService, + hashRoute: make(map[lntypes.Hash]Challenger), + } +} + +// NewChallenge is the legacy entry point; with no service context, it +// delegates to the default challenger. Kept so the router still +// satisfies the plain mint.Challenger interface. +func (r *RouterChallenger) NewChallenge(price int64) ( + string, lntypes.Hash, error) { + + return r.newChallengeVia(r.defaultChallenger, price) +} + +// NewChallengeForService routes the invoice creation to the service's +// own lnd when configured, otherwise uses the default. +func (r *RouterChallenger) NewChallengeForService(serviceName string, + price int64) (string, lntypes.Hash, error) { + + chal := r.defaultChallenger + if serviceName != "" { + if sub, ok := r.perService[serviceName]; ok { + chal = sub + } + } + return r.newChallengeVia(chal, price) +} + +// newChallengeVia issues the invoice against `c` and records the hash → +// challenger mapping so later VerifyInvoiceStatus calls route to the +// same lnd. +func (r *RouterChallenger) newChallengeVia(c Challenger, price int64) ( + string, lntypes.Hash, error) { + + payReq, hash, err := c.NewChallenge(price) + if err != nil { + return "", lntypes.Hash{}, err + } + + r.routeMu.Lock() + r.hashRoute[hash] = c + r.routeMu.Unlock() + + return payReq, hash, nil +} + +// VerifyInvoiceStatus routes the lookup to whichever sub-challenger +// originally created the invoice. If the hash is unknown (e.g. after a +// gateway restart), falls back to the default challenger, which will +// either find the invoice (single-lnd deployments) or return a standard +// "invoice not found" error (multi-merchant deployments where the +// invoice is on a different lnd). +func (r *RouterChallenger) VerifyInvoiceStatus(hash lntypes.Hash, + state lnrpc.Invoice_InvoiceState, timeout time.Duration) error { + + r.routeMu.RLock() + sub, ok := r.hashRoute[hash] + r.routeMu.RUnlock() + + if !ok { + // After restart the in-memory route table is empty. Try each + // sub-challenger in turn so that invoices minted by a + // previous process still verify correctly. + for _, c := range r.perService { + if err := c.VerifyInvoiceStatus(hash, state, timeout); err == nil { + // Remember the route for future lookups. + r.routeMu.Lock() + r.hashRoute[hash] = c + r.routeMu.Unlock() + return nil + } + } + // Last resort: the default. + return r.defaultChallenger.VerifyInvoiceStatus( + hash, state, timeout, + ) + } + return sub.VerifyInvoiceStatus(hash, state, timeout) +} + +// Stop shuts down every wrapped challenger. Errors are swallowed; each +// sub-challenger logs its own stop issues. +func (r *RouterChallenger) Stop() { + if r.defaultChallenger != nil { + r.defaultChallenger.Stop() + } + for _, c := range r.perService { + c.Stop() + } +} + +// ensureDistinctMerchants is a helper used by aperture startup to +// sanity-check that no two services share the same (lndhost, macpath) +// pair by accident — which would silently pool their funds on the same +// lnd and defeat the whole point of the per-service routing. Returns a +// descriptive error on collision, nil otherwise. +func EnsureDistinctMerchants(configs map[string]*MerchantKey) error { + seen := make(map[MerchantKey]string) + for name, key := range configs { + if key == nil { + continue + } + if prev, ok := seen[*key]; ok { + return fmt.Errorf("services %q and %q share the same "+ + "lnd endpoint (%s) and macaroon — invoices "+ + "for both services would pool on one lnd, "+ + "undermining per-merchant isolation", + prev, name, key.LndHost) + } + seen[*key] = name + } + return nil +} + +// MerchantKey identifies a distinct (lnd, macaroon) pair used for +// collision detection above. +type MerchantKey struct { + LndHost string + MacPath string +} diff --git a/mint/mint.go b/mint/mint.go index b8fab8c0..1569c508 100644 --- a/mint/mint.go +++ b/mint/mint.go @@ -34,6 +34,24 @@ type Challenger interface { Stop() } +// ServiceAwareChallenger is an optional extension of Challenger for +// deployments that route each service's invoices through a distinct lnd +// node — typically because each merchant runs their own lnd and the +// gateway operator must not take custody of funds. Callers that have a +// service name available should prefer NewChallengeForService; the fall- +// back path (NewChallenge without service context) keeps backwards +// compatibility with single-lnd deployments. +type ServiceAwareChallenger interface { + Challenger + + // NewChallengeForService returns a new challenge against the lnd + // node bound to the named service. If no per-service lnd is + // configured for this service, implementations should fall back to + // the global default challenger. + NewChallengeForService(serviceName string, price int64) ( + string, lntypes.Hash, error) +} + // SecretStore is the store responsible for storing L402 secrets. These secrets // are required for proper verification of each minted L402. type SecretStore interface { @@ -128,9 +146,21 @@ func (m *Mint) MintL402(ctx context.Context, // services. price := maximumPrice(services) + // When all services share the same challenger, use any service name + // (they will all resolve the same way). In multi-merchant deploy- + // ments each service can be bound to its own lnd node, so we pick + // the name of the most-expensive service to ensure its invoice is + // the one that gets created. + serviceName := maximumPriceServiceName(services) + // We'll start by retrieving a new challenge in the form of a Lightning - // payment request to present the requester of the L402 with. - paymentRequest, paymentHash, err := m.cfg.Challenger.NewChallenge(price) + // payment request to present the requester of the L402 with. Prefer + // the service-aware path when the challenger supports it, so per- + // merchant lnd routing works; fall back to the plain path for + // single-lnd deployments. + paymentRequest, paymentHash, err := mintChallenge( + m.cfg.Challenger, serviceName, price, + ) if err != nil { return nil, "", err } @@ -224,6 +254,38 @@ func maximumPrice(services []l402.Service) int64 { return max } +// maximumPriceServiceName returns the name of the most expensive service +// in the given slice. In the multi-merchant case the invoice is routed +// through this service's lnd, so the name selection must match whichever +// service's price was chosen by maximumPrice. Returns empty string for +// an empty input; an empty service name causes ServiceAwareChallenger +// implementations to fall back to their default lnd. +func maximumPriceServiceName(services []l402.Service) string { + var ( + max int64 + name string + ) + for _, svc := range services { + if svc.Price >= max { + max = svc.Price + name = svc.Name + } + } + return name +} + +// mintChallenge issues a new challenge, preferring the service-aware +// routing path when the challenger supports it. Returns the payment +// request and payment hash. +func mintChallenge(c Challenger, serviceName string, price int64) ( + string, lntypes.Hash, error) { + + if sac, ok := c.(ServiceAwareChallenger); ok { + return sac.NewChallengeForService(serviceName, price) + } + return c.NewChallenge(price) +} + // createUniqueIdentifier creates a new L402 identifier bound to a payment hash // and a randomly generated ID. func createUniqueIdentifier(paymentHash lntypes.Hash) ([]byte, error) { diff --git a/proxy/service.go b/proxy/service.go index 07d9dd36..17eb6947 100644 --- a/proxy/service.go +++ b/proxy/service.go @@ -39,6 +39,33 @@ type RewriteConfig struct { Prefix string `long:"prefix" description:"Absolute path prefix to prepend to the request path"` } +// PaymentBackend overrides the global authenticator lnd for a specific +// service. Use this when each merchant runs their own lnd node, so +// payments for their API land directly in their wallet and the gateway +// operator never takes custody. +// +// When unset (nil on a Service), the service uses the global +// authenticator.lndhost, which is the legacy single-operator-multi- +// services behavior. +// +// The macaroon at MacPath should be the minimum-privilege one baked +// for this gateway — typically `invoices:read invoices:write info:read` +// (see docs/admin-api.md "Merchant onboarding" section). The gateway +// never needs payment-sending, channel-management, or wallet keys. +type PaymentBackend struct { + // LndHost is the host:port of the merchant's lnd gRPC endpoint. + LndHost string `long:"lndhost" description:"Merchant lnd gRPC host:port"` + + // TLSPath is the path to the merchant's lnd tls.cert. The gateway + // reads this file at startup; rotate cert → restart gateway. + TLSPath string `long:"tlspath" description:"Path to the merchant lnd TLS cert"` + + // MacPath is the absolute path to the merchant-supplied macaroon + // file. The macaroon should grant invoices:read invoices:write + // info:read only. + MacPath string `long:"macpath" description:"Path to the merchant-supplied macaroon (minimum: invoices:read, invoices:write, info:read)"` +} + // Service generically specifies configuration data for backend services to the // Aperture proxy. type Service struct { @@ -133,6 +160,14 @@ type Service struct { // Rewrite defines what should be rewritten in the client request. Rewrite RewriteConfig `long:"rewrite" description:"Values to rewrite in the client request"` + // Payment optionally overrides the global authenticator lnd for + // this service. When nil, the service uses the single global lnd + // (backwards-compatible single-operator mode). When set, invoices + // for this service are issued against the merchant's own lnd — + // payments land directly in the merchant's wallet, never the + // gateway's. + Payment *PaymentBackend `long:"payment" description:"Optional per-service lnd override; the merchant's own lnd node" yaml:"payment"` + // compiledHostRegexp is the compiled host regex. compiledHostRegexp *regexp.Regexp From afd680596b394dfaab6ee6c233750c0b6f705615 Mon Sep 17 00:00:00 2001 From: ai-chen2050 <1033467071@qq.com> Date: Fri, 24 Apr 2026 17:08:32 +0800 Subject: [PATCH 17/32] docs(api,config): multi-merchant onboarding guide + macaroon baking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document the per-service lnd routing feature landed in the previous commit — how to configure it, how merchants bake the minimum-privilege macaroon they hand over, what that macaroon can and cannot do if leaked, and how to migrate from the legacy single-lnd mode. docs/admin-api.md * New "Multi-merchant (per-service lnd routing)" section covering the feature end-to-end. * Macaroon baking walkthrough: `lncli bakemacaroon` with invoices:read invoices:write info:read, plus --ip_address pinning and --expiry=7776000 (90 days) for defense in depth. * Permission capability table: which RPCs the minimum macaroon allows and which it rejects (no SendPayment, no channel control, no wallet access, no signing). Makes the blast radius on leak explicit. * Hardening checklist on the merchant side (network ACL, TLS pinning, log watching, rotation cadence). * Migration guide from single-lnd mode to multi-merchant mode; two modes coexist so merchants can be moved one at a time. sample-conf.yaml * Commented-out multi-merchant example service block showing the payment: lndhost/tlspath/macpath shape, pointing readers at the admin-api.md section for the full onboarding steps. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/admin-api.md | 158 ++++++++++++++++++++++++++++++++++++++++++++++ sample-conf.yaml | 27 ++++++++ 2 files changed, 185 insertions(+) diff --git a/docs/admin-api.md b/docs/admin-api.md index 24bf62aa..179d7568 100644 --- a/docs/admin-api.md +++ b/docs/admin-api.md @@ -364,6 +364,164 @@ When `auth_scheme` is `AUTH_SCHEME_L402_MPP`, the 402 response includes both `WWW-Authenticate: L402` and `WWW-Authenticate: Payment` headers, and the response body uses RFC 9457 Problem Details JSON. +## Multi-merchant (per-service LND routing) + +By default prism routes every service's invoices through a single LND node +(the one configured under `authenticator.lndhost`). In that mode the +gateway operator's wallet receives all payments and is responsible for +splitting revenue to merchants out-of-band. That works for a single- +operator-multi-services deployment but makes the operator a custodian of +merchants' funds, with all the regulatory weight that implies. + +Prism supports an alternative **per-service** routing mode: each service +can opt into its own dedicated LND backend via a `payment:` block. When +set, invoices for that service are issued against the merchant's own LND +so payments land directly in the merchant's wallet. The gateway never +takes custody. + +The two modes coexist in the same deployment — services without a +`payment:` block continue to use the global LND, services with one use +theirs. The router composes them transparently. + +### Config + +```yaml +services: + # Legacy single-lnd mode: no `payment:` block, uses global lnd. + - name: "loka-internal-api" + hostregexp: '^api\.loka\.local$' + address: "127.0.0.1:8080" + price: 10000000 + + # Multi-merchant mode: this service's invoices land in the merchant's + # own lnd, not the gateway's. + - name: "merchant-a" + hostregexp: '^a\.example\.com$' + address: "https://merchant-a.api.internal:8443" + price: 10000000 + payment: + lndhost: "merchant-a.lnd.internal:10009" + tlspath: "/gateway-secrets/merchant-a/tls.cert" + macpath: "/gateway-secrets/merchant-a/prism-gateway.macaroon" +``` + +Runtime behavior: + +- Startup log: `Service "merchant-a" bound to merchant lnd at ...` +- Startup log: `Multi-merchant mode: N service(s) bound to dedicated lnd backends` +- A 402 challenge for `merchant-a` returns a BOLT11 invoice whose + destination is the merchant's node pubkey. +- Admin `transactions` / `stats` still aggregate across all services; + each row's `payment_hash` is unique per lnd. +- Prism validates that no two `payment:` blocks share the same + `(lndhost, macpath)` — shared endpoints would silently pool funds. + +### Merchant onboarding: bake a minimum-privilege macaroon + +Prism only needs three RPC permissions on the merchant's lnd: +`invoices:read`, `invoices:write`, `info:read`. Give it anything more and +you increase blast radius without benefit. LND's `bakemacaroon` creates +tokens scoped to exactly those permissions; the merchant runs the +command on their own node, then hands the file to the gateway operator +(you). + +**1. Verify you have LND (v0.14+) with `bakemacaroon` support:** + +```bash +lncli --version +lncli bakemacaroon --help +``` + +**2. Bake the macaroon:** + +```bash +# Minimum permissions. No payment-send, no wallet, no channels. +lncli bakemacaroon --save_to=prism-gateway.macaroon \ + invoices:read invoices:write info:read +``` + +**3. (Recommended) Add IP-lock + expiry:** + +```bash +lncli bakemacaroon --save_to=prism-gateway.macaroon \ + --ip_address= \ + --expiry=7776000 \ + invoices:read invoices:write info:read +``` + +- `--ip_address` pins the macaroon to the gateway's IP — even if the + file is stolen, it can't be used from anywhere else. +- `--expiry=7776000` = 90 days. Plan to re-bake before it lapses. + +**4. Hand over to the gateway operator:** + +``` +Send over an encrypted channel (Signal, PGP email, Vault): + - tls.cert (your lnd's TLS cert, from $LND_DIR/tls.cert) + - prism-gateway.macaroon (the file you just baked) + - host:port (your lnd's gRPC listen address, publicly reachable) +``` + +**5. What this macaroon can and can't do:** + +| RPC | Allowed? | Why | +|---|---|---| +| `AddInvoice`, `LookupInvoice`, `ListInvoices`, `SubscribeInvoices` | Yes | Prism's invoice-creation and reconciliation flow | +| `GetInfo` | Yes | Prism reads `chains[0].chain` once at startup for unit labelling | +| `SendPayment`, `SendPaymentSync`, `RouterSendPaymentV2` | **No** | `offchain:write` not granted — gateway cannot move merchant's money | +| `OpenChannel`, `CloseChannel`, `AbandonChannel` | **No** | `onchain:write` + `lightning:write` not granted | +| `ListChannels`, `ChannelBalance`, `WalletBalance` | **No** | `lightning:read` / `onchain:read` not granted; gateway can't even see the merchant's balance | +| `SignMessage`, `DeriveKey`, `DeriveNextKey` | **No** | `signer:generate` not granted | +| `StopDaemon`, `DebugLevel` | **No** | `meta:write` not granted | + +Worst case (macaroon leaked, `--ip_address` bypassed somehow): attacker +can create arbitrary fake invoices on the merchant's lnd, which clutters +the database but moves zero value — no one has to pay those invoices, +and even if someone did, the money goes to the merchant. + +**6. Revocation:** + +If you suspect the macaroon was compromised, the merchant runs: + +```bash +lncli listmacaroonids # find the id of the gateway's macaroon +lncli deletemacaroonid # revokes immediately +``` + +Then they bake a new one and send it over. Prism reloads the macaroon on +restart; plan a brief maintenance window or a hot-reload path if zero +downtime matters. + +### Hardening checklist (merchant side) + +In addition to the macaroon scope, merchants can defend in depth: + +- **Network ACL**: restrict inbound gRPC (`:10009`) to the gateway's IP + range via firewall. The gateway doesn't need public access to the lnd. +- **TLS pinning**: the gateway stores the merchant's `tls.cert`; on + every connection LND verifies the cert. Rotate cert → notify gateway + to re-deploy. +- **Watch logs**: if the macaroon's monthly RPC pattern changes + abruptly (many `AddInvoice` with amount 0, or calls from new IPs), + rotate. +- **Rebake every 90 days**: document the rotation date alongside the + gateway operator, put it in the calendar. + +### Migration from single-lnd mode + +To convert an existing single-lnd deployment to multi-merchant: + +1. Each target merchant bakes their own gateway macaroon (step 2 above). +2. Edit prism config: add `payment:` blocks to the relevant services. +3. Restart prism. Services with `payment:` start issuing invoices on + the merchant's lnd; services without continue on the global lnd. +4. Migrate one merchant at a time; the two modes coexist. + +Existing settled transactions in the admin DB continue to be visible +(they're keyed by payment hash, not lnd node). Newly created invoices +after the cutover won't be visible on the global lnd — verify with +`prismcli transactions list --service=` on the gateway. + ## Security - **Macaroon auth**: All endpoints except `GetHealth` require a valid admin diff --git a/sample-conf.yaml b/sample-conf.yaml index 24db5126..7f8f0dec 100644 --- a/sample-conf.yaml +++ b/sample-conf.yaml @@ -363,6 +363,33 @@ services: # Any path not matched above costs `price` satoshis per token. + # Multi-merchant example — a service that routes its own invoices to + # the merchant's own lnd node instead of the global authenticator lnd. + # Use this when onboarding third-party developers so payments land + # directly in their wallet and the gateway never takes custody. + # + # The merchant should bake a minimum-privilege macaroon on their lnd: + # lncli bakemacaroon --save_to=prism-gateway.macaroon \ + # --ip_address= --expiry=7776000 \ + # invoices:read invoices:write info:read + # + # Then hand tls.cert + prism-gateway.macaroon + lnd host:port to the + # gateway operator. See docs/admin-api.md "Merchant onboarding" for + # the full procedure, permission rationale, and rotation advice. + # + # Services WITHOUT this block keep using the global authenticator.lnd + # — both modes coexist in one prism deployment. + # - name: "merchant-a" + # hostregexp: '^a\.example\.com$' + # pathregexp: '^/.*$' + # address: "https://merchant-a.api.internal:8443" + # protocol: https + # price: 10000000 + # payment: + # lndhost: "merchant-a.lnd.internal:10009" + # tlspath: "/gateway-secrets/merchant-a/tls.cert" + # macpath: "/gateway-secrets/merchant-a/prism-gateway.macaroon" + # Settings for a Tor instance to allow requests over Tor as onion services. # Configuring Tor is optional. tor: From cab4e0725f736fba3c9164b5b72ee8490a924a0c Mon Sep 17 00:00:00 2001 From: ai-chen2050 <1033467071@qq.com> Date: Fri, 24 Apr 2026 17:38:32 +0800 Subject: [PATCH 18/32] feat(admin): expose per-service payment backend via admin API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends Phase B of the multi-merchant routing work (#55966ad) so merchant-specific lnd overrides can be managed at runtime through the admin API, not just via YAML. - DB migration 000008 adds payment_lndhost / payment_tlspath / payment_macpath columns to services (NOT NULL, default ''). UpsertService and sqlc types thread the new fields through. - adminrpc: new PaymentBackend message, optional payment field on Service / CreateServiceRequest / UpdateServiceRequest, plus a clear_payment boolean on UpdateServiceRequest (mutually exclusive with setting payment) for removing an override explicitly. - admin/server.go: proxyServiceToProto centralises the mapping and validatePaymentBackend enforces all-or-nothing at the handler edge. - aperture.go: mergeServicesFromDB now runs before buildPerServiceChallengers at startup, so services created via the admin API (and their payment overrides) participate in the challenger router on the next restart — previously the router only considered YAML-defined services and DB-only services silently fell back to the global lnd. Changes take effect on restart (router is built at startup). Co-Authored-By: Claude Opus 4.7 (1M context) --- admin/server.go | 178 ++++++--- adminrpc/admin.pb.go | 376 ++++++++++++------ adminrpc/admin.proto | 45 +++ adminrpc/admin.swagger.json | 34 ++ aperture.go | 28 +- aperturedb/services.go | 33 +- .../000008_services_payment.down.sql | 3 + .../migrations/000008_services_payment.up.sql | 11 + aperturedb/sqlc/models.go | 25 +- aperturedb/sqlc/queries/services.sql | 8 +- aperturedb/sqlc/schemas/generated_schema.sql | 2 +- aperturedb/sqlc/services.sql.go | 39 +- 12 files changed, 572 insertions(+), 210 deletions(-) create mode 100644 aperturedb/sqlc/migrations/000008_services_payment.down.sql create mode 100644 aperturedb/sqlc/migrations/000008_services_payment.up.sql diff --git a/admin/server.go b/admin/server.go index 1b5906f7..4655cb11 100644 --- a/admin/server.go +++ b/admin/server.go @@ -176,21 +176,61 @@ func (s *Server) ListServices(_ context.Context, resp := make([]*adminrpc.Service, 0, len(services)) for _, svc := range services { - resp = append(resp, &adminrpc.Service{ - Name: svc.Name, - Address: svc.Address, - Protocol: svc.Protocol, - HostRegexp: svc.HostRegexp, - PathRegexp: svc.PathRegexp, - Price: svc.Price, - Auth: string(svc.Auth), - AuthScheme: stringToAuthScheme(svc.AuthScheme), - }) + resp = append(resp, proxyServiceToProto(svc)) } return &adminrpc.ListServicesResponse{Services: resp}, nil } +// proxyServiceToProto converts an internal proxy.Service to its wire +// representation. Keeping this in one place so fields added to Service +// (like the payment override) reach every admin API response without +// needing to update each handler. +func proxyServiceToProto(svc *proxy.Service) *adminrpc.Service { + out := &adminrpc.Service{ + Name: svc.Name, + Address: svc.Address, + Protocol: svc.Protocol, + HostRegexp: svc.HostRegexp, + PathRegexp: svc.PathRegexp, + Price: svc.Price, + Auth: string(svc.Auth), + AuthScheme: stringToAuthScheme(svc.AuthScheme), + } + if svc.Payment != nil { + out.Payment = &adminrpc.PaymentBackend{ + LndHost: svc.Payment.LndHost, + TlsPath: svc.Payment.TLSPath, + MacPath: svc.Payment.MacPath, + } + } + return out +} + +// validatePaymentBackend enforces that a payment block is internally +// consistent: either all three fields are populated or all three are +// empty. Returns a user-friendly error message otherwise. +func validatePaymentBackend(p *adminrpc.PaymentBackend) error { + if p == nil { + return nil + } + set := 0 + if p.LndHost != "" { + set++ + } + if p.TlsPath != "" { + set++ + } + if p.MacPath != "" { + set++ + } + if set != 0 && set != 3 { + return fmt.Errorf("payment block requires all of lnd_host, "+ + "tls_path, mac_path (got %d of 3 populated)", set) + } + return nil +} + // CreateService creates a new backend service. When a ServiceStore is // configured, the service is persisted to the database and will survive // restarts. @@ -289,6 +329,10 @@ func (s *Server) CreateService(ctx context.Context, authScheme := authSchemeToString(req.AuthScheme) + if err := validatePaymentBackend(req.Payment); err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + newSvc := &proxy.Service{ Name: req.Name, Address: req.Address, @@ -301,6 +345,16 @@ func (s *Server) CreateService(ctx context.Context, if normalizedAuth != "" { newSvc.Auth = auth.Level(normalizedAuth) } + // Attach the payment override when the client supplied a fully + // populated block. Takes effect on the next prism restart — the + // in-memory challenger router is built during startup only. + if req.Payment != nil && req.Payment.LndHost != "" { + newSvc.Payment = &proxy.PaymentBackend{ + LndHost: req.Payment.LndHost, + TLSPath: req.Payment.TlsPath, + MacPath: req.Payment.MacPath, + } + } services = append(services, newSvc) if err := s.cfg.UpdateServices(services); err != nil { @@ -313,32 +367,29 @@ func (s *Server) CreateService(ctx context.Context, // Persist the new service to the database if a store is // configured. if s.cfg.ServiceStore != nil { + params := aperturedb.ServiceParams{ + Name: newSvc.Name, + Address: newSvc.Address, + Protocol: newSvc.Protocol, + HostRegexp: newSvc.HostRegexp, + PathRegexp: newSvc.PathRegexp, + Auth: string(newSvc.Auth), + AuthScheme: newSvc.AuthScheme, + Price: newSvc.Price, + } + if newSvc.Payment != nil { + params.PaymentLndHost = newSvc.Payment.LndHost + params.PaymentTLSPath = newSvc.Payment.TLSPath + params.PaymentMacPath = newSvc.Payment.MacPath + } if err := s.cfg.ServiceStore.UpsertService( - ctx, aperturedb.ServiceParams{ - Name: newSvc.Name, - Address: newSvc.Address, - Protocol: newSvc.Protocol, - HostRegexp: newSvc.HostRegexp, - PathRegexp: newSvc.PathRegexp, - Auth: string(newSvc.Auth), - AuthScheme: newSvc.AuthScheme, - Price: newSvc.Price, - }, + ctx, params, ); err != nil { log.Errorf("Error persisting service: %v", err) } } - return &adminrpc.Service{ - Name: newSvc.Name, - Address: newSvc.Address, - Protocol: newSvc.Protocol, - HostRegexp: newSvc.HostRegexp, - PathRegexp: newSvc.PathRegexp, - Price: newSvc.Price, - Auth: string(newSvc.Auth), - AuthScheme: stringToAuthScheme(newSvc.AuthScheme), - }, nil + return proxyServiceToProto(newSvc), nil } // UpdateService updates a service's mutable fields. When a ServiceStore is @@ -449,6 +500,32 @@ func (s *Server) UpdateService(ctx context.Context, updated.AuthScheme = authSchemeToString(*req.AuthScheme) } + // Update payment block. Three cases: ClearPayment=true removes any + // existing override; Payment populated installs or replaces one; + // both unset leaves existing config untouched. We explicitly reject + // passing both together. + if req.ClearPayment && req.Payment != nil { + return nil, status.Error( + codes.InvalidArgument, + "clear_payment and payment are mutually exclusive", + ) + } + switch { + case req.ClearPayment: + updated.Payment = nil + case req.Payment != nil && req.Payment.LndHost != "": + if err := validatePaymentBackend(req.Payment); err != nil { + return nil, status.Error( + codes.InvalidArgument, err.Error(), + ) + } + updated.Payment = &proxy.PaymentBackend{ + LndHost: req.Payment.LndHost, + TLSPath: req.Payment.TlsPath, + MacPath: req.Payment.MacPath, + } + } + // Replace the pointer in the slice with the updated copy. for i, svc := range services { if svc.Name == req.Name { @@ -467,33 +544,34 @@ func (s *Server) UpdateService(ctx context.Context, // Persist the updated service to the database if a store is // configured. if s.cfg.ServiceStore != nil { + params := aperturedb.ServiceParams{ + Name: updated.Name, + Address: updated.Address, + Protocol: updated.Protocol, + HostRegexp: updated.HostRegexp, + PathRegexp: updated.PathRegexp, + Auth: string(updated.Auth), + AuthScheme: updated.AuthScheme, + Price: updated.Price, + } + if updated.Payment != nil { + params.PaymentLndHost = updated.Payment.LndHost + params.PaymentTLSPath = updated.Payment.TLSPath + params.PaymentMacPath = updated.Payment.MacPath + } + // When ClearPayment was honored, updated.Payment is nil and + // params.Payment* fields are empty strings — the UPSERT will + // overwrite the old values with empties, which is the + // desired effect. if err := s.cfg.ServiceStore.UpsertService( - ctx, aperturedb.ServiceParams{ - Name: updated.Name, - Address: updated.Address, - Protocol: updated.Protocol, - HostRegexp: updated.HostRegexp, - PathRegexp: updated.PathRegexp, - Auth: string(updated.Auth), - AuthScheme: updated.AuthScheme, - Price: updated.Price, - }, + ctx, params, ); err != nil { log.Errorf("Error persisting updated service: %v", err) } } - return &adminrpc.Service{ - Name: updated.Name, - Address: updated.Address, - Protocol: updated.Protocol, - HostRegexp: updated.HostRegexp, - PathRegexp: updated.PathRegexp, - Price: updated.Price, - Auth: string(updated.Auth), - AuthScheme: stringToAuthScheme(updated.AuthScheme), - }, nil + return proxyServiceToProto(&updated), nil } // DeleteService removes a backend service by name. diff --git a/adminrpc/admin.pb.go b/adminrpc/admin.pb.go index 1bab3276..4c43e81f 100644 --- a/adminrpc/admin.pb.go +++ b/adminrpc/admin.pb.go @@ -383,7 +383,13 @@ type Service struct { Auth string `protobuf:"bytes,7,opt,name=auth,proto3" json:"auth,omitempty"` // auth_scheme specifies which payment auth scheme(s) are used for this // service. Defaults to AUTH_SCHEME_L402 for backwards compatibility. - AuthScheme AuthScheme `protobuf:"varint,8,opt,name=auth_scheme,json=authScheme,proto3,enum=adminrpc.AuthScheme" json:"auth_scheme,omitempty"` + AuthScheme AuthScheme `protobuf:"varint,8,opt,name=auth_scheme,json=authScheme,proto3,enum=adminrpc.AuthScheme" json:"auth_scheme,omitempty"` + // payment optionally overrides the global authenticator lnd for this + // service (multi-merchant mode). When set, invoices for this service + // are issued against the merchant's own lnd so payments land in their + // wallet — the gateway never takes custody. Unset = legacy single-lnd + // mode using authenticator.lndhost. + Payment *PaymentBackend `protobuf:"bytes,9,opt,name=payment,proto3" json:"payment,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -474,6 +480,86 @@ func (x *Service) GetAuthScheme() AuthScheme { return AuthScheme_AUTH_SCHEME_L402 } +func (x *Service) GetPayment() *PaymentBackend { + if x != nil { + return x.Payment + } + return nil +} + +// PaymentBackend is an optional per-service lnd connection used in multi- +// merchant deployments. The merchant runs their own lnd and hands the +// gateway a minimum-privilege macaroon (invoices:read invoices:write +// info:read) so the gateway can create/verify invoices but cannot move +// funds or see wallet state. +// +// All three fields are required when the block is present. Paths are +// absolute paths on the gateway host's filesystem. +type PaymentBackend struct { + state protoimpl.MessageState `protogen:"open.v1"` + // lnd_host is the merchant lnd's gRPC address (host:port). + LndHost string `protobuf:"bytes,1,opt,name=lnd_host,json=lndHost,proto3" json:"lnd_host,omitempty"` + // tls_path is the path to the merchant lnd's tls.cert (public). + TlsPath string `protobuf:"bytes,2,opt,name=tls_path,json=tlsPath,proto3" json:"tls_path,omitempty"` + // mac_path is the absolute path to the minimum-privilege macaroon + // file supplied by the merchant. Only invoices:{read,write} and + // info:read are expected to be granted. + MacPath string `protobuf:"bytes,3,opt,name=mac_path,json=macPath,proto3" json:"mac_path,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PaymentBackend) Reset() { + *x = PaymentBackend{} + mi := &file_admin_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PaymentBackend) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PaymentBackend) ProtoMessage() {} + +func (x *PaymentBackend) ProtoReflect() protoreflect.Message { + mi := &file_admin_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PaymentBackend.ProtoReflect.Descriptor instead. +func (*PaymentBackend) Descriptor() ([]byte, []int) { + return file_admin_proto_rawDescGZIP(), []int{7} +} + +func (x *PaymentBackend) GetLndHost() string { + if x != nil { + return x.LndHost + } + return "" +} + +func (x *PaymentBackend) GetTlsPath() string { + if x != nil { + return x.TlsPath + } + return "" +} + +func (x *PaymentBackend) GetMacPath() string { + if x != nil { + return x.MacPath + } + return "" +} + type CreateServiceRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` @@ -485,14 +571,19 @@ type CreateServiceRequest struct { Auth string `protobuf:"bytes,7,opt,name=auth,proto3" json:"auth,omitempty"` // auth_scheme specifies which payment auth scheme(s) to use. Defaults to // AUTH_SCHEME_L402 if unset. - AuthScheme AuthScheme `protobuf:"varint,8,opt,name=auth_scheme,json=authScheme,proto3,enum=adminrpc.AuthScheme" json:"auth_scheme,omitempty"` + AuthScheme AuthScheme `protobuf:"varint,8,opt,name=auth_scheme,json=authScheme,proto3,enum=adminrpc.AuthScheme" json:"auth_scheme,omitempty"` + // payment optionally configures a per-service lnd override. See the + // Service message for field semantics. Omit for legacy single-lnd + // mode. Setting this takes effect after the next prism restart — + // the in-memory challenger router is built at startup. + Payment *PaymentBackend `protobuf:"bytes,9,opt,name=payment,proto3" json:"payment,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *CreateServiceRequest) Reset() { *x = CreateServiceRequest{} - mi := &file_admin_proto_msgTypes[7] + mi := &file_admin_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -504,7 +595,7 @@ func (x *CreateServiceRequest) String() string { func (*CreateServiceRequest) ProtoMessage() {} func (x *CreateServiceRequest) ProtoReflect() protoreflect.Message { - mi := &file_admin_proto_msgTypes[7] + mi := &file_admin_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -517,7 +608,7 @@ func (x *CreateServiceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateServiceRequest.ProtoReflect.Descriptor instead. func (*CreateServiceRequest) Descriptor() ([]byte, []int) { - return file_admin_proto_rawDescGZIP(), []int{7} + return file_admin_proto_rawDescGZIP(), []int{8} } func (x *CreateServiceRequest) GetName() string { @@ -576,6 +667,13 @@ func (x *CreateServiceRequest) GetAuthScheme() AuthScheme { return AuthScheme_AUTH_SCHEME_L402 } +func (x *CreateServiceRequest) GetPayment() *PaymentBackend { + if x != nil { + return x.Payment + } + return nil +} + type UpdateServiceRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` @@ -587,14 +685,23 @@ type UpdateServiceRequest struct { Auth string `protobuf:"bytes,7,opt,name=auth,proto3" json:"auth,omitempty"` // auth_scheme specifies which payment auth scheme(s) to use. When not // set, the existing auth_scheme is preserved (not reset to L402). - AuthScheme *AuthScheme `protobuf:"varint,8,opt,name=auth_scheme,json=authScheme,proto3,enum=adminrpc.AuthScheme,oneof" json:"auth_scheme,omitempty"` + AuthScheme *AuthScheme `protobuf:"varint,8,opt,name=auth_scheme,json=authScheme,proto3,enum=adminrpc.AuthScheme,oneof" json:"auth_scheme,omitempty"` + // payment updates the per-service lnd override. When omitted (nil), + // the existing payment config is preserved (not reset). To explicitly + // remove a payment override from a service, set clear_payment=true + // instead. As with CreateService, changes take effect on restart. + Payment *PaymentBackend `protobuf:"bytes,9,opt,name=payment,proto3" json:"payment,omitempty"` + // clear_payment, when true, removes any existing per-service lnd + // override from the service, returning it to the global single-lnd + // mode. Mutually exclusive with setting payment. + ClearPayment bool `protobuf:"varint,10,opt,name=clear_payment,json=clearPayment,proto3" json:"clear_payment,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *UpdateServiceRequest) Reset() { *x = UpdateServiceRequest{} - mi := &file_admin_proto_msgTypes[8] + mi := &file_admin_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -606,7 +713,7 @@ func (x *UpdateServiceRequest) String() string { func (*UpdateServiceRequest) ProtoMessage() {} func (x *UpdateServiceRequest) ProtoReflect() protoreflect.Message { - mi := &file_admin_proto_msgTypes[8] + mi := &file_admin_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -619,7 +726,7 @@ func (x *UpdateServiceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateServiceRequest.ProtoReflect.Descriptor instead. func (*UpdateServiceRequest) Descriptor() ([]byte, []int) { - return file_admin_proto_rawDescGZIP(), []int{8} + return file_admin_proto_rawDescGZIP(), []int{9} } func (x *UpdateServiceRequest) GetName() string { @@ -678,6 +785,20 @@ func (x *UpdateServiceRequest) GetAuthScheme() AuthScheme { return AuthScheme_AUTH_SCHEME_L402 } +func (x *UpdateServiceRequest) GetPayment() *PaymentBackend { + if x != nil { + return x.Payment + } + return nil +} + +func (x *UpdateServiceRequest) GetClearPayment() bool { + if x != nil { + return x.ClearPayment + } + return false +} + type DeleteServiceRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` @@ -687,7 +808,7 @@ type DeleteServiceRequest struct { func (x *DeleteServiceRequest) Reset() { *x = DeleteServiceRequest{} - mi := &file_admin_proto_msgTypes[9] + mi := &file_admin_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -699,7 +820,7 @@ func (x *DeleteServiceRequest) String() string { func (*DeleteServiceRequest) ProtoMessage() {} func (x *DeleteServiceRequest) ProtoReflect() protoreflect.Message { - mi := &file_admin_proto_msgTypes[9] + mi := &file_admin_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -712,7 +833,7 @@ func (x *DeleteServiceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteServiceRequest.ProtoReflect.Descriptor instead. func (*DeleteServiceRequest) Descriptor() ([]byte, []int) { - return file_admin_proto_rawDescGZIP(), []int{9} + return file_admin_proto_rawDescGZIP(), []int{10} } func (x *DeleteServiceRequest) GetName() string { @@ -731,7 +852,7 @@ type DeleteServiceResponse struct { func (x *DeleteServiceResponse) Reset() { *x = DeleteServiceResponse{} - mi := &file_admin_proto_msgTypes[10] + mi := &file_admin_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -743,7 +864,7 @@ func (x *DeleteServiceResponse) String() string { func (*DeleteServiceResponse) ProtoMessage() {} func (x *DeleteServiceResponse) ProtoReflect() protoreflect.Message { - mi := &file_admin_proto_msgTypes[10] + mi := &file_admin_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -756,7 +877,7 @@ func (x *DeleteServiceResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteServiceResponse.ProtoReflect.Descriptor instead. func (*DeleteServiceResponse) Descriptor() ([]byte, []int) { - return file_admin_proto_rawDescGZIP(), []int{10} + return file_admin_proto_rawDescGZIP(), []int{11} } func (x *DeleteServiceResponse) GetStatus() string { @@ -780,7 +901,7 @@ type ListTransactionsRequest struct { func (x *ListTransactionsRequest) Reset() { *x = ListTransactionsRequest{} - mi := &file_admin_proto_msgTypes[11] + mi := &file_admin_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -792,7 +913,7 @@ func (x *ListTransactionsRequest) String() string { func (*ListTransactionsRequest) ProtoMessage() {} func (x *ListTransactionsRequest) ProtoReflect() protoreflect.Message { - mi := &file_admin_proto_msgTypes[11] + mi := &file_admin_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -805,7 +926,7 @@ func (x *ListTransactionsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListTransactionsRequest.ProtoReflect.Descriptor instead. func (*ListTransactionsRequest) Descriptor() ([]byte, []int) { - return file_admin_proto_rawDescGZIP(), []int{11} + return file_admin_proto_rawDescGZIP(), []int{12} } func (x *ListTransactionsRequest) GetService() string { @@ -860,7 +981,7 @@ type ListTransactionsResponse struct { func (x *ListTransactionsResponse) Reset() { *x = ListTransactionsResponse{} - mi := &file_admin_proto_msgTypes[12] + mi := &file_admin_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -872,7 +993,7 @@ func (x *ListTransactionsResponse) String() string { func (*ListTransactionsResponse) ProtoMessage() {} func (x *ListTransactionsResponse) ProtoReflect() protoreflect.Message { - mi := &file_admin_proto_msgTypes[12] + mi := &file_admin_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -885,7 +1006,7 @@ func (x *ListTransactionsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListTransactionsResponse.ProtoReflect.Descriptor instead. func (*ListTransactionsResponse) Descriptor() ([]byte, []int) { - return file_admin_proto_rawDescGZIP(), []int{12} + return file_admin_proto_rawDescGZIP(), []int{13} } func (x *ListTransactionsResponse) GetTransactions() []*Transaction { @@ -918,7 +1039,7 @@ type Transaction struct { func (x *Transaction) Reset() { *x = Transaction{} - mi := &file_admin_proto_msgTypes[13] + mi := &file_admin_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -930,7 +1051,7 @@ func (x *Transaction) String() string { func (*Transaction) ProtoMessage() {} func (x *Transaction) ProtoReflect() protoreflect.Message { - mi := &file_admin_proto_msgTypes[13] + mi := &file_admin_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -943,7 +1064,7 @@ func (x *Transaction) ProtoReflect() protoreflect.Message { // Deprecated: Use Transaction.ProtoReflect.Descriptor instead. func (*Transaction) Descriptor() ([]byte, []int) { - return file_admin_proto_rawDescGZIP(), []int{13} + return file_admin_proto_rawDescGZIP(), []int{14} } func (x *Transaction) GetId() int32 { @@ -1012,7 +1133,7 @@ type ListTokensRequest struct { func (x *ListTokensRequest) Reset() { *x = ListTokensRequest{} - mi := &file_admin_proto_msgTypes[14] + mi := &file_admin_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1024,7 +1145,7 @@ func (x *ListTokensRequest) String() string { func (*ListTokensRequest) ProtoMessage() {} func (x *ListTokensRequest) ProtoReflect() protoreflect.Message { - mi := &file_admin_proto_msgTypes[14] + mi := &file_admin_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1037,7 +1158,7 @@ func (x *ListTokensRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListTokensRequest.ProtoReflect.Descriptor instead. func (*ListTokensRequest) Descriptor() ([]byte, []int) { - return file_admin_proto_rawDescGZIP(), []int{14} + return file_admin_proto_rawDescGZIP(), []int{15} } func (x *ListTokensRequest) GetLimit() int32 { @@ -1064,7 +1185,7 @@ type ListTokensResponse struct { func (x *ListTokensResponse) Reset() { *x = ListTokensResponse{} - mi := &file_admin_proto_msgTypes[15] + mi := &file_admin_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1076,7 +1197,7 @@ func (x *ListTokensResponse) String() string { func (*ListTokensResponse) ProtoMessage() {} func (x *ListTokensResponse) ProtoReflect() protoreflect.Message { - mi := &file_admin_proto_msgTypes[15] + mi := &file_admin_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1089,7 +1210,7 @@ func (x *ListTokensResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListTokensResponse.ProtoReflect.Descriptor instead. func (*ListTokensResponse) Descriptor() ([]byte, []int) { - return file_admin_proto_rawDescGZIP(), []int{15} + return file_admin_proto_rawDescGZIP(), []int{16} } func (x *ListTokensResponse) GetTokens() []*Transaction { @@ -1115,7 +1236,7 @@ type RevokeTokenRequest struct { func (x *RevokeTokenRequest) Reset() { *x = RevokeTokenRequest{} - mi := &file_admin_proto_msgTypes[16] + mi := &file_admin_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1127,7 +1248,7 @@ func (x *RevokeTokenRequest) String() string { func (*RevokeTokenRequest) ProtoMessage() {} func (x *RevokeTokenRequest) ProtoReflect() protoreflect.Message { - mi := &file_admin_proto_msgTypes[16] + mi := &file_admin_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1140,7 +1261,7 @@ func (x *RevokeTokenRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RevokeTokenRequest.ProtoReflect.Descriptor instead. func (*RevokeTokenRequest) Descriptor() ([]byte, []int) { - return file_admin_proto_rawDescGZIP(), []int{16} + return file_admin_proto_rawDescGZIP(), []int{17} } func (x *RevokeTokenRequest) GetTokenId() string { @@ -1159,7 +1280,7 @@ type RevokeTokenResponse struct { func (x *RevokeTokenResponse) Reset() { *x = RevokeTokenResponse{} - mi := &file_admin_proto_msgTypes[17] + mi := &file_admin_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1171,7 +1292,7 @@ func (x *RevokeTokenResponse) String() string { func (*RevokeTokenResponse) ProtoMessage() {} func (x *RevokeTokenResponse) ProtoReflect() protoreflect.Message { - mi := &file_admin_proto_msgTypes[17] + mi := &file_admin_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1184,7 +1305,7 @@ func (x *RevokeTokenResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RevokeTokenResponse.ProtoReflect.Descriptor instead. func (*RevokeTokenResponse) Descriptor() ([]byte, []int) { - return file_admin_proto_rawDescGZIP(), []int{17} + return file_admin_proto_rawDescGZIP(), []int{18} } func (x *RevokeTokenResponse) GetStatus() string { @@ -1204,7 +1325,7 @@ type GetStatsRequest struct { func (x *GetStatsRequest) Reset() { *x = GetStatsRequest{} - mi := &file_admin_proto_msgTypes[18] + mi := &file_admin_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1216,7 +1337,7 @@ func (x *GetStatsRequest) String() string { func (*GetStatsRequest) ProtoMessage() {} func (x *GetStatsRequest) ProtoReflect() protoreflect.Message { - mi := &file_admin_proto_msgTypes[18] + mi := &file_admin_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1229,7 +1350,7 @@ func (x *GetStatsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetStatsRequest.ProtoReflect.Descriptor instead. func (*GetStatsRequest) Descriptor() ([]byte, []int) { - return file_admin_proto_rawDescGZIP(), []int{18} + return file_admin_proto_rawDescGZIP(), []int{19} } func (x *GetStatsRequest) GetFrom() string { @@ -1257,7 +1378,7 @@ type GetStatsResponse struct { func (x *GetStatsResponse) Reset() { *x = GetStatsResponse{} - mi := &file_admin_proto_msgTypes[19] + mi := &file_admin_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1269,7 +1390,7 @@ func (x *GetStatsResponse) String() string { func (*GetStatsResponse) ProtoMessage() {} func (x *GetStatsResponse) ProtoReflect() protoreflect.Message { - mi := &file_admin_proto_msgTypes[19] + mi := &file_admin_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1282,7 +1403,7 @@ func (x *GetStatsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetStatsResponse.ProtoReflect.Descriptor instead. func (*GetStatsResponse) Descriptor() ([]byte, []int) { - return file_admin_proto_rawDescGZIP(), []int{19} + return file_admin_proto_rawDescGZIP(), []int{20} } func (x *GetStatsResponse) GetTotalRevenueSats() int64 { @@ -1316,7 +1437,7 @@ type ServiceRevenue struct { func (x *ServiceRevenue) Reset() { *x = ServiceRevenue{} - mi := &file_admin_proto_msgTypes[20] + mi := &file_admin_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1328,7 +1449,7 @@ func (x *ServiceRevenue) String() string { func (*ServiceRevenue) ProtoMessage() {} func (x *ServiceRevenue) ProtoReflect() protoreflect.Message { - mi := &file_admin_proto_msgTypes[20] + mi := &file_admin_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1341,7 +1462,7 @@ func (x *ServiceRevenue) ProtoReflect() protoreflect.Message { // Deprecated: Use ServiceRevenue.ProtoReflect.Descriptor instead. func (*ServiceRevenue) Descriptor() ([]byte, []int) { - return file_admin_proto_rawDescGZIP(), []int{20} + return file_admin_proto_rawDescGZIP(), []int{21} } func (x *ServiceRevenue) GetServiceName() string { @@ -1381,7 +1502,7 @@ type MPPSession struct { func (x *MPPSession) Reset() { *x = MPPSession{} - mi := &file_admin_proto_msgTypes[21] + mi := &file_admin_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1393,7 +1514,7 @@ func (x *MPPSession) String() string { func (*MPPSession) ProtoMessage() {} func (x *MPPSession) ProtoReflect() protoreflect.Message { - mi := &file_admin_proto_msgTypes[21] + mi := &file_admin_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1406,7 +1527,7 @@ func (x *MPPSession) ProtoReflect() protoreflect.Message { // Deprecated: Use MPPSession.ProtoReflect.Descriptor instead. func (*MPPSession) Descriptor() ([]byte, []int) { - return file_admin_proto_rawDescGZIP(), []int{21} + return file_admin_proto_rawDescGZIP(), []int{22} } func (x *MPPSession) GetSessionId() string { @@ -1486,7 +1607,7 @@ type ListSessionsRequest struct { func (x *ListSessionsRequest) Reset() { *x = ListSessionsRequest{} - mi := &file_admin_proto_msgTypes[22] + mi := &file_admin_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1498,7 +1619,7 @@ func (x *ListSessionsRequest) String() string { func (*ListSessionsRequest) ProtoMessage() {} func (x *ListSessionsRequest) ProtoReflect() protoreflect.Message { - mi := &file_admin_proto_msgTypes[22] + mi := &file_admin_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1511,7 +1632,7 @@ func (x *ListSessionsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListSessionsRequest.ProtoReflect.Descriptor instead. func (*ListSessionsRequest) Descriptor() ([]byte, []int) { - return file_admin_proto_rawDescGZIP(), []int{22} + return file_admin_proto_rawDescGZIP(), []int{23} } func (x *ListSessionsRequest) GetStatus() string { @@ -1546,7 +1667,7 @@ type ListSessionsResponse struct { func (x *ListSessionsResponse) Reset() { *x = ListSessionsResponse{} - mi := &file_admin_proto_msgTypes[23] + mi := &file_admin_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1558,7 +1679,7 @@ func (x *ListSessionsResponse) String() string { func (*ListSessionsResponse) ProtoMessage() {} func (x *ListSessionsResponse) ProtoReflect() protoreflect.Message { - mi := &file_admin_proto_msgTypes[23] + mi := &file_admin_proto_msgTypes[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1571,7 +1692,7 @@ func (x *ListSessionsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListSessionsResponse.ProtoReflect.Descriptor instead. func (*ListSessionsResponse) Descriptor() ([]byte, []int) { - return file_admin_proto_rawDescGZIP(), []int{23} + return file_admin_proto_rawDescGZIP(), []int{24} } func (x *ListSessionsResponse) GetSessions() []*MPPSession { @@ -1596,7 +1717,7 @@ type GetSessionStatsRequest struct { func (x *GetSessionStatsRequest) Reset() { *x = GetSessionStatsRequest{} - mi := &file_admin_proto_msgTypes[24] + mi := &file_admin_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1608,7 +1729,7 @@ func (x *GetSessionStatsRequest) String() string { func (*GetSessionStatsRequest) ProtoMessage() {} func (x *GetSessionStatsRequest) ProtoReflect() protoreflect.Message { - mi := &file_admin_proto_msgTypes[24] + mi := &file_admin_proto_msgTypes[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1621,7 +1742,7 @@ func (x *GetSessionStatsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetSessionStatsRequest.ProtoReflect.Descriptor instead. func (*GetSessionStatsRequest) Descriptor() ([]byte, []int) { - return file_admin_proto_rawDescGZIP(), []int{24} + return file_admin_proto_rawDescGZIP(), []int{25} } type GetSessionStatsResponse struct { @@ -1644,7 +1765,7 @@ type GetSessionStatsResponse struct { func (x *GetSessionStatsResponse) Reset() { *x = GetSessionStatsResponse{} - mi := &file_admin_proto_msgTypes[25] + mi := &file_admin_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1656,7 +1777,7 @@ func (x *GetSessionStatsResponse) String() string { func (*GetSessionStatsResponse) ProtoMessage() {} func (x *GetSessionStatsResponse) ProtoReflect() protoreflect.Message { - mi := &file_admin_proto_msgTypes[25] + mi := &file_admin_proto_msgTypes[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1669,7 +1790,7 @@ func (x *GetSessionStatsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetSessionStatsResponse.ProtoReflect.Descriptor instead. func (*GetSessionStatsResponse) Descriptor() ([]byte, []int) { - return file_admin_proto_rawDescGZIP(), []int{25} + return file_admin_proto_rawDescGZIP(), []int{26} } func (x *GetSessionStatsResponse) GetTotalSessions() int64 { @@ -1735,7 +1856,7 @@ const file_admin_proto_rawDesc = "" + "\x06status\x18\x01 \x01(\tR\x06status\"\x15\n" + "\x13ListServicesRequest\"E\n" + "\x14ListServicesResponse\x12-\n" + - "\bservices\x18\x01 \x03(\v2\x11.adminrpc.ServiceR\bservices\"\xf6\x01\n" + + "\bservices\x18\x01 \x03(\v2\x11.adminrpc.ServiceR\bservices\"\xaa\x02\n" + "\aService\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x18\n" + "\aaddress\x18\x02 \x01(\tR\aaddress\x12\x1a\n" + @@ -1747,7 +1868,12 @@ const file_admin_proto_rawDesc = "" + "\x05price\x18\x06 \x01(\x03R\x05price\x12\x12\n" + "\x04auth\x18\a \x01(\tR\x04auth\x125\n" + "\vauth_scheme\x18\b \x01(\x0e2\x14.adminrpc.AuthSchemeR\n" + - "authScheme\"\x83\x02\n" + + "authScheme\x122\n" + + "\apayment\x18\t \x01(\v2\x18.adminrpc.PaymentBackendR\apayment\"a\n" + + "\x0ePaymentBackend\x12\x19\n" + + "\blnd_host\x18\x01 \x01(\tR\alndHost\x12\x19\n" + + "\btls_path\x18\x02 \x01(\tR\atlsPath\x12\x19\n" + + "\bmac_path\x18\x03 \x01(\tR\amacPath\"\xb7\x02\n" + "\x14CreateServiceRequest\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x18\n" + "\aaddress\x18\x02 \x01(\tR\aaddress\x12\x1a\n" + @@ -1759,7 +1885,8 @@ const file_admin_proto_rawDesc = "" + "\x05price\x18\x06 \x01(\x03R\x05price\x12\x12\n" + "\x04auth\x18\a \x01(\tR\x04auth\x125\n" + "\vauth_scheme\x18\b \x01(\x0e2\x14.adminrpc.AuthSchemeR\n" + - "authScheme\"\xa7\x02\n" + + "authScheme\x122\n" + + "\apayment\x18\t \x01(\v2\x18.adminrpc.PaymentBackendR\apayment\"\x80\x03\n" + "\x14UpdateServiceRequest\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x18\n" + "\aaddress\x18\x02 \x01(\tR\aaddress\x12\x1a\n" + @@ -1771,7 +1898,10 @@ const file_admin_proto_rawDesc = "" + "\x05price\x18\x06 \x01(\x03H\x00R\x05price\x88\x01\x01\x12\x12\n" + "\x04auth\x18\a \x01(\tR\x04auth\x12:\n" + "\vauth_scheme\x18\b \x01(\x0e2\x14.adminrpc.AuthSchemeH\x01R\n" + - "authScheme\x88\x01\x01B\b\n" + + "authScheme\x88\x01\x01\x122\n" + + "\apayment\x18\t \x01(\v2\x18.adminrpc.PaymentBackendR\apayment\x12#\n" + + "\rclear_payment\x18\n" + + " \x01(\bR\fclearPaymentB\b\n" + "\x06_priceB\x0e\n" + "\f_auth_scheme\"*\n" + "\x14DeleteServiceRequest\x12\x12\n" + @@ -1886,7 +2016,7 @@ func file_admin_proto_rawDescGZIP() []byte { } var file_admin_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_admin_proto_msgTypes = make([]protoimpl.MessageInfo, 26) +var file_admin_proto_msgTypes = make([]protoimpl.MessageInfo, 27) var file_admin_proto_goTypes = []any{ (AuthScheme)(0), // 0: adminrpc.AuthScheme (*GetInfoRequest)(nil), // 1: adminrpc.GetInfoRequest @@ -1896,64 +2026,68 @@ var file_admin_proto_goTypes = []any{ (*ListServicesRequest)(nil), // 5: adminrpc.ListServicesRequest (*ListServicesResponse)(nil), // 6: adminrpc.ListServicesResponse (*Service)(nil), // 7: adminrpc.Service - (*CreateServiceRequest)(nil), // 8: adminrpc.CreateServiceRequest - (*UpdateServiceRequest)(nil), // 9: adminrpc.UpdateServiceRequest - (*DeleteServiceRequest)(nil), // 10: adminrpc.DeleteServiceRequest - (*DeleteServiceResponse)(nil), // 11: adminrpc.DeleteServiceResponse - (*ListTransactionsRequest)(nil), // 12: adminrpc.ListTransactionsRequest - (*ListTransactionsResponse)(nil), // 13: adminrpc.ListTransactionsResponse - (*Transaction)(nil), // 14: adminrpc.Transaction - (*ListTokensRequest)(nil), // 15: adminrpc.ListTokensRequest - (*ListTokensResponse)(nil), // 16: adminrpc.ListTokensResponse - (*RevokeTokenRequest)(nil), // 17: adminrpc.RevokeTokenRequest - (*RevokeTokenResponse)(nil), // 18: adminrpc.RevokeTokenResponse - (*GetStatsRequest)(nil), // 19: adminrpc.GetStatsRequest - (*GetStatsResponse)(nil), // 20: adminrpc.GetStatsResponse - (*ServiceRevenue)(nil), // 21: adminrpc.ServiceRevenue - (*MPPSession)(nil), // 22: adminrpc.MPPSession - (*ListSessionsRequest)(nil), // 23: adminrpc.ListSessionsRequest - (*ListSessionsResponse)(nil), // 24: adminrpc.ListSessionsResponse - (*GetSessionStatsRequest)(nil), // 25: adminrpc.GetSessionStatsRequest - (*GetSessionStatsResponse)(nil), // 26: adminrpc.GetSessionStatsResponse + (*PaymentBackend)(nil), // 8: adminrpc.PaymentBackend + (*CreateServiceRequest)(nil), // 9: adminrpc.CreateServiceRequest + (*UpdateServiceRequest)(nil), // 10: adminrpc.UpdateServiceRequest + (*DeleteServiceRequest)(nil), // 11: adminrpc.DeleteServiceRequest + (*DeleteServiceResponse)(nil), // 12: adminrpc.DeleteServiceResponse + (*ListTransactionsRequest)(nil), // 13: adminrpc.ListTransactionsRequest + (*ListTransactionsResponse)(nil), // 14: adminrpc.ListTransactionsResponse + (*Transaction)(nil), // 15: adminrpc.Transaction + (*ListTokensRequest)(nil), // 16: adminrpc.ListTokensRequest + (*ListTokensResponse)(nil), // 17: adminrpc.ListTokensResponse + (*RevokeTokenRequest)(nil), // 18: adminrpc.RevokeTokenRequest + (*RevokeTokenResponse)(nil), // 19: adminrpc.RevokeTokenResponse + (*GetStatsRequest)(nil), // 20: adminrpc.GetStatsRequest + (*GetStatsResponse)(nil), // 21: adminrpc.GetStatsResponse + (*ServiceRevenue)(nil), // 22: adminrpc.ServiceRevenue + (*MPPSession)(nil), // 23: adminrpc.MPPSession + (*ListSessionsRequest)(nil), // 24: adminrpc.ListSessionsRequest + (*ListSessionsResponse)(nil), // 25: adminrpc.ListSessionsResponse + (*GetSessionStatsRequest)(nil), // 26: adminrpc.GetSessionStatsRequest + (*GetSessionStatsResponse)(nil), // 27: adminrpc.GetSessionStatsResponse } var file_admin_proto_depIdxs = []int32{ 7, // 0: adminrpc.ListServicesResponse.services:type_name -> adminrpc.Service 0, // 1: adminrpc.Service.auth_scheme:type_name -> adminrpc.AuthScheme - 0, // 2: adminrpc.CreateServiceRequest.auth_scheme:type_name -> adminrpc.AuthScheme - 0, // 3: adminrpc.UpdateServiceRequest.auth_scheme:type_name -> adminrpc.AuthScheme - 14, // 4: adminrpc.ListTransactionsResponse.transactions:type_name -> adminrpc.Transaction - 14, // 5: adminrpc.ListTokensResponse.tokens:type_name -> adminrpc.Transaction - 21, // 6: adminrpc.GetStatsResponse.service_breakdown:type_name -> adminrpc.ServiceRevenue - 22, // 7: adminrpc.ListSessionsResponse.sessions:type_name -> adminrpc.MPPSession - 1, // 8: adminrpc.Admin.GetInfo:input_type -> adminrpc.GetInfoRequest - 3, // 9: adminrpc.Admin.GetHealth:input_type -> adminrpc.GetHealthRequest - 5, // 10: adminrpc.Admin.ListServices:input_type -> adminrpc.ListServicesRequest - 8, // 11: adminrpc.Admin.CreateService:input_type -> adminrpc.CreateServiceRequest - 9, // 12: adminrpc.Admin.UpdateService:input_type -> adminrpc.UpdateServiceRequest - 10, // 13: adminrpc.Admin.DeleteService:input_type -> adminrpc.DeleteServiceRequest - 12, // 14: adminrpc.Admin.ListTransactions:input_type -> adminrpc.ListTransactionsRequest - 15, // 15: adminrpc.Admin.ListTokens:input_type -> adminrpc.ListTokensRequest - 17, // 16: adminrpc.Admin.RevokeToken:input_type -> adminrpc.RevokeTokenRequest - 19, // 17: adminrpc.Admin.GetStats:input_type -> adminrpc.GetStatsRequest - 23, // 18: adminrpc.Admin.ListSessions:input_type -> adminrpc.ListSessionsRequest - 25, // 19: adminrpc.Admin.GetSessionStats:input_type -> adminrpc.GetSessionStatsRequest - 2, // 20: adminrpc.Admin.GetInfo:output_type -> adminrpc.GetInfoResponse - 4, // 21: adminrpc.Admin.GetHealth:output_type -> adminrpc.GetHealthResponse - 6, // 22: adminrpc.Admin.ListServices:output_type -> adminrpc.ListServicesResponse - 7, // 23: adminrpc.Admin.CreateService:output_type -> adminrpc.Service - 7, // 24: adminrpc.Admin.UpdateService:output_type -> adminrpc.Service - 11, // 25: adminrpc.Admin.DeleteService:output_type -> adminrpc.DeleteServiceResponse - 13, // 26: adminrpc.Admin.ListTransactions:output_type -> adminrpc.ListTransactionsResponse - 16, // 27: adminrpc.Admin.ListTokens:output_type -> adminrpc.ListTokensResponse - 18, // 28: adminrpc.Admin.RevokeToken:output_type -> adminrpc.RevokeTokenResponse - 20, // 29: adminrpc.Admin.GetStats:output_type -> adminrpc.GetStatsResponse - 24, // 30: adminrpc.Admin.ListSessions:output_type -> adminrpc.ListSessionsResponse - 26, // 31: adminrpc.Admin.GetSessionStats:output_type -> adminrpc.GetSessionStatsResponse - 20, // [20:32] is the sub-list for method output_type - 8, // [8:20] is the sub-list for method input_type - 8, // [8:8] is the sub-list for extension type_name - 8, // [8:8] is the sub-list for extension extendee - 0, // [0:8] is the sub-list for field type_name + 8, // 2: adminrpc.Service.payment:type_name -> adminrpc.PaymentBackend + 0, // 3: adminrpc.CreateServiceRequest.auth_scheme:type_name -> adminrpc.AuthScheme + 8, // 4: adminrpc.CreateServiceRequest.payment:type_name -> adminrpc.PaymentBackend + 0, // 5: adminrpc.UpdateServiceRequest.auth_scheme:type_name -> adminrpc.AuthScheme + 8, // 6: adminrpc.UpdateServiceRequest.payment:type_name -> adminrpc.PaymentBackend + 15, // 7: adminrpc.ListTransactionsResponse.transactions:type_name -> adminrpc.Transaction + 15, // 8: adminrpc.ListTokensResponse.tokens:type_name -> adminrpc.Transaction + 22, // 9: adminrpc.GetStatsResponse.service_breakdown:type_name -> adminrpc.ServiceRevenue + 23, // 10: adminrpc.ListSessionsResponse.sessions:type_name -> adminrpc.MPPSession + 1, // 11: adminrpc.Admin.GetInfo:input_type -> adminrpc.GetInfoRequest + 3, // 12: adminrpc.Admin.GetHealth:input_type -> adminrpc.GetHealthRequest + 5, // 13: adminrpc.Admin.ListServices:input_type -> adminrpc.ListServicesRequest + 9, // 14: adminrpc.Admin.CreateService:input_type -> adminrpc.CreateServiceRequest + 10, // 15: adminrpc.Admin.UpdateService:input_type -> adminrpc.UpdateServiceRequest + 11, // 16: adminrpc.Admin.DeleteService:input_type -> adminrpc.DeleteServiceRequest + 13, // 17: adminrpc.Admin.ListTransactions:input_type -> adminrpc.ListTransactionsRequest + 16, // 18: adminrpc.Admin.ListTokens:input_type -> adminrpc.ListTokensRequest + 18, // 19: adminrpc.Admin.RevokeToken:input_type -> adminrpc.RevokeTokenRequest + 20, // 20: adminrpc.Admin.GetStats:input_type -> adminrpc.GetStatsRequest + 24, // 21: adminrpc.Admin.ListSessions:input_type -> adminrpc.ListSessionsRequest + 26, // 22: adminrpc.Admin.GetSessionStats:input_type -> adminrpc.GetSessionStatsRequest + 2, // 23: adminrpc.Admin.GetInfo:output_type -> adminrpc.GetInfoResponse + 4, // 24: adminrpc.Admin.GetHealth:output_type -> adminrpc.GetHealthResponse + 6, // 25: adminrpc.Admin.ListServices:output_type -> adminrpc.ListServicesResponse + 7, // 26: adminrpc.Admin.CreateService:output_type -> adminrpc.Service + 7, // 27: adminrpc.Admin.UpdateService:output_type -> adminrpc.Service + 12, // 28: adminrpc.Admin.DeleteService:output_type -> adminrpc.DeleteServiceResponse + 14, // 29: adminrpc.Admin.ListTransactions:output_type -> adminrpc.ListTransactionsResponse + 17, // 30: adminrpc.Admin.ListTokens:output_type -> adminrpc.ListTokensResponse + 19, // 31: adminrpc.Admin.RevokeToken:output_type -> adminrpc.RevokeTokenResponse + 21, // 32: adminrpc.Admin.GetStats:output_type -> adminrpc.GetStatsResponse + 25, // 33: adminrpc.Admin.ListSessions:output_type -> adminrpc.ListSessionsResponse + 27, // 34: adminrpc.Admin.GetSessionStats:output_type -> adminrpc.GetSessionStatsResponse + 23, // [23:35] is the sub-list for method output_type + 11, // [11:23] is the sub-list for method input_type + 11, // [11:11] is the sub-list for extension type_name + 11, // [11:11] is the sub-list for extension extendee + 0, // [0:11] is the sub-list for field type_name } func init() { file_admin_proto_init() } @@ -1961,14 +2095,14 @@ func file_admin_proto_init() { if File_admin_proto != nil { return } - file_admin_proto_msgTypes[8].OneofWrappers = []any{} + file_admin_proto_msgTypes[9].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_admin_proto_rawDesc), len(file_admin_proto_rawDesc)), NumEnums: 1, - NumMessages: 26, + NumMessages: 27, NumExtensions: 0, NumServices: 1, }, diff --git a/adminrpc/admin.proto b/adminrpc/admin.proto index 9808468a..fe943885 100644 --- a/adminrpc/admin.proto +++ b/adminrpc/admin.proto @@ -86,6 +86,34 @@ message Service { // auth_scheme specifies which payment auth scheme(s) are used for this // service. Defaults to AUTH_SCHEME_L402 for backwards compatibility. AuthScheme auth_scheme = 8; + + // payment optionally overrides the global authenticator lnd for this + // service (multi-merchant mode). When set, invoices for this service + // are issued against the merchant's own lnd so payments land in their + // wallet — the gateway never takes custody. Unset = legacy single-lnd + // mode using authenticator.lndhost. + PaymentBackend payment = 9; +} + +// PaymentBackend is an optional per-service lnd connection used in multi- +// merchant deployments. The merchant runs their own lnd and hands the +// gateway a minimum-privilege macaroon (invoices:read invoices:write +// info:read) so the gateway can create/verify invoices but cannot move +// funds or see wallet state. +// +// All three fields are required when the block is present. Paths are +// absolute paths on the gateway host's filesystem. +message PaymentBackend { + // lnd_host is the merchant lnd's gRPC address (host:port). + string lnd_host = 1; + + // tls_path is the path to the merchant lnd's tls.cert (public). + string tls_path = 2; + + // mac_path is the absolute path to the minimum-privilege macaroon + // file supplied by the merchant. Only invoices:{read,write} and + // info:read are expected to be granted. + string mac_path = 3; } message CreateServiceRequest { @@ -100,6 +128,12 @@ message CreateServiceRequest { // auth_scheme specifies which payment auth scheme(s) to use. Defaults to // AUTH_SCHEME_L402 if unset. AuthScheme auth_scheme = 8; + + // payment optionally configures a per-service lnd override. See the + // Service message for field semantics. Omit for legacy single-lnd + // mode. Setting this takes effect after the next prism restart — + // the in-memory challenger router is built at startup. + PaymentBackend payment = 9; } message UpdateServiceRequest { @@ -114,6 +148,17 @@ message UpdateServiceRequest { // auth_scheme specifies which payment auth scheme(s) to use. When not // set, the existing auth_scheme is preserved (not reset to L402). optional AuthScheme auth_scheme = 8; + + // payment updates the per-service lnd override. When omitted (nil), + // the existing payment config is preserved (not reset). To explicitly + // remove a payment override from a service, set clear_payment=true + // instead. As with CreateService, changes take effect on restart. + PaymentBackend payment = 9; + + // clear_payment, when true, removes any existing per-service lnd + // override from the service, returning it to the global single-lnd + // mode. Mutually exclusive with setting payment. + bool clear_payment = 10; } message DeleteServiceRequest { string name = 1; } diff --git a/adminrpc/admin.swagger.json b/adminrpc/admin.swagger.json index b2cd47bd..514b5181 100644 --- a/adminrpc/admin.swagger.json +++ b/adminrpc/admin.swagger.json @@ -442,6 +442,14 @@ "auth_scheme": { "$ref": "#/definitions/adminrpcAuthScheme", "description": "auth_scheme specifies which payment auth scheme(s) to use. When not\nset, the existing auth_scheme is preserved (not reset to L402)." + }, + "payment": { + "$ref": "#/definitions/adminrpcPaymentBackend", + "description": "payment updates the per-service lnd override. When omitted (nil),\nthe existing payment config is preserved (not reset). To explicitly\nremove a payment override from a service, set clear_payment=true\ninstead. As with CreateService, changes take effect on restart." + }, + "clear_payment": { + "type": "boolean", + "description": "clear_payment, when true, removes any existing per-service lnd\noverride from the service, returning it to the global single-lnd\nmode. Mutually exclusive with setting payment." } } }, @@ -483,6 +491,10 @@ "auth_scheme": { "$ref": "#/definitions/adminrpcAuthScheme", "description": "auth_scheme specifies which payment auth scheme(s) to use. Defaults to\nAUTH_SCHEME_L402 if unset." + }, + "payment": { + "$ref": "#/definitions/adminrpcPaymentBackend", + "description": "payment optionally configures a per-service lnd override. See the\nService message for field semantics. Omit for legacy single-lnd\nmode. Setting this takes effect after the next prism restart —\nthe in-memory challenger router is built at startup." } } }, @@ -684,6 +696,24 @@ }, "description": "MPPSession mirrors the on-server session record for the admin API.\nPayment hash is hex-encoded. Amounts are in the chain's base unit." }, + "adminrpcPaymentBackend": { + "type": "object", + "properties": { + "lnd_host": { + "type": "string", + "description": "lnd_host is the merchant lnd's gRPC address (host:port)." + }, + "tls_path": { + "type": "string", + "description": "tls_path is the path to the merchant lnd's tls.cert (public)." + }, + "mac_path": { + "type": "string", + "description": "mac_path is the absolute path to the minimum-privilege macaroon\nfile supplied by the merchant. Only invoices:{read,write} and\ninfo:read are expected to be granted." + } + }, + "description": "PaymentBackend is an optional per-service lnd connection used in multi-\nmerchant deployments. The merchant runs their own lnd and hands the\ngateway a minimum-privilege macaroon (invoices:read invoices:write\ninfo:read) so the gateway can create/verify invoices but cannot move\nfunds or see wallet state.\n\nAll three fields are required when the block is present. Paths are\nabsolute paths on the gateway host's filesystem." + }, "adminrpcRevokeTokenResponse": { "type": "object", "properties": { @@ -720,6 +750,10 @@ "auth_scheme": { "$ref": "#/definitions/adminrpcAuthScheme", "description": "auth_scheme specifies which payment auth scheme(s) are used for this\nservice. Defaults to AUTH_SCHEME_L402 for backwards compatibility." + }, + "payment": { + "$ref": "#/definitions/adminrpcPaymentBackend", + "description": "payment optionally overrides the global authenticator lnd for this\nservice (multi-merchant mode). When set, invoices for this service\nare issued against the merchant's own lnd so payments land in their\nwallet — the gateway never takes custody. Unset = legacy single-lnd\nmode using authenticator.lndhost." } } }, diff --git a/aperture.go b/aperture.go index b6fd0252..3dd76602 100644 --- a/aperture.go +++ b/aperture.go @@ -431,8 +431,17 @@ func (a *Aperture) Start(errChan chan error, shutdown <-chan struct{}) error { // the merchant's own wallet, the gateway never takes // custody. Services without a payment block fall // through to defaultChal (legacy single-lnd mode). + // + // We merge DB-persisted services into the YAML list + // first, so services created at runtime via the + // admin API (and their payment overrides) are + // considered on restart — otherwise the router would + // only know about YAML-defined services. + mergedForChallengers := mergeServicesFromDB( + a.cfg.Services, svcStore, + ) perServiceChallengers, err := buildPerServiceChallengers( - a.cfg.Services, authCfg.Network, + mergedForChallengers, authCfg.Network, a.cfg.InvoiceBatchSize, genInvoiceReq, errChan, a.cfg.StrictVerify, challengerOpts, @@ -1728,7 +1737,7 @@ func mergeServicesFromDB(configServices []*proxy.Service, // Build a map of DB services keyed by name. dbByName := make(map[string]*proxy.Service, len(dbRows)) for _, row := range dbRows { - dbByName[row.Name] = &proxy.Service{ + svc := &proxy.Service{ Name: row.Name, Address: row.Address, Protocol: row.Protocol, @@ -1738,6 +1747,21 @@ func mergeServicesFromDB(configServices []*proxy.Service, Auth: auth.Level(row.Auth), AuthScheme: row.AuthScheme, } + // Rehydrate per-service lnd override if set. We only attach + // the payment block when all three columns have non-empty + // values — partial state is nonsensical (and the admin API + // rejects it on write), but guard here too in case the DB + // was hand-edited. + if row.PaymentLndhost != "" && row.PaymentTlspath != "" && + row.PaymentMacpath != "" { + + svc.Payment = &proxy.PaymentBackend{ + LndHost: row.PaymentLndhost, + TLSPath: row.PaymentTlspath, + MacPath: row.PaymentMacpath, + } + } + dbByName[row.Name] = svc } // Start with DB services, then add config services that are not diff --git a/aperturedb/services.go b/aperturedb/services.go index 6ac00b1a..7d3c1865 100644 --- a/aperturedb/services.go +++ b/aperturedb/services.go @@ -74,6 +74,16 @@ type ServiceParams struct { Auth string AuthScheme string Price int64 + + // PaymentLndHost, PaymentTLSPath, PaymentMacPath together configure + // an optional per-service lnd override. When any of them is set, + // all three must be set — invoices for this service are routed + // through that lnd so payments land in the merchant's wallet. + // Empty on all three means the service uses the global + // authenticator.lndhost (legacy single-lnd mode). + PaymentLndHost string + PaymentTLSPath string + PaymentMacPath string } // UpsertService inserts or updates a service configuration. @@ -84,16 +94,19 @@ func (s *ServicesStore) UpsertService(ctx context.Context, now := s.clock.Now().UTC() err := s.db.ExecTx(ctx, &writeTxOpts, func(tx ServicesDB) error { return tx.UpsertService(ctx, UpsertServiceParams{ - Name: params.Name, - Address: params.Address, - Protocol: params.Protocol, - HostRegexp: params.HostRegexp, - PathRegexp: params.PathRegexp, - Price: params.Price, - Auth: params.Auth, - AuthScheme: params.AuthScheme, - CreatedAt: now, - UpdatedAt: now, + Name: params.Name, + Address: params.Address, + Protocol: params.Protocol, + HostRegexp: params.HostRegexp, + PathRegexp: params.PathRegexp, + Price: params.Price, + Auth: params.Auth, + AuthScheme: params.AuthScheme, + PaymentLndhost: params.PaymentLndHost, + PaymentTlspath: params.PaymentTLSPath, + PaymentMacpath: params.PaymentMacPath, + CreatedAt: now, + UpdatedAt: now, }) }) diff --git a/aperturedb/sqlc/migrations/000008_services_payment.down.sql b/aperturedb/sqlc/migrations/000008_services_payment.down.sql new file mode 100644 index 00000000..47ba81fa --- /dev/null +++ b/aperturedb/sqlc/migrations/000008_services_payment.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE services DROP COLUMN payment_lndhost; +ALTER TABLE services DROP COLUMN payment_tlspath; +ALTER TABLE services DROP COLUMN payment_macpath; diff --git a/aperturedb/sqlc/migrations/000008_services_payment.up.sql b/aperturedb/sqlc/migrations/000008_services_payment.up.sql new file mode 100644 index 00000000..09dcd09e --- /dev/null +++ b/aperturedb/sqlc/migrations/000008_services_payment.up.sql @@ -0,0 +1,11 @@ +-- Optional per-service lnd override for multi-merchant deployments. +-- When any of these columns are non-empty, invoices for this service are +-- routed through the merchant's own lnd instead of the global gateway +-- lnd, so payments land directly in the merchant's wallet. +-- +-- All three columns are expected to be set together (lndhost + tlspath + +-- macpath); enforced at the admin-server layer rather than here so we +-- can give a friendlier error than a constraint violation. +ALTER TABLE services ADD COLUMN payment_lndhost TEXT NOT NULL DEFAULT ''; +ALTER TABLE services ADD COLUMN payment_tlspath TEXT NOT NULL DEFAULT ''; +ALTER TABLE services ADD COLUMN payment_macpath TEXT NOT NULL DEFAULT ''; diff --git a/aperturedb/sqlc/models.go b/aperturedb/sqlc/models.go index 799d064d..c99cbd0a 100644 --- a/aperturedb/sqlc/models.go +++ b/aperturedb/sqlc/models.go @@ -59,15 +59,18 @@ type Secret struct { } type Service struct { - ID int32 - Name string - Address string - Protocol string - HostRegexp string - PathRegexp string - Price int64 - Auth string - CreatedAt time.Time - UpdatedAt time.Time - AuthScheme string + ID int32 + Name string + Address string + Protocol string + HostRegexp string + PathRegexp string + Price int64 + Auth string + CreatedAt time.Time + UpdatedAt time.Time + AuthScheme string + PaymentLndhost string + PaymentTlspath string + PaymentMacpath string } diff --git a/aperturedb/sqlc/queries/services.sql b/aperturedb/sqlc/queries/services.sql index 9c4bba8a..6cf00733 100644 --- a/aperturedb/sqlc/queries/services.sql +++ b/aperturedb/sqlc/queries/services.sql @@ -1,9 +1,10 @@ -- name: UpsertService :exec INSERT INTO services ( name, address, protocol, host_regexp, path_regexp, price, auth, - auth_scheme, created_at, updated_at + auth_scheme, payment_lndhost, payment_tlspath, payment_macpath, + created_at, updated_at ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10 + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13 ) ON CONFLICT(name) DO UPDATE SET address = excluded.address, @@ -13,6 +14,9 @@ ON CONFLICT(name) DO UPDATE SET price = excluded.price, auth = excluded.auth, auth_scheme = excluded.auth_scheme, + payment_lndhost = excluded.payment_lndhost, + payment_tlspath = excluded.payment_tlspath, + payment_macpath = excluded.payment_macpath, updated_at = excluded.updated_at; -- name: DeleteService :execrows diff --git a/aperturedb/sqlc/schemas/generated_schema.sql b/aperturedb/sqlc/schemas/generated_schema.sql index 9a1b6354..b8e61ed3 100644 --- a/aperturedb/sqlc/schemas/generated_schema.sql +++ b/aperturedb/sqlc/schemas/generated_schema.sql @@ -104,5 +104,5 @@ CREATE TABLE services ( auth TEXT NOT NULL DEFAULT '', created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL -, auth_scheme TEXT NOT NULL DEFAULT 'l402'); +, auth_scheme TEXT NOT NULL DEFAULT 'l402', payment_lndhost TEXT NOT NULL DEFAULT '', payment_tlspath TEXT NOT NULL DEFAULT '', payment_macpath TEXT NOT NULL DEFAULT ''); diff --git a/aperturedb/sqlc/services.sql.go b/aperturedb/sqlc/services.sql.go index 80921cdf..e7293018 100644 --- a/aperturedb/sqlc/services.sql.go +++ b/aperturedb/sqlc/services.sql.go @@ -24,7 +24,7 @@ func (q *Queries) DeleteService(ctx context.Context, name string) (int64, error) } const listServices = `-- name: ListServices :many -SELECT id, name, address, protocol, host_regexp, path_regexp, price, auth, created_at, updated_at, auth_scheme +SELECT id, name, address, protocol, host_regexp, path_regexp, price, auth, created_at, updated_at, auth_scheme, payment_lndhost, payment_tlspath, payment_macpath FROM services ORDER BY name ` @@ -50,6 +50,9 @@ func (q *Queries) ListServices(ctx context.Context) ([]Service, error) { &i.CreatedAt, &i.UpdatedAt, &i.AuthScheme, + &i.PaymentLndhost, + &i.PaymentTlspath, + &i.PaymentMacpath, ); err != nil { return nil, err } @@ -67,9 +70,10 @@ func (q *Queries) ListServices(ctx context.Context) ([]Service, error) { const upsertService = `-- name: UpsertService :exec INSERT INTO services ( name, address, protocol, host_regexp, path_regexp, price, auth, - auth_scheme, created_at, updated_at + auth_scheme, payment_lndhost, payment_tlspath, payment_macpath, + created_at, updated_at ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10 + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13 ) ON CONFLICT(name) DO UPDATE SET address = excluded.address, @@ -79,20 +83,26 @@ ON CONFLICT(name) DO UPDATE SET price = excluded.price, auth = excluded.auth, auth_scheme = excluded.auth_scheme, + payment_lndhost = excluded.payment_lndhost, + payment_tlspath = excluded.payment_tlspath, + payment_macpath = excluded.payment_macpath, updated_at = excluded.updated_at ` type UpsertServiceParams struct { - Name string - Address string - Protocol string - HostRegexp string - PathRegexp string - Price int64 - Auth string - AuthScheme string - CreatedAt time.Time - UpdatedAt time.Time + Name string + Address string + Protocol string + HostRegexp string + PathRegexp string + Price int64 + Auth string + AuthScheme string + PaymentLndhost string + PaymentTlspath string + PaymentMacpath string + CreatedAt time.Time + UpdatedAt time.Time } func (q *Queries) UpsertService(ctx context.Context, arg UpsertServiceParams) error { @@ -105,6 +115,9 @@ func (q *Queries) UpsertService(ctx context.Context, arg UpsertServiceParams) er arg.Price, arg.Auth, arg.AuthScheme, + arg.PaymentLndhost, + arg.PaymentTlspath, + arg.PaymentMacpath, arg.CreatedAt, arg.UpdatedAt, ) From e4310aaa9d3d881b47ff0da50a295528af725339 Mon Sep 17 00:00:00 2001 From: ai-chen2050 <1033467071@qq.com> Date: Fri, 24 Apr 2026 18:00:43 +0800 Subject: [PATCH 19/32] feat(cli,dashboard): expose per-service payment backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces the payment backend (multi-merchant per-service lnd override) through the operator-facing tools so merchants can be onboarded without editing prism's YAML + restart. Complements the admin-API plumbing in bb777f3. prismcli: - services create / update: --payment-lndhost / --payment-tlspath / --payment-macpath (all-or-nothing, validated before the RPC). - services update: --clear-payment removes an existing override; mutually exclusive with --payment-*. - services list: new PAYMENT_LND column highlights which services route to a dedicated merchant lnd. Dashboard: - /services create form gains a "Payment backend (optional)" group under Advanced options, with client-side all-or-nothing validation. The services table shows an inline `↪ lnd ` hint under the address when an override is active. - /services/detail gets a full-width Payment Backend card: shows lnd_host / tls_path / mac_path when set (plus a Clear-override button that issues clear_payment=true), and a helper note when not set. - PaymentBackend type + optional payment fields on Service and ServiceCreateRequest; updateService accepts payment / clear_payment. Changes take effect on the next prism restart (challenger router is built at startup). Co-Authored-By: Claude Opus 4.7 (1M context) --- cli/services.go | 143 +++++++++++++++++--- dashboard/app/services/detail/page.tsx | 73 +++++++++++ dashboard/app/services/page.tsx | 174 ++++++++++++++++++++----- dashboard/lib/api.ts | 4 + dashboard/lib/types.ts | 17 +++ 5 files changed, 364 insertions(+), 47 deletions(-) diff --git a/cli/services.go b/cli/services.go index 4984f5fa..2dfdc668 100644 --- a/cli/services.go +++ b/cli/services.go @@ -7,6 +7,7 @@ import ( "github.com/lightninglabs/aperture/adminrpc" "github.com/spf13/cobra" + "github.com/spf13/pflag" "google.golang.org/protobuf/proto" ) @@ -59,14 +60,19 @@ func newServicesListCmd() *cobra.Command { os.Stdout, 0, 0, 2, ' ', 0, ) fmt.Fprintln( - w, "NAME\tADDRESS\tPROTOCOL\tPRICE\tAUTH", + w, "NAME\tADDRESS\tPROTOCOL\tPRICE\t"+ + "AUTH\tPAYMENT_LND", ) for _, s := range resp.Services { + lnd := "-" + if s.Payment != nil && s.Payment.LndHost != "" { + lnd = s.Payment.LndHost + } fmt.Fprintf( - w, "%s\t%s\t%s\t%d\t%s\n", + w, "%s\t%s\t%s\t%d\t%s\t%s\n", s.Name, s.Address, - s.Protocol, s.Price, s.Auth, + s.Protocol, s.Price, s.Auth, lnd, ) } @@ -77,13 +83,16 @@ func newServicesListCmd() *cobra.Command { func newServicesCreateCmd() *cobra.Command { var ( - name string - address string - protocol string - hostRegexp string - pathRegexp string - price int64 - auth string + name string + address string + protocol string + hostRegexp string + pathRegexp string + price int64 + auth string + paymentLndhost string + paymentTLSPath string + paymentMacPath string ) cmd := &cobra.Command{ @@ -99,6 +108,13 @@ func newServicesCreateCmd() *cobra.Command { ) } + payment, err := buildPaymentBackend( + paymentLndhost, paymentTLSPath, paymentMacPath, + ) + if err != nil { + return err + } + req := &adminrpc.CreateServiceRequest{ Name: name, Address: address, @@ -107,6 +123,7 @@ func newServicesCreateCmd() *cobra.Command { PathRegexp: pathRegexp, Price: price, Auth: auth, + Payment: payment, } if flags.dryRun { @@ -168,19 +185,26 @@ func newServicesCreateCmd() *cobra.Command { &auth, "auth", "on", "Auth level: on, off, or freebie N", ) + addPaymentFlags( + f, &paymentLndhost, &paymentTLSPath, &paymentMacPath, + ) return cmd } func newServicesUpdateCmd() *cobra.Command { var ( - name string - address string - protocol string - hostRegexp string - pathRegexp string - price int64 - auth string + name string + address string + protocol string + hostRegexp string + pathRegexp string + price int64 + auth string + paymentLndhost string + paymentTLSPath string + paymentMacPath string + clearPayment bool ) cmd := &cobra.Command{ @@ -225,6 +249,29 @@ Examples: req.Auth = auth } + paymentSet := cmd.Flags().Changed("payment-lndhost") || + cmd.Flags().Changed("payment-tlspath") || + cmd.Flags().Changed("payment-macpath") + if paymentSet && clearPayment { + return ErrInvalidArgsf( + "--payment-* and --clear-payment " + + "are mutually exclusive", + ) + } + if paymentSet { + payment, err := buildPaymentBackend( + paymentLndhost, paymentTLSPath, + paymentMacPath, + ) + if err != nil { + return err + } + req.Payment = payment + } + if clearPayment { + req.ClearPayment = true + } + if flags.dryRun { if err := printDryRun( "UpdateService", req, @@ -282,6 +329,15 @@ Examples: &auth, "auth", "", "Auth level: on, off, or freebie N", ) + addPaymentFlags( + f, &paymentLndhost, &paymentTLSPath, &paymentMacPath, + ) + f.BoolVar( + &clearPayment, "clear-payment", false, + "Remove any existing per-service lnd override (returns "+ + "the service to the global default lnd). Mutually "+ + "exclusive with --payment-*.", + ) return cmd } @@ -345,3 +401,56 @@ func newServicesDeleteCmd() *cobra.Command { return cmd } + +// addPaymentFlags registers the --payment-lndhost, --payment-tlspath and +// --payment-macpath flags on f, binding them to the passed-in string +// pointers. Kept in one place so the create and update commands stay in +// sync with each other and with the server-side validation rules. +func addPaymentFlags(f *pflag.FlagSet, lndHost, tlsPath, macPath *string) { + f.StringVar( + lndHost, "payment-lndhost", "", + "Merchant lnd gRPC host:port (enables per-service lnd "+ + "routing; requires --payment-tlspath and "+ + "--payment-macpath)", + ) + f.StringVar( + tlsPath, "payment-tlspath", "", + "Absolute path to the merchant lnd's tls.cert on the "+ + "gateway host", + ) + f.StringVar( + macPath, "payment-macpath", "", + "Absolute path to the merchant's minimum-privilege "+ + "macaroon file (invoices:read invoices:write "+ + "info:read)", + ) +} + +// buildPaymentBackend validates that the three --payment-* values are +// either all empty (no per-service override, use the global lnd) or all +// set (per-service override). Returns a PaymentBackend pointer on the +// all-set path and nil on the all-empty path. Mirrors the server-side +// check so the user gets a clear error before the gRPC round-trip. +func buildPaymentBackend(lndHost, tlsPath, macPath string) ( + *adminrpc.PaymentBackend, error) { + + allEmpty := lndHost == "" && tlsPath == "" && macPath == "" + allSet := lndHost != "" && tlsPath != "" && macPath != "" + + switch { + case allEmpty: + return nil, nil + case allSet: + return &adminrpc.PaymentBackend{ + LndHost: lndHost, + TlsPath: tlsPath, + MacPath: macPath, + }, nil + default: + return nil, ErrInvalidArgsf( + "--payment-lndhost, --payment-tlspath and " + + "--payment-macpath must all be set together " + + "or all be omitted", + ) + } +} diff --git a/dashboard/app/services/detail/page.tsx b/dashboard/app/services/detail/page.tsx index 3f09a37a..0506b839 100644 --- a/dashboard/app/services/detail/page.tsx +++ b/dashboard/app/services/detail/page.tsx @@ -468,6 +468,28 @@ function ServiceDetailContent() { } }, [decodedName]); + const handleClearPayment = useCallback(async () => { + if ( + !confirm( + "Remove the per-service lnd override? This service will fall " + + "back to the gateway's global lnd on the next prism restart." + ) + ) { + return; + } + setSaving(true); + try { + await updateService(decodedName, { clear_payment: true }); + toast("Payment override cleared. Restart prism to apply."); + } catch (e: unknown) { + toast( + e instanceof Error ? e.message : "Failed to clear payment", + "error" + ); + } + setSaving(false); + }, [decodedName]); + const startPriceEdit = useCallback(() => { if (!svc) return; setEditingPrice(true); @@ -788,6 +810,57 @@ function ServiceDetailContent() { + +
+ Payment Backend + {svc.payment?.lnd_host && ( + + )} +
+ {svc.payment?.lnd_host ? ( +
+ + Merchant lnd + {svc.payment.lnd_host} + + + tls.cert + {svc.payment.tls_path} + + + macaroon + {svc.payment.mac_path} + + + Invoices for this service are issued against the merchant's + own lnd, so payments land in their wallet — the gateway never + takes custody. Changes take effect on the next prism restart. + +
+ ) : ( + + This service uses the gateway's global lnd. To route + payments to a merchant's own lnd, set the{" "} + payment block via the admin API or{" "} + prismcli services update --payment-*. + + )} +
+ diff --git a/dashboard/app/services/page.tsx b/dashboard/app/services/page.tsx index e7651af5..9a0bd575 100644 --- a/dashboard/app/services/page.tsx +++ b/dashboard/app/services/page.tsx @@ -40,6 +40,21 @@ const initialForm: ServiceCreateRequest = { auth_scheme: "AUTH_SCHEME_L402", }; +// Local UI state for the payment backend fields on the create form. +// Tracked separately from the request body so an empty form doesn't +// emit a half-filled `payment` object (which the server rejects). +interface PaymentForm { + lnd_host: string; + tls_path: string; + mac_path: string; +} + +const initialPaymentForm: PaymentForm = { + lnd_host: "", + tls_path: "", + mac_path: "", +}; + const Styled = { Card: styled.div` background-color: ${(p) => p.theme.colors.lightNavy}; @@ -206,6 +221,13 @@ const Styled = { width: 80px; text-align: right; `, + PaymentHint: styled.div` + margin-top: 4px; + font-size: 11px; + color: ${(p) => p.theme.colors.lightningYellow}; + font-family: monospace; + letter-spacing: 0.2px; + `, AuthBadge: styled.span<{ $level: "on" | "off" | "freebie" }>` display: inline-block; padding: 2px 10px; @@ -249,6 +271,9 @@ export default function ServicesPage() { const [showAdvanced, setShowAdvanced] = useState(false); const [saving, setSaving] = useState(false); const [form, setForm] = useState({ ...initialForm }); + const [paymentForm, setPaymentForm] = useState({ + ...initialPaymentForm, + }); const handlePriceSave = useCallback( async (name: string) => { @@ -303,11 +328,31 @@ export default function ServicesPage() { toast("Name and address are required", "error"); return; } + // All-or-nothing payment backend — mirrors the server check so the + // user gets immediate feedback instead of a round-trip 400. + const paymentSet = [ + paymentForm.lnd_host, + paymentForm.tls_path, + paymentForm.mac_path, + ].filter((v) => v.trim() !== ""); + if (paymentSet.length > 0 && paymentSet.length < 3) { + toast( + "Payment backend: lnd host, tls path and macaroon path must " + + "all be set together or all be empty", + "error" + ); + return; + } + const body: ServiceCreateRequest = { ...form }; + if (paymentSet.length === 3) { + body.payment = { ...paymentForm }; + } setSaving(true); try { - await createService(form); + await createService(body); toast(`Service "${form.name}" created`); setForm({ ...initialForm }); + setPaymentForm({ ...initialPaymentForm }); setShowAdd(false); setShowAdvanced(false); } catch (e: unknown) { @@ -318,7 +363,7 @@ export default function ServicesPage() { } setSaving(false); }, - [form] + [form, paymentForm] ); const toggleAdd = useCallback(() => { @@ -332,6 +377,7 @@ export default function ServicesPage() { setShowAdd(false); setShowAdvanced(false); setForm({ ...initialForm }); + setPaymentForm({ ...initialPaymentForm }); }, []); const toggleAdvanced = useCallback(() => setShowAdvanced((s) => !s), []); @@ -364,6 +410,7 @@ export default function ServicesPage() { EditablePrice, PriceInput, AuthBadge, + PaymentHint, Skeleton, } = Styled; @@ -503,34 +550,89 @@ export default function ServicesPage() { {showAdvanced ? "\u25BE" : "\u25B8"} Advanced options {showAdvanced && ( - -
- - - setForm({ ...form, hostregexp: e.target.value }) - } - placeholder=".*" - /> -
-
- - - setForm({ ...form, pathregexp: e.target.value }) - } - placeholder="^/api/.*$" - /> + <> + +
+ + + setForm({ ...form, hostregexp: e.target.value }) + } + placeholder=".*" + /> +
+
+ + + setForm({ ...form, pathregexp: e.target.value }) + } + placeholder="^/api/.*$" + /> +
+
+
+ Payment backend (optional) +
- + +
+ + + setPaymentForm({ + ...paymentForm, + lnd_host: e.target.value, + }) + } + placeholder="merchant.example:10009" + /> +
+
+ + + setPaymentForm({ + ...paymentForm, + tls_path: e.target.value, + }) + } + placeholder="/etc/prism/merchants/foo/tls.cert" + /> +
+
+ + + setPaymentForm({ + ...paymentForm, + mac_path: e.target.value, + }) + } + placeholder="/etc/prism/merchants/foo/invoice.macaroon" + /> +
+
+ )}
From 7cddbbfceccc94f4e2e6048c41d221ab9f85a89a Mon Sep 17 00:00:00 2001 From: ai-chen2050 <1033467071@qq.com> Date: Wed, 13 May 2026 16:51:40 +0800 Subject: [PATCH 32/32] feat(admin): expose ListServices as a public read endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `GET /api/admin/services` was admin-gated, which forced any end-user client that wanted to render an L402 service picker to ship an admin macaroon — exactly the wrong key to hand out widely. Add `/adminrpc.Admin/ListServices` to `unauthenticatedMethods` so it joins `GetHealth` as a no-auth read. Mutation endpoints (CreateService, UpdateService, DeleteService, RevokeToken) stay admin-gated; the interceptor's structure made the change a one-line whitelist with clear comment + a unit-test loop that asserts both methods bypass auth. Co-Authored-By: Claude Opus 4.7 (1M context) --- admin/auth.go | 15 ++++++++++++--- admin/auth_test.go | 26 +++++++++++++++++--------- 2 files changed, 29 insertions(+), 12 deletions(-) diff --git a/admin/auth.go b/admin/auth.go index 482f68b8..7e30d559 100644 --- a/admin/auth.go +++ b/admin/auth.go @@ -79,10 +79,19 @@ func verifyMacaroon(ctx context.Context, rootKey []byte) error { } // unauthenticatedMethods lists gRPC full-method paths that should bypass -// macaroon authentication, allowing health probes from load balancers and -// monitoring systems without credentials. +// macaroon authentication. Two classes of method live here: +// +// - Health probes from load balancers / monitoring (GetHealth). +// - Read-only catalog endpoints that are safe to expose so external +// clients can discover the L402 services this gateway hosts without +// a credential. ListServices in particular is used by paycli (and +// other end-user clients) to render a service picker. +// +// Mutation endpoints (CreateService, UpdateService, RevokeToken, …) MUST +// stay off this list — they remain admin-gated. var unauthenticatedMethods = map[string]struct{}{ - "/adminrpc.Admin/GetHealth": {}, + "/adminrpc.Admin/GetHealth": {}, + "/adminrpc.Admin/ListServices": {}, } // MacaroonInterceptor returns a gRPC unary server interceptor that validates diff --git a/admin/auth_test.go b/admin/auth_test.go index a9d8fb59..005f98ee 100644 --- a/admin/auth_test.go +++ b/admin/auth_test.go @@ -28,8 +28,11 @@ func TestMacaroonInterceptor(t *testing.T) { macHex := hex.EncodeToString(macBytes) interceptor := MacaroonInterceptor(rootKey) + // CreateService is admin-gated (ListServices is intentionally NOT — see + // unauthenticatedMethods in auth.go), so it's the right method to drive + // the rejection paths below. authedInfo := &grpc.UnaryServerInfo{ - FullMethod: "/adminrpc.Admin/ListServices", + FullMethod: "/adminrpc.Admin/CreateService", } handler := func(ctx context.Context, req interface{}) ( @@ -78,15 +81,20 @@ func TestMacaroonInterceptor(t *testing.T) { s, _ = status.FromError(err) require.Equal(t, codes.Unauthenticated, s.Code()) - // Test that health endpoint bypasses authentication. - healthInfo := &grpc.UnaryServerInfo{ - FullMethod: "/adminrpc.Admin/GetHealth", + // Test that whitelisted methods bypass authentication, even with no + // metadata at all. Two cases today: GetHealth (LB probes) and + // ListServices (public catalog for end-user clients). + for _, method := range []string{ + "/adminrpc.Admin/GetHealth", + "/adminrpc.Admin/ListServices", + } { + info := &grpc.UnaryServerInfo{FullMethod: method} + resp, err = interceptor( + context.Background(), nil, info, handler, + ) + require.NoError(t, err, "method %s should bypass auth", method) + require.Equal(t, "ok", resp, "method %s", method) } - resp, err = interceptor( - context.Background(), nil, healthInfo, handler, - ) - require.NoError(t, err) - require.Equal(t, "ok", resp) } func TestGenerateAndWriteReadMacaroon(t *testing.T) {