# 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/` 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 ``. 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/` 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://` | Public base URL for the hosted `/pay/` 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/` 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.`); 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=`. 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 `/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 `) 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=` and `X-GoblinPay-Delivery: `. 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)