3d36117d7b
ci / fmt / clippy / test (push) Waiting to run
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.
256 lines
14 KiB
Markdown
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)
|