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:

{
  "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)

S
Description
No description provided
Readme 511 KiB
Languages
Rust 91.4%
PHP 6.1%
HTML 1.4%
CSS 0.8%
JavaScript 0.3%