Files
GoblinPay/README.md
T
2ro 3d36117d7b
ci / fmt / clippy / test (push) Waiting to run
docs: bundled relay, GP_NYM production posture, connectors + deploy in README
Document bundled mode and GP_BUNDLED_RELAY_URL, state GP_NYM=off as a supported server-side-clearnet posture (the payer's wallet still provides privacy) rather than debugging-only, and add Connectors (WooCommerce/Medusa/REST) and Deploy sections. The README ends with the AI pair-programming credit line.
2026-07-03 03:22:53 -04:00

256 lines
14 KiB
Markdown

# GoblinPay
A self-hostable, receive-only Grin payment server. A merchant runs it, a
customer pays from Goblin Wallet by scanning a QR code, and the payment
travels as a gift-wrapped slatepack over Nostr (optionally over the Nym
mixnet). GoblinPay auto-receives, returns the S2 reply so the payer can
finalize, confirms the transaction on chain, and signals paid.
Beyond the core wallet + transport + on-chain confirmation path, GoblinPay
carries the full merchant surface:
- **Invoices + matching:** create an invoice against an order, matched by
any of three modes (per-invoice override or the `GP_MATCH_MODE` default):
the payer's memo, a per-invoice derived Nostr identity (a stateless child of
the server nsec, recommended for stores), or an exact amount. The matcher
runs inside the ingest pipeline, so a gift-wrapped payment resolves to its
invoice automatically.
- **Hosted checkout:** a zero-JS `/pay/<token>` page (server-rendered
Askama + one CSS file + a server-generated QR SVG at ECC level H with an
optional GoblinPay-mark center logo) with live status via
`<meta http-equiv=refresh>`. It offers two first-class ways to pay:
- **Goblin Wallet (Nostr):** scan the `nprofile` QR (or copy it) and the
payment auto-receives over Nostr.
- **Slatepack (`grin1`):** pay from any Grin wallet, no Nostr needed. The
page shows the wallet's stable index-0 Slatepack address (`grin1...`) plus
its QR and the exact amount to send. The payer sends that amount to the
address using their wallet's Slatepack/file method, pastes the resulting S1
into the page (offline `receive_tx`), then copies the returned S2 back into
their wallet to finalize and broadcast it. There is no Tor listener; the
`grin1` address is stable and reused across invoices, and the existing
invoice matcher and on-chain confirmation handle the received payment like
any other. The Slatepack option only appears when a wallet is loaded.
The same renderer serves embedded and hosted use.
- **Per-user endpubs:** an admin assigns one receiving identity per user
(a derived child keyed by `(user_id, epoch)`; only public keys and the
rotation clock are stored, never private keys), with optional rolling rotation
and an overlap window so a just-rotated endpub still lands. All funds still
land in the one Grin wallet.
- **Notifications (all optional):** an HMAC-signed, idempotent, retried
HTTP webhook (the WooCommerce contract), an authenticated admin dashboard +
JSON API, and NIP-17 DMs to the merchant / payer.
By default all relay traffic rides an in-process Nym mixnet tunnel (smolmix,
auto-selected exit, mix-dns). `GP_NYM=off` is also a supported production
posture, not just a debugging switch: the server then reaches relays over
clearnet, but the payer's Goblin Wallet still provides sender privacy over its
own mixnet and the payload stays gift-wrapped end to end. An operator who fronts
GoblinPay with their own network privacy, or who accepts server-side clearnet for
a receive-only till, can run it that way. Encryption negotiates NIP-44 v3 (the
NIP-17 extension, via the companion `nip44` crate) per recipient, with v2 as the
mandatory baseline.
## Workspace
| Crate | Purpose |
|---|---|
| `crates/gp-wallet` | Grin wallet handoff: open from mnemonic, S1 -> `receive_tx` -> S2 (offline) |
| `crates/gp-goblin-sender` | Test-only gate helper: sends and finalizes with Goblin's wallet stack |
| `crates/gp-nostr` | Nostr transport: identity, gift wrap (NIP-44 v2/v3), ingest, Nym mixnet |
| `crates/gp-core` | Domain core: config, SQLite persistence (sqlx, raw SQL) |
| `crates/gp-server` | Actix-Web binary: routes, Askama templates, rustls TLS |
Supporting directories: `migrations/` (raw sqlx SQL), `templates/` (Askama,
zero JS), `static/` (one hand-written CSS file, no build step).
## Configuration
Everything is environment variables, defaults are safe for local use.
| Variable | Default | Meaning |
|---|---|---|
| `GP_BIND` | `127.0.0.1:8080` | Listen address |
| `GP_TLS` | `off` | `off` (plain HTTP) or `rustls` (in-process TLS) |
| `GP_TLS_CERT` | unset | PEM certificate chain path, required for `rustls` |
| `GP_TLS_KEY` | unset | PEM private key path, required for `rustls` |
| `GP_DB_PATH` | `./goblinpay.db` | SQLite file, created on first start |
| `GP_DATA_DIR` | `./gp-data` | Data directory (wallet files, encrypted seed) |
| `GP_NODE_URL` | `https://main.gri.mw` | External Grin node (read only) |
| `GP_CHAIN` | `mainnet` | Grin network: `mainnet` or `testnet` |
| `GP_RELAY_MODE` | `bundled` | `bundled` (GoblinPay runs its own co-located relay) or `external` |
| `GP_BUNDLED_RELAY_URL` | `ws://127.0.0.1:7777` | In `bundled` mode, the self-contained relay GoblinPay dials AND advertises in the checkout `nprofile`; set to the relay's public `wss://` URL in production |
| `GP_RELAYS` | unset | Extra relay URLs (comma separated): redundancy in `bundled` mode, the whole set in `external` mode |
| `GP_NYM` | `on` | Route this server's Nostr traffic over the Nym mixnet (`on`, or `off` for supported server-side clearnet) |
| `GP_INGEST` | `on` | Nostr ingest service (`off` = HTTP surface only, for debugging) |
| `GP_CHECKOUT_METHODS` | `nostr,slatepack` | Which payment methods the hosted `/pay/<token>` page shows: comma list of `nostr` (Goblin Wallet) and `slatepack` (`grin1`). Unset = both. Unknown tokens are ignored; an empty result falls back to both |
| `GP_MATCH_MODE` | `memo` | Default matching mode: `memo`, `derived`, `amount` |
| `GP_MNEMONIC` | unset | Grin seed mnemonic (money secret) |
| `GP_WALLET_PASSWORD` | unset | Password encrypting the wallet seed and the Nostr identity at rest |
| `GP_NSEC` | unset | Nostr identity key (payment identity secret) |
| `GP_NCRYPTSEC` | unset | NIP-49 encrypted identity key (unlocked with the wallet password) |
| `GP_PUBLIC_URL` | `http://<bind>` | Public base URL for the hosted `/pay/<token>` links |
| `GP_API_TOKEN` | unset | Bearer token for the connector/create-invoice API (unset = write API closed) |
| `GP_ADMIN_TOKEN` | unset | Bearer token for the admin dashboard + endpub/webhook API |
| `GP_WEBHOOK_URL` | unset | Webhook endpoint for payment events (requires `GP_WEBHOOK_SECRET`) |
| `GP_WEBHOOK_SECRET` | unset | HMAC-SHA256 secret for signing webhooks |
| `GP_QR_LOGO` | GoblinPay mark | Checkout QR center logo: unset = GoblinPay mark, `off`/`none` = plain, else a URL/path |
| `GP_MERCHANT_NPUB` | unset | Merchant npub for the NIP-17 confirmed-payment DM |
| `GP_NOTIFY_MERCHANT_DM` | `off` | Send a NIP-17 DM to the merchant on a received payment |
| `GP_NOTIFY_PAYER_RECEIPT` | `off` | Send a NIP-17 receipt DM to the payer |
| `GP_ENDPUB_ROTATE_INTERVAL` | `0` | Default per-user endpub rotation interval in seconds (0 = off) |
| `GP_ENDPUB_OVERLAP_EPOCHS` | `1` | Past epochs kept watched after a rotation |
| `GP_RATE_SOURCE` | `coingecko` | Conversion-rate oracle source for pricing fiat invoices |
| `GP_RATE_CURRENCIES` | `usd` | Comma-separated fiat currencies the oracle prices (ISO codes) |
| `GP_RATE_CACHE_TTL` | `60` | Seconds a fetched rate is reused before refetching (0 = always) |
| `GP_QUOTE_TTL` | `900` | Seconds a created fiat invoice locks its Grin quote (its expiry window) |
| `GP_RATE_STALE_MAX` | `0` | Bounded stale-rate fallback in seconds if a live fetch fails (0 = off) |
### Checkout methods
`GP_CHECKOUT_METHODS` only controls what the hosted `/pay/<token>` page
advertises to a payer; it does not turn any payment processing on or off. The
Slatepack (`grin1`) method also needs a loaded wallet to appear, so an enabled
method that cannot work is simply hidden. Keep this consistent with `GP_INGEST`:
`GP_INGEST` runs the Nostr ingest service that actually receives and matches
Goblin Wallet payments, so `GP_INGEST=off` with `GP_CHECKOUT_METHODS=nostr`
would advertise a Nostr method that nothing is listening for. If you disable
ingest, drop `nostr` from `GP_CHECKOUT_METHODS`; if you advertise `nostr`, keep
ingest on. The connector `POST /invoice` JSON response still returns the
`nprofile` regardless of this setting, which affects only the hosted page.
### Bundled relay
`GP_RELAY_MODE=bundled` (the default) means GoblinPay runs against its own
co-located Nostr relay, so a merchant needs no third-party relay. The relay is a
stock, unmodified `nostr-rs-relay` (a small, SQLite-backed Rust relay) vendored
as the `relay` service in `deploy/docker-compose.yml` with a config file at
`deploy/relay/nostr-rs-relay.toml` (config only, no fork). It was chosen over
writing a relay from scratch: it is battle-tested, lightweight enough for a
single-merchant till, and keeps the money path off any third-party
infrastructure.
`GP_BUNDLED_RELAY_URL` is the relay's URL. It is both dialed by the server and
advertised to payers in the checkout `nprofile`, so the payer's Goblin Wallet is
told to deliver the gift-wrapped slatepack straight to the merchant's own relay.
Set it to the relay's public `wss://` URL in production (the compose file and
`deploy/Caddyfile` serve it on `relay.<GP_DOMAIN>`); the default
`ws://127.0.0.1:7777` suits local and same-host development. Any `GP_RELAYS` are
appended for redundancy and advertised alongside the bundled relay.
`GP_RELAY_MODE=external` uses only the `GP_RELAYS` set and runs no bundled relay.
### Conversion rates (optional)
A store that prices in fiat (for example cryptodrip.com prices in USD) sends
`amount_fiat` + `currency` to `POST /invoice`. GoblinPay then quotes the Grin
amount through the configured oracle, locks it for `GP_QUOTE_TTL` seconds, and
fills the invoice `expected_amount` so the invoice matches by amount. A
Grin-denominated invoice (`amount_grin`) bypasses the oracle unchanged.
The oracle default is CoinGecko (GRIN is listed under id `grin`), queried at
`api.coingecko.com/api/v3/simple/price?ids=grin&vs_currencies=<currencies>`.
Rates are cached for `GP_RATE_CACHE_TTL` seconds so concurrent checkouts do not
hammer the source. If the source is unreachable or the currency is not enabled,
`create-invoice` fails fast with a clear error rather than creating an
unpriceable invoice; `GP_RATE_STALE_MAX` optionally permits serving the last
cached rate within a bounded window instead. The oracle fetch goes DIRECT over
normal HTTP, never through the Nym mixnet (the mixnet carries only the Nostr
gift-wrap layer, the same ruling as the read-only node client).
The secrets also accept mounted-file variants, `GP_MNEMONIC_FILE`,
`GP_WALLET_PASSWORD_FILE`, `GP_NSEC_FILE`, and `GP_NCRYPTSEC_FILE`
(mode 0400 recommended). Setting both the variable and its `_FILE` variant
is an error, as is setting both `GP_NSEC` and `GP_NCRYPTSEC`. When neither
identity variable is set, a fresh random identity is generated on first
start and persisted NIP-49 encrypted at `<GP_DATA_DIR>/nostr/identity.json`
(mode 0600). The mnemonic and the nsec are deliberately independent secrets:
the mnemonic recovers the funds, the nsec recovers the payment identity, and
the Grin seed is never used for anything Nostr.
## REST API
Public (no auth): `/health`, and the token-as-capability routes below. Bearer
auth (`Authorization: Bearer <token>`) where noted; the `_FILE` mounted-file
variant works for `GP_API_TOKEN`, `GP_ADMIN_TOKEN`, and `GP_WEBHOOK_SECRET` too.
| Method | Route | Auth | Purpose |
|---|---|---|---|
| GET | `/health` | none | Liveness + version |
| POST | `/invoice` | api | Create an invoice, returns checkout info (pay_url, nprofile, QR SVG) |
| GET | `/invoice/{id}` | api | Invoice checkout info + status |
| GET | `/pay/{token}` | token | Hosted zero-JS checkout page |
| GET | `/pay/{token}/status` | token | Invoice status JSON (for polling) |
| POST | `/pay/{token}/slatepack` | token | Manual fallback: paste S1, returns the S2 page |
| GET | `/payment/{id}` | token | Payment status JSON |
| GET | `/payment/{id}/receipt` | token | Server-signed verifiable receipt |
| GET | `/admin` | admin | Dashboard (payments, balances, config) |
| GET | `/admin/payments` | admin | Recent payments JSON |
| GET/POST | `/admin/users` | admin | List users / create a user + endpub |
| GET | `/admin/users/{id}` | admin | A user's current endpub + QR |
| POST | `/admin/users/{id}/rotate` | admin | Force-rotate a user's endpub |
| POST | `/admin/users/{id}/rotate-interval` | admin | Set the per-user rotation interval |
| GET | `/admin/webhooks` | admin | Webhook delivery log |
`POST /invoice` body: `{ order_ref?, amount_grin? | (amount_fiat + currency), memo?, match_mode?, expiry_secs? }`.
## Webhook contract
On a received payment, GoblinPay POSTs `application/json` to `GP_WEBHOOK_URL`:
```json
{
"event_id": "5f3c...", // 128-bit hex, the idempotency key
"event_type": "payment.received",
"created_at": "2026-07-01T12:00:00Z",
"payment": {
"slate_id": "...", "amount": 2000000000, "amount_grin": "2",
"status": "received", "payer": "...hex...", "confirmed_height": null
},
"invoice_id": "...", "order_ref": "order-42", "user_id": "..."
}
```
Headers: `X-GoblinPay-Signature: sha256=<hex(HMAC-SHA256(secret, raw_body))>`
and `X-GoblinPay-Delivery: <event_id>`. Verify by recomputing the HMAC over the
exact received bytes (constant-time) and dedupe on the delivery id. Deliveries
are persisted and retried with exponential backoff.
## Run
```
cargo run -p gp-server
curl http://127.0.0.1:8080/health
```
## Develop
```
./ci.sh # cargo fmt --check, clippy -D warnings, tests
```
## Connectors
Store integrations live under `connectors/` and all speak the same
create-invoice + signed-webhook contract:
- `connectors/woocommerce` — a WordPress/WooCommerce gateway (classic + Blocks).
- `connectors/medusa` — a Medusa v2 payment-module provider.
- The generic REST connector is built in: `POST /invoice` plus the webhook.
Refunds are unsupported/manual everywhere (GoblinPay is receive-only).
## Deploy
`deploy/` holds a reproducible deployment: a hardened systemd unit
(`gp-server.service`) with `deploy/install.sh` for bare metal, and a
`docker-compose.yml` that brings up the server, the bundled relay, and an
auto-HTTPS Caddy proxy. CI (`.github` / `.gitea` workflows) runs fmt, clippy,
and tests. See `deploy/` for details.
## Credits
GoblinPay is developed with the help of Claude (Anthropic).
Built with AI pair-programming assistance (Claude)