Compare commits

...

4 Commits

Author SHA1 Message Date
2ro 3d36117d7b docs: bundled relay, GP_NYM production posture, connectors + deploy in README
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.
2026-07-03 03:22:53 -04:00
2ro 3fdf4a230c M11: reproducible deploy pipeline
Multi-stage non-root Dockerfile (builds -p gp-server against the nip44/nym siblings; excludes the goblin-tree dev crate), a full docker-compose (server + bundled nostr-rs-relay + auto-HTTPS Caddy), a hardened systemd unit (DynamicUser, ProtectSystem=strict, NoNewPrivileges, seed via LoadCredential), an install.sh bare-metal bootstrap, .env.example, and an fmt+clippy+test CI workflow for Gitea and GitHub.
2026-07-03 03:22:43 -04:00
2ro bba1dd5cba M9: Medusa v2 payment provider connector
A minimal receive-only GoblinPay payment provider for Medusa v2 under connectors/medusa (service + module registration + types, README + INSTALL), modeled on connectors/woocommerce and the medusa-plugin-btcpay reference: create-invoice on initiate, an HMAC-verified webhook flips the payment to captured, status polling as the webhook-miss fallback. Refunds throw (receive-only, manual). Also refresh the store.rs docstring now that WooCommerce and Medusa have shipped.
2026-07-03 03:22:36 -04:00
2ro c32ddfa9ff M8: bundled relay — RelayMode::Bundled runs a co-located nostr-rs-relay
Make bundled mode actually self-contained: resolve() now leads the relay set with GP_BUNDLED_RELAY_URL (default ws://127.0.0.1:7777), which the checkout nprofile advertises, so a merchant needs no third-party relay. External mode uses only GP_RELAYS. Ship the relay as a vendored, unmodified nostr-rs-relay config (deploy/relay/nostr-rs-relay.toml); the compose service arrives with the deploy pipeline. Fix the stale "bundled is a later milestone" comment and reconcile the GP_NYM=off wording to a supported server-side-clearnet posture.
2026-07-03 03:22:29 -04:00
25 changed files with 1304 additions and 39 deletions
+58
View File
@@ -0,0 +1,58 @@
# CI gate for GoblinPay. Mirror of .github/workflows/ci.yml.
#
# What runs where:
# - fmt + the gp-core clippy/test gate run on ANY runner: gp-core is
# self-contained (no out-of-repo deps), and it holds the domain logic
# (config, invoices, matching, webhooks, rates, the connector seam).
# - The FULL gate (gp-wallet + gp-nostr + gp-server, via ./ci.sh) needs the
# sibling checkouts next to the repo: nip44/ and nym/ (the Nostr/Nym path)
# and goblin/ (the gp-goblin-sender round-trip gate). Where the workspace is
# laid out like the deploy host, it runs too; otherwise it is skipped with a
# note. `-p` scoping always keeps the goblin-tree dev crate off the money
# path build.
name: ci
on:
push:
branches: [main, master]
pull_request:
env:
CARGO_TERM_COLOR: always
jobs:
rust:
name: fmt / clippy / test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- name: Cache cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Format check (whole workspace)
run: cargo fmt --all -- --check
- name: Clippy (gp-core, deny warnings)
run: cargo clippy -p gp-core --all-targets -- -D warnings
- name: Test (gp-core)
run: cargo test -p gp-core --locked
- name: Full gate (when sibling checkouts are present)
run: |
if [ -d ../nip44 ] && [ -d ../nym/smolmix/core ] && [ -d ../goblin ]; then
echo "Workspace siblings present — running the full ./ci.sh gate."
./ci.sh
else
echo "nip44/nym/goblin siblings absent on this runner;"
echo "the full gp-server gate runs via ./ci.sh on the deploy host."
fi
+58
View File
@@ -0,0 +1,58 @@
# CI gate for GoblinPay. Mirror of .gitea/workflows/ci.yml.
#
# What runs where:
# - fmt + the gp-core clippy/test gate run on ANY runner: gp-core is
# self-contained (no out-of-repo deps), and it holds the domain logic
# (config, invoices, matching, webhooks, rates, the connector seam).
# - The FULL gate (gp-wallet + gp-nostr + gp-server, via ./ci.sh) needs the
# sibling checkouts next to the repo: nip44/ and nym/ (the Nostr/Nym path)
# and goblin/ (the gp-goblin-sender round-trip gate). Where the workspace is
# laid out like the deploy host, it runs too; otherwise it is skipped with a
# note. `-p` scoping always keeps the goblin-tree dev crate off the money
# path build.
name: ci
on:
push:
branches: [main, master]
pull_request:
env:
CARGO_TERM_COLOR: always
jobs:
rust:
name: fmt / clippy / test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- name: Cache cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Format check (whole workspace)
run: cargo fmt --all -- --check
- name: Clippy (gp-core, deny warnings)
run: cargo clippy -p gp-core --all-targets -- -D warnings
- name: Test (gp-core)
run: cargo test -p gp-core --locked
- name: Full gate (when sibling checkouts are present)
run: |
if [ -d ../nip44 ] && [ -d ../nym/smolmix/core ] && [ -d ../goblin ]; then
echo "Workspace siblings present — running the full ./ci.sh gate."
./ci.sh
else
echo "nip44/nym/goblin siblings absent on this runner;"
echo "the full gp-server gate runs via ./ci.sh on the deploy host."
fi
Generated
+1 -1
View File
@@ -5733,7 +5733,7 @@ dependencies = [
[[package]]
name = "nip44"
version = "0.1.0"
version = "0.3.0"
dependencies = [
"base64 0.22.1",
"chacha20 0.9.1",
+55 -7
View File
@@ -41,10 +41,15 @@ carries the full merchant surface:
HTTP webhook (the WooCommerce contract), an authenticated admin dashboard +
JSON API, and NIP-17 DMs to the merchant / payer.
All relay traffic rides an in-process Nym mixnet tunnel (smolmix, auto-selected
exit, mix-dns; `GP_NYM=off` is a debugging escape hatch only). Encryption
negotiates NIP-44 v3 (the NIP-17 extension, via the companion `nip44` crate) per
recipient, with v2 as the mandatory baseline.
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
@@ -73,9 +78,10 @@ Everything is environment variables, defaults are safe for local use.
| `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` or `external` |
| `GP_RELAYS` | unset | Comma-separated relay URLs |
| `GP_NYM` | `on` | Route Nostr traffic over the Nym mixnet (`on` or `off`) |
| `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` |
@@ -113,6 +119,27 @@ 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
@@ -202,6 +229,27 @@ curl http://127.0.0.1:8080/health
./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)
+94
View File
@@ -0,0 +1,94 @@
# Installing GoblinPay for Medusa
This is a Medusa v2 payment-module provider. There are two ways to add it.
## 1. Add the provider to your Medusa app
### Option A: copy the source (simplest)
Copy this `medusa` directory into your Medusa app as a module, for example
`src/modules/goblinpay`, keeping the `src/` files (`index.ts`, `service.ts`,
`types.ts`). Medusa compiles it with the rest of your app.
### Option B: install as a package
Publish or vendor `medusa-payment-goblinpay` and add it to your app's
dependencies. Build it first with `npm run build` (emits `dist/`).
## 2. Register it in `medusa-config.ts`
Add GoblinPay to the payment module's `providers`. Use `id: "goblinpay"` so the
webhook route is predictable (see step 4):
```ts
module.exports = defineConfig({
// ...
modules: [
{
resolve: "@medusajs/medusa/payment",
options: {
providers: [
{
// Option A: the path to the copied module.
resolve: "./src/modules/goblinpay",
// Option B: the package name, "medusa-payment-goblinpay".
id: "goblinpay",
options: {
baseUrl: process.env.GOBLINPAY_URL,
apiToken: process.env.GOBLINPAY_API_TOKEN,
webhookSecret: process.env.GOBLINPAY_WEBHOOK_SECRET,
matchMode: "derived",
},
},
],
},
},
],
})
```
## 3. Set the environment
In your Medusa app's `.env`:
```
GOBLINPAY_URL=https://pay.example
GOBLINPAY_API_TOKEN=<the same value as GP_API_TOKEN on the server>
GOBLINPAY_WEBHOOK_SECRET=<the same value as GP_WEBHOOK_SECRET on the server>
```
Then enable the `goblinpay` provider in the region(s) that should offer Grin,
via the Medusa admin (Settings, then Regions, then Payment Providers).
## 4. Register the webhook in GoblinPay
Point your GoblinPay server at the Medusa payment webhook route. The route id is
`<provider id>_<identifier>`, both `goblinpay`, so set these on the GoblinPay
side:
- `GP_WEBHOOK_URL` = `https://YOUR-MEDUSA-HOST/hooks/payment/goblinpay_goblinpay`
- `GP_WEBHOOK_SECRET` = the same secret you set as `webhookSecret`.
- `GP_API_TOKEN` = the same token you set as `apiToken`.
GoblinPay signs each delivery with `X-GoblinPay-Signature: sha256=<hmac>` over
the raw body and sends an idempotency key in `X-GoblinPay-Delivery`. The provider
verifies the signature (constant-time) and flips the payment to captured.
Make sure the Medusa host is reachable from the GoblinPay host. If a webhook is
ever missed, Medusa's `getPaymentStatus` polls
`GET {baseUrl}/invoice/{invoice_id}` (with the bearer token) as a fallback.
## 5. Test
Place a test order, choose Grin (GoblinPay), and confirm:
- The storefront shows the GoblinPay QR / redirects to the `/pay/<token>` page
(the checkout details are on the payment session's `data.goblinpay`).
- Paying from a Goblin Wallet moves the order's payment to captured once
GoblinPay delivers the `payment.received` webhook.
## Refund caveat
Refunds are not automated. GoblinPay is receive-only and never sends Grin, so
any refund is a manual Grin send performed by the merchant from a wallet under
their control. `refundPayment` throws to make this explicit.
+90
View File
@@ -0,0 +1,90 @@
# GoblinPay for Medusa
Accept Grin (GRIN / MimbleWimble) payments in a Medusa v2 store through a
self-hosted GoblinPay server. The customer pays from their Goblin Wallet by
scanning an `nprofile` QR code. The payment travels as a gift-wrapped slatepack
over Nostr (optionally over the Nym mixnet). GoblinPay receives it, returns the
reply slatepack to the payer, watches the chain to confirm, and notifies Medusa
through a signed webhook.
This provider is a thin client. All of the Grin and Nostr work happens in
GoblinPay; Medusa only talks HTTP to your GoblinPay instance. No BTCPay, no node
exposed to the store, no wallet RPC.
## What it does
- Registers a `goblinpay` payment provider in the Medusa v2 payment module.
- On checkout, calls GoblinPay to create an invoice for the payment session and
stores the checkout details (`pay_url`, `nprofile`, `qr_svg`) on the session
so your storefront can render the QR or redirect to GoblinPay's hosted
`/pay/<token>` page.
- Captures the payment when GoblinPay reports it, via a signed webhook. If a
webhook is missed, `authorizePayment` and `getPaymentStatus` poll GoblinPay
for the invoice status as a fallback.
## Requirements
- Medusa v2 (built against `@medusajs/framework` 2.12; 2.x expected to work).
- Node 20 or newer.
- A running GoblinPay server reachable from the Medusa host.
## Options
Set these per-provider in `medusa-config.ts` (see INSTALL.md):
- `baseUrl`: base URL of your GoblinPay server, no trailing slash, for example
`https://pay.example`.
- `apiToken`: the GoblinPay create-invoice bearer token (`GP_API_TOKEN` on the
server).
- `webhookSecret`: the shared HMAC secret (`GP_WEBHOOK_SECRET` on the server).
- `matchMode` (optional): how GoblinPay ties an incoming payment to the order.
`derived` (per-invoice identity, recommended) gives each order its own QR and
is the most reliable. `memo` and `amount` are also available. Omit to use the
server default.
- `expirySecs` (optional): invoice expiry in seconds from creation.
## Webhook
GoblinPay reports payments to the Medusa payment module's built-in webhook
route. Point your GoblinPay server's `GP_WEBHOOK_URL` at:
```
https://YOUR-MEDUSA-HOST/hooks/payment/goblinpay_goblinpay
```
The provider verifies the `X-GoblinPay-Signature: sha256=<hmac>` header against
the exact raw body (constant-time) before acting.
## Status mapping
| GoblinPay | Medusa payment session |
|---|---|
| invoice `open` | `pending` |
| invoice `paid` | `captured` |
| invoice `expired` | `canceled` |
| webhook `payment.received` | captured (SUCCESSFUL) |
| webhook `payment.confirmed` | captured (SUCCESSFUL, idempotent) |
For a receive-only till, a received payment (the reply slatepack is back and the
funds are in the merchant wallet) is treated as paid, the same as the
WooCommerce connector. The later on-chain confirmation is an idempotent no-op.
## Refunds
Refunds are not automated. GoblinPay is receive-only: it never sends Grin. A
refund is therefore a manual, out-of-band Grin send by the merchant from a
wallet under their control. `refundPayment` throws to make this explicit, the
same caveat the Grin BTCPay connector carries.
## Security notes
- The webhook is authenticated by an HMAC-SHA256 signature over the exact raw
request body, compared in constant time. A bad or missing signature is
rejected and the payment is not flipped.
- The capture amount is read from the Medusa payment session (its own
store-currency amount), not from untrusted webhook fields.
- Secrets live in the provider options / environment, never in code.
## Credit
Built by Claude (Anthropic) for the Goblin project.
+41
View File
@@ -0,0 +1,41 @@
{
"name": "medusa-payment-goblinpay",
"version": "1.0.0",
"description": "GoblinPay payment provider for Medusa v2: accept Grin (GRIN / MimbleWimble) payments through a self-hosted, receive-only GoblinPay server.",
"license": "Apache-2.0",
"keywords": [
"medusa",
"medusa-v2",
"medusa-plugin",
"medusa-plugin-payment",
"payment",
"grin",
"mimblewimble",
"goblinpay"
],
"repository": {
"type": "git",
"url": "https://git.us-ea.st/GRIN/GoblinPay"
},
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist",
"src",
"README.md",
"INSTALL.md"
],
"scripts": {
"build": "tsc",
"typecheck": "tsc --noEmit"
},
"peerDependencies": {
"@medusajs/framework": "^2.0.0"
},
"devDependencies": {
"@medusajs/framework": "^2.12.0",
"@types/node": "^20.0.0",
"typescript": "^5.4.0"
}
}
+9
View File
@@ -0,0 +1,9 @@
import { ModuleProvider, Modules } from "@medusajs/framework/utils"
import GoblinPayProviderService from "./service"
// Register GoblinPay as a Medusa v2 payment-module provider. Referenced from
// medusa-config.ts under the payment module's `providers` (see INSTALL.md).
export default ModuleProvider(Modules.PAYMENT, {
services: [GoblinPayProviderService],
})
+302
View File
@@ -0,0 +1,302 @@
/**
* GoblinPay payment provider for Medusa v2 (tested against @medusajs 2.12).
*
* Modeled on connectors/woocommerce and on the reference
* github.com/SGFGOV/medusa-payment-plugins (packages/medusa-plugin-btcpay).
*
* Flow: `initiatePayment` creates a GoblinPay invoice for the order and stashes
* the checkout details (pay_url, nprofile, qr_svg) on the session so the
* storefront can render or redirect. The customer pays from their Goblin Wallet;
* GoblinPay receives it, returns the reply slatepack, watches the chain, and
* POSTs a signed webhook. `getWebhookActionAndData` verifies the HMAC and flips
* the Medusa payment to captured. Status polling (`authorizePayment`,
* `getPaymentStatus`) is the webhook-miss fallback.
*
* Refunds are NOT automated: GoblinPay is receive-only (it never sends Grin), so
* `refundPayment` throws. A refund is a manual, out-of-band Grin send by the
* merchant. See README.md.
*/
import crypto from "node:crypto"
import {
AbstractPaymentProvider,
ContainerRegistrationKeys,
MedusaError,
Modules,
PaymentActions,
} from "@medusajs/framework/utils"
import type {
AuthorizePaymentInput,
AuthorizePaymentOutput,
CancelPaymentInput,
CancelPaymentOutput,
CapturePaymentInput,
CapturePaymentOutput,
DeletePaymentInput,
DeletePaymentOutput,
GetPaymentStatusInput,
GetPaymentStatusOutput,
InitiatePaymentInput,
InitiatePaymentOutput,
IPaymentModuleService,
Logger,
ProviderWebhookPayload,
RefundPaymentInput,
RefundPaymentOutput,
RetrievePaymentInput,
RetrievePaymentOutput,
UpdatePaymentInput,
UpdatePaymentOutput,
WebhookActionResult,
} from "@medusajs/framework/types"
import type { GoblinPayInvoice, GoblinPayOptions } from "./types"
class GoblinPayProviderService extends AbstractPaymentProvider<GoblinPayOptions> {
static identifier = "goblinpay"
protected readonly options_: GoblinPayOptions
protected readonly logger_: Logger
protected readonly paymentService_: IPaymentModuleService
constructor(container: Record<string, unknown>, options: GoblinPayOptions) {
super(container as never, options)
this.options_ = options
this.logger_ = container[ContainerRegistrationKeys.LOGGER] as Logger
this.paymentService_ = container[Modules.PAYMENT] as IPaymentModuleService
if (!options?.baseUrl || !options?.apiToken || !options?.webhookSecret) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"GoblinPay provider requires baseUrl, apiToken, and webhookSecret options"
)
}
}
private get base(): string {
return this.options_.baseUrl.replace(/\/+$/, "")
}
/** Call the GoblinPay REST API with the bearer token. */
private async request<T>(
method: "GET" | "POST",
path: string,
body?: unknown
): Promise<T> {
const res = await fetch(`${this.base}${path}`, {
method,
headers: {
Accept: "application/json",
Authorization: `Bearer ${this.options_.apiToken}`,
...(body ? { "Content-Type": "application/json" } : {}),
},
body: body ? JSON.stringify(body) : undefined,
})
const text = await res.text()
const json = text ? JSON.parse(text) : {}
if (!res.ok) {
const err =
(json && (json.error as string)) || `GoblinPay HTTP ${res.status}`
throw new MedusaError(MedusaError.Types.UNEXPECTED_STATE, err)
}
return json as T
}
/** Map a GoblinPay invoice status to a Medusa payment session status. */
private static mapStatus(
status: string
): "captured" | "canceled" | "pending" {
switch (status) {
case "paid":
return "captured"
case "expired":
return "canceled"
default:
return "pending"
}
}
async initiatePayment(
input: InitiatePaymentInput
): Promise<InitiatePaymentOutput> {
const sessionId = input.context?.idempotency_key
if (!sessionId) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Idempotency key (payment session id) is required to initiate payment"
)
}
// GoblinPay prices the fiat order into Grin via its own oracle. The order's
// session id is the order_ref, so the signed webhook echoes it back to us.
const invoice = await this.request<GoblinPayInvoice>("POST", "/invoice", {
order_ref: sessionId,
amount_fiat: input.amount.toString(),
currency: input.currency_code,
memo: `Medusa order ${sessionId}`,
...(this.options_.matchMode ? { match_mode: this.options_.matchMode } : {}),
...(this.options_.expirySecs ? { expiry_secs: this.options_.expirySecs } : {}),
})
return {
id: sessionId,
data: { ...input.data, goblinpay: invoice },
}
}
/** Re-read the current invoice from GoblinPay using the stored invoice_id. */
private async fetchInvoice(
data: Record<string, unknown> | undefined
): Promise<GoblinPayInvoice> {
const stored = (data?.goblinpay ?? {}) as GoblinPayInvoice
if (!stored.invoice_id) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"No GoblinPay invoice_id on the payment session"
)
}
return this.request<GoblinPayInvoice>(
"GET",
`/invoice/${encodeURIComponent(stored.invoice_id)}`
)
}
async authorizePayment(
input: AuthorizePaymentInput
): Promise<AuthorizePaymentOutput> {
const invoice = await this.fetchInvoice(input.data)
return {
status: GoblinPayProviderService.mapStatus(invoice.status),
data: { ...input.data, goblinpay: invoice },
}
}
async getPaymentStatus(
input: GetPaymentStatusInput
): Promise<GetPaymentStatusOutput> {
const invoice = await this.fetchInvoice(input.data)
return {
status: GoblinPayProviderService.mapStatus(invoice.status),
data: { ...input.data, goblinpay: invoice },
}
}
async capturePayment(
input: CapturePaymentInput
): Promise<CapturePaymentOutput> {
// GoblinPay is receive-only: once the payment is received the funds are
// already in the merchant wallet, so capture is a no-op acknowledgement.
return { data: input.data ?? {} }
}
async cancelPayment(
input: CancelPaymentInput
): Promise<CancelPaymentOutput> {
// Nothing to cancel server-side; an unpaid GoblinPay invoice simply expires.
return { data: input.data ?? {} }
}
async deletePayment(
input: DeletePaymentInput
): Promise<DeletePaymentOutput> {
return { data: input.data ?? {} }
}
async refundPayment(
_input: RefundPaymentInput
): Promise<RefundPaymentOutput> {
// Receive-only: GoblinPay never sends Grin, so refunds cannot be automated.
// A refund is a manual, out-of-band Grin send by the merchant.
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"GoblinPay is receive-only; refunds must be issued manually by the merchant (out-of-band Grin send)."
)
}
async retrievePayment(
input: RetrievePaymentInput
): Promise<RetrievePaymentOutput> {
const invoice = await this.fetchInvoice(input.data)
return { data: { ...input.data, goblinpay: invoice } }
}
async updatePayment(
input: UpdatePaymentInput
): Promise<UpdatePaymentOutput> {
return { data: input.data ?? {} }
}
/**
* Verify the HMAC-SHA256 over the EXACT raw body, constant-time. Mirrors the
* WooCommerce connector and GoblinPay's webhook contract:
* X-GoblinPay-Signature: sha256=<hex(HMAC-SHA256(secret, raw_body))>
*/
private verifySignature(payload: ProviderWebhookPayload["payload"]): boolean {
const raw = payload.rawData
if (!raw) {
return false
}
const provided = (payload.headers?.["x-goblinpay-signature"] as string) ?? ""
const expected =
"sha256=" +
crypto
.createHmac("sha256", this.options_.webhookSecret)
.update(raw as string | Buffer)
.digest("hex")
const a = Buffer.from(provided, "utf8")
const b = Buffer.from(expected, "utf8")
return a.length === b.length && crypto.timingSafeEqual(a, b)
}
async getWebhookActionAndData(
payload: ProviderWebhookPayload["payload"]
): Promise<WebhookActionResult> {
if (!this.verifySignature(payload)) {
this.logger_.warn("goblinpay: webhook signature mismatch")
return { action: PaymentActions.FAILED }
}
const data = (payload.data ?? {}) as {
event_type?: string
order_ref?: string
}
const sessionId = data.order_ref
if (!sessionId) {
return { action: PaymentActions.NOT_SUPPORTED }
}
// payment.received (funds in hand, S2 returned) and payment.confirmed
// (on-chain) both mean paid for a receive-only till: flip to captured. The
// capture is idempotent, so a later confirmation after a received event is a
// no-op. Capture the session's own (store-currency) amount.
if (
data.event_type === "payment.received" ||
data.event_type === "payment.confirmed"
) {
const amount = await this.sessionAmount(sessionId)
return {
action: PaymentActions.SUCCESSFUL,
data: { session_id: sessionId, amount },
}
}
return { action: PaymentActions.NOT_SUPPORTED }
}
/** The payment session's authorized amount, for the webhook capture. */
private async sessionAmount(sessionId: string): Promise<number> {
try {
const session = await this.paymentService_.retrievePaymentSession(sessionId)
return Number(session.amount)
} catch (e) {
this.logger_.warn(
`goblinpay: could not read session ${sessionId} amount: ${
(e as Error).message
}`
)
return 0
}
}
}
export default GoblinPayProviderService
+40
View File
@@ -0,0 +1,40 @@
/**
* Options + wire types for the GoblinPay Medusa v2 payment provider.
*
* GoblinPay is a receive-only Grin payment server. This provider is a thin
* client: it calls GoblinPay's REST API to create an invoice and reads back the
* checkout details, then flips the Medusa payment on a signed webhook. All Grin
* and Nostr work happens in GoblinPay; Medusa only speaks HTTP to it.
*/
/** Provider options, set per-provider in `medusa-config.ts`. */
export interface GoblinPayOptions {
/** Base URL of your GoblinPay server, no trailing slash (e.g. https://pay.example). */
baseUrl: string
/** Bearer token for the create-invoice API (`GP_API_TOKEN` on the server). */
apiToken: string
/** Shared HMAC secret for webhook verification (`GP_WEBHOOK_SECRET`). */
webhookSecret: string
/**
* How GoblinPay matches an incoming payment to this order. `derived`
* (per-invoice identity, recommended) gives each order its own QR. Omit to
* use the server default.
*/
matchMode?: "memo" | "derived" | "amount"
/** Optional invoice expiry in seconds from creation. */
expirySecs?: number
}
/** The subset of GoblinPay's `/invoice` response this provider stores/uses. */
export interface GoblinPayInvoice {
invoice_id: string
token?: string
pay_url: string
nprofile?: string
npub?: string
qr_svg?: string
amount?: string
/** GoblinPay invoice lifecycle: `open` | `paid` | `expired`. */
status: string
order_ref?: string
}
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2021",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2021"],
"declaration": true,
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"],
"exclude": ["dist", "node_modules"]
}
+35 -3
View File
@@ -29,6 +29,14 @@ pub const DEFAULT_DATA_DIR: &str = "./gp-data";
/// Nostr gift-wrap layer in gp-nostr).
pub const DEFAULT_NODE_URL: &str = "https://main.gri.mw";
/// Default URL of the bundled relay in `bundled` relay mode: the co-located
/// relay GoblinPay ships in `deploy/docker-compose.yml` (a vendored
/// nostr-rs-relay), so a merchant needs no third-party relay. Override with
/// `GP_BUNDLED_RELAY_URL`. In a public deployment set this to the relay's
/// publicly reachable `wss://<domain>` URL, because the same value is both
/// dialed by the server AND advertised to payers in the checkout `nprofile`.
pub const DEFAULT_BUNDLED_RELAY: &str = "ws://127.0.0.1:7777";
/// TLS mode for the HTTP server.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
@@ -53,7 +61,11 @@ pub enum Chain {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RelayMode {
/// GoblinPay supervises its own relay (default; see module design 3).
/// GoblinPay talks to its own co-located relay (default): the bundled
/// nostr-rs-relay from `deploy/docker-compose.yml`, reached at
/// `GP_BUNDLED_RELAY_URL`. That relay is what the checkout `nprofile`
/// advertises, so a merchant needs no third-party relay. Any `GP_RELAYS`
/// are added alongside it for redundancy.
Bundled,
/// Only external relays from `GP_RELAYS` are used.
External,
@@ -130,8 +142,14 @@ pub struct Config {
pub relay_mode: RelayMode,
/// External relays (`GP_RELAYS`, comma separated).
pub relays: Vec<String>,
/// URL of the bundled relay used in `bundled` relay mode
/// (`GP_BUNDLED_RELAY_URL`, default `ws://127.0.0.1:7777`). Both dialed by
/// the ingest service and advertised to payers in the checkout `nprofile`.
pub bundled_relay_url: String,
/// Route Nostr traffic over the Nym mixnet (`GP_NYM`: `on` or `off`,
/// default on; clearnet is a debugging escape hatch only).
/// default on). Production may deliberately run `off` (server-side
/// clearnet): the payer's Goblin Wallet still provides sender privacy over
/// its own mixnet, and the payload is gift-wrapped end to end regardless.
pub nym: bool,
/// Run the Nostr ingest service (`GP_INGEST`: `on` or `off`, default on).
/// When on, the wallet and identity secrets are required at boot.
@@ -235,6 +253,7 @@ impl Default for Config {
chain: Chain::Mainnet,
relay_mode: RelayMode::Bundled,
relays: Vec::new(),
bundled_relay_url: DEFAULT_BUNDLED_RELAY.into(),
nym: true,
ingest: true,
checkout_nostr: true,
@@ -320,6 +339,10 @@ impl Config {
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect::<Vec<_>>();
let bundled_relay_url = get("GP_BUNDLED_RELAY_URL")
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or(defaults.bundled_relay_url);
let nym = match get("GP_NYM").as_deref().unwrap_or("on") {
"on" => true,
@@ -406,6 +429,7 @@ impl Config {
chain,
relay_mode,
relays,
bundled_relay_url,
nym,
ingest,
checkout_nostr,
@@ -473,6 +497,9 @@ impl Config {
if self.relay_mode == RelayMode::External && self.relays.is_empty() {
return Err("GP_RELAY_MODE=external requires GP_RELAYS".into());
}
if self.relay_mode == RelayMode::Bundled && self.bundled_relay_url.trim().is_empty() {
return Err("GP_RELAY_MODE=bundled requires a non-empty GP_BUNDLED_RELAY_URL".into());
}
if self.nsec.is_some() && self.ncryptsec.is_some() {
return Err("set only one of GP_NSEC and GP_NCRYPTSEC".into());
}
@@ -507,7 +534,8 @@ impl Config {
let set = |o: bool| if o { "set" } else { "unset" };
format!(
"bind={} tls={} db={} data_dir={} node={} chain={:?} relay_mode={:?} \
relays={:?} nym={} ingest={} checkout_methods={} match_mode={:?} mnemonic={} \
relays={:?} bundled_relay={} nym={} ingest={} checkout_methods={} match_mode={:?} \
mnemonic={} \
wallet_password={} \
nsec={} ncryptsec={} public_url={} api_token={} admin_token={} webhook_url={} \
webhook_secret={} qr_logo={} merchant_npub={} notify_merchant_dm={} \
@@ -525,6 +553,7 @@ impl Config {
self.chain,
self.relay_mode,
self.relays,
self.bundled_relay_url,
if self.nym { "on" } else { "off" },
if self.ingest { "on" } else { "off" },
self.checkout_methods_str(),
@@ -657,6 +686,7 @@ mod tests {
assert_eq!(cfg.chain, Chain::Mainnet);
assert_eq!(cfg.relay_mode, RelayMode::Bundled);
assert!(cfg.relays.is_empty());
assert_eq!(cfg.bundled_relay_url, DEFAULT_BUNDLED_RELAY);
assert!(cfg.nym);
assert!(cfg.ingest);
assert_eq!(cfg.match_mode, MatchMode::Memo);
@@ -676,6 +706,7 @@ mod tests {
("GP_CHAIN", "testnet"),
("GP_RELAY_MODE", "external"),
("GP_RELAYS", "wss://relay.example, wss://relay2.example ,"),
("GP_BUNDLED_RELAY_URL", "wss://relay.mystore.example"),
("GP_NYM", "off"),
("GP_INGEST", "off"),
("GP_MATCH_MODE", "derived"),
@@ -691,6 +722,7 @@ mod tests {
cfg.relays,
vec!["wss://relay.example", "wss://relay2.example"]
);
assert_eq!(cfg.bundled_relay_url, "wss://relay.mystore.example");
assert!(!cfg.nym);
assert!(!cfg.ingest);
assert_eq!(cfg.match_mode, MatchMode::Derived);
+3 -3
View File
@@ -1,8 +1,8 @@
//! The store-connector seam.
//!
//! Every store integration (the built-in generic REST connector, the
//! WooCommerce and Medusa plugins that arrive in a later milestone, and the
//! future pop-up Nostr store) drives GoblinPay through one uniform contract:
//! Every store integration (the built-in generic REST connector, the shipped
//! WooCommerce and Medusa plugins under `connectors/`, and the future pop-up
//! Nostr store) drives GoblinPay through one uniform contract:
//! a create-invoice request in, a hosted checkout + signed webhook out. This
//! trait keeps that mapping in one place so the core never grows per-store
//! branches: a connector only decides how a store's order becomes invoice
+75 -18
View File
@@ -1,24 +1,48 @@
//! Default relay set and helpers (mirrors `goblin/src/nostr/relays.rs`).
//! Relay set resolution.
//!
//! GoblinPay runs in one of two relay modes (`GP_RELAY_MODE`, see
//! [`gp_core::config::RelayMode`]):
//!
//! - `bundled` (default): GoblinPay talks to its own co-located relay, the
//! nostr-rs-relay shipped as the `relay` service in
//! `deploy/docker-compose.yml`. Its URL is `GP_BUNDLED_RELAY_URL` (default
//! `ws://127.0.0.1:7777`). Because the resolved set is exactly what the
//! checkout `nprofile` advertises to payers, a merchant needs no third-party
//! relay: the payer's Goblin Wallet is told to deliver the gift-wrapped
//! slatepack to the merchant's own relay. Extra relays listed in `GP_RELAYS`
//! are appended for redundancy (and advertised alongside the bundled one).
//! - `external`: only the relays listed in `GP_RELAYS` are used (no bundled
//! relay); config validation requires at least one.
//!
//! The bundled relay is a vendored, unmodified nostr-rs-relay (config only, no
//! fork) rather than a relay written from scratch: it is a small, SQLite-backed
//! Rust relay that fits a single-merchant till, and reusing it keeps the money
//! path off any third-party infrastructure.
/// Default DM relays: the Goblin relay plus large public relays for
/// redundancy. Used when `GP_RELAYS` is unset (the bundled relay is a later
/// milestone; until then `bundled` mode serves this set too).
pub const DEFAULT_RELAYS: &[&str] = &[
"wss://relay.goblin.st",
"wss://relay.damus.io",
"wss://nos.lol",
];
use gp_core::config::RelayMode;
/// Maximum relays published in the kind 10050 DM relay list (NIP-17
/// guidance) and read from a payer's list.
pub const MAX_DM_RELAYS: usize = 3;
/// The relay set to run with: the configured external list, else defaults.
pub fn resolve(configured: &[String]) -> Vec<String> {
if configured.is_empty() {
DEFAULT_RELAYS.iter().map(|s| s.to_string()).collect()
} else {
configured.to_vec()
/// The relay set to listen on, publish to, and advertise in the `nprofile`.
///
/// In `bundled` mode the co-located `bundled_url` comes first (so it heads the
/// advertised kind 10050 / `nprofile` hints), followed by any `configured`
/// redundancy relays, de-duplicated. In `external` mode only the `configured`
/// relays are used.
pub fn resolve(mode: RelayMode, bundled_url: &str, configured: &[String]) -> Vec<String> {
match mode {
RelayMode::Bundled => {
let mut relays = vec![bundled_url.to_string()];
for relay in configured {
if !relays.iter().any(|r| r == relay) {
relays.push(relay.clone());
}
}
relays
}
RelayMode::External => configured.to_vec(),
}
}
@@ -27,9 +51,42 @@ mod tests {
use super::*;
#[test]
fn resolves_defaults_and_overrides() {
assert_eq!(resolve(&[]), DEFAULT_RELAYS.to_vec());
fn bundled_leads_with_the_bundled_relay() {
// No extras: just the bundled relay, so the nprofile advertises it and
// nothing third-party is involved.
assert_eq!(
resolve(RelayMode::Bundled, "ws://127.0.0.1:7777", &[]),
vec!["ws://127.0.0.1:7777".to_string()]
);
// Extras are appended for redundancy; the bundled relay stays first.
let extras = vec!["wss://relay.damus.io".to_string()];
assert_eq!(
resolve(RelayMode::Bundled, "ws://127.0.0.1:7777", &extras),
vec![
"ws://127.0.0.1:7777".to_string(),
"wss://relay.damus.io".to_string(),
]
);
// A configured relay equal to the bundled one is not added twice.
let dup = vec![
"ws://127.0.0.1:7777".to_string(),
"wss://r.example".to_string(),
];
assert_eq!(
resolve(RelayMode::Bundled, "ws://127.0.0.1:7777", &dup),
vec![
"ws://127.0.0.1:7777".to_string(),
"wss://r.example".to_string(),
]
);
}
#[test]
fn external_uses_only_configured() {
let own = vec!["wss://relay.example".to_string()];
assert_eq!(resolve(&own), own);
assert_eq!(
resolve(RelayMode::External, "ws://127.0.0.1:7777", &own),
own
);
}
}
+7 -3
View File
@@ -41,8 +41,9 @@ const NYM_WARM_WAIT: Duration = Duration::from_secs(30);
pub struct ServiceOptions {
/// Relay set to listen on and publish to.
pub relays: Vec<String>,
/// Route everything over the Nym mixnet (default on; clearnet is a
/// debugging escape hatch only).
/// Route everything over the Nym mixnet (default on). `off` is a supported
/// production posture (server-side clearnet): the payer's Goblin Wallet
/// still rides its own mixnet, and the payload is gift-wrapped end to end.
pub nym: bool,
/// Optional NIP-17 payment DMs (milestone 6, all off by default).
pub notify: NotifyOptions,
@@ -141,7 +142,10 @@ pub async fn run<R: SlatepackReceiver>(
.websocket_transport(NymWebSocketTransport)
.build()
} else {
warn!("nostr: GP_NYM=off — relay traffic goes CLEARNET (debugging only)");
warn!(
"nostr: GP_NYM=off — this server's relay traffic goes CLEARNET (supported: the \
payer's wallet still provides sender privacy; the payload stays gift-wrapped)"
);
Client::builder().build()
};
+3 -2
View File
@@ -105,7 +105,8 @@ async fn dashboard(
match_mode: format!("{:?}", cfg.match_mode).to_lowercase(),
nym: cfg.nym,
ingest: cfg.ingest,
relay_count: gp_nostr::relays::resolve(&cfg.relays).len(),
relay_count: gp_nostr::relays::resolve(cfg.relay_mode, &cfg.bundled_relay_url, &cfg.relays)
.len(),
webhook_configured: cfg.webhook_url.is_some(),
pending_webhooks,
rotate_interval: cfg.endpub_rotate_interval,
@@ -206,7 +207,7 @@ struct CreateUserBody {
}
fn endpub_json(cfg: &Config, user_id: &str, epoch: i64, pubkey: &str) -> serde_json::Value {
let relays = gp_nostr::relays::resolve(&cfg.relays);
let relays = gp_nostr::relays::resolve(cfg.relay_mode, &cfg.bundled_relay_url, &cfg.relays);
let (npub, nprofile, qr) = match PublicKey::from_hex(pubkey) {
Ok(pk) => (
gp_nostr::npub_of(pk),
+1 -1
View File
@@ -56,7 +56,7 @@ pub struct CheckoutInfo {
/// caller does not surface the Slatepack option (e.g. the JSON connector API),
/// in which case no Slatepack address or QR is produced.
pub fn build_info(inv: &Invoice, cfg: &Config, slatepack_addr: Option<&str>) -> CheckoutInfo {
let relays = gp_nostr::relays::resolve(&cfg.relays);
let relays = gp_nostr::relays::resolve(cfg.relay_mode, &cfg.bundled_relay_url, &cfg.relays);
let recipient_pubkey = inv.recipient_pubkey.clone().unwrap_or_default();
// The Nostr (Goblin Wallet) method is only surfaced when the operator has it
// enabled (`GP_CHECKOUT_METHODS`). Disabled, the nprofile/npub/QR are left
+1 -1
View File
@@ -132,7 +132,7 @@ async fn start_ingest(cfg: &Config, pool: sqlx::SqlitePool) -> (Keys, GpWallet)
eprintln!("warning: GP_NOTIFY_MERCHANT_DM=on but GP_MERCHANT_NPUB is unset/invalid");
}
let opts = gp_nostr::service::ServiceOptions {
relays: gp_nostr::relays::resolve(&cfg.relays),
relays: gp_nostr::relays::resolve(cfg.relay_mode, &cfg.bundled_relay_url, &cfg.relays),
nym: cfg.nym,
notify: gp_nostr::service::NotifyOptions {
merchant,
+39
View File
@@ -0,0 +1,39 @@
# GoblinPay environment. Copy to /etc/goblinpay.env (bare metal) or deploy/.env
# (docker compose), then edit. NON-SECRET config only: the Grin seed and the
# wallet password live as mode-0400 files (systemd LoadCredential / the compose
# ./secrets mount), never in this file.
# --- domain / URLs ---
# docker-compose serves GoblinPay on GP_DOMAIN and the bundled relay on
# relay.<GP_DOMAIN>; point BOTH DNS records at this host before `compose up`.
GP_DOMAIN=pay.example
GP_PUBLIC_URL=https://pay.example
# --- relay (bundled is the default: GoblinPay runs its own relay) ---
GP_RELAY_MODE=bundled
# The bundled relay's PUBLIC url: it is BOTH dialed by the server AND advertised
# to payers in the checkout nprofile, so it must be reachable from the internet.
GP_BUNDLED_RELAY_URL=wss://relay.pay.example
# For GP_RELAY_MODE=external instead, drop the bundled relay and set:
#GP_RELAY_MODE=external
#GP_RELAYS=wss://relay.damus.io,wss://nos.lol
# --- Grin node (read-only: confirmations + balance) ---
GP_NODE_URL=https://main.gri.mw
# --- mixnet ---
# on (default) routes THIS server's relay traffic over the Nym mixnet. off is a
# supported production posture (server-side clearnet): the payer's Goblin Wallet
# still provides sender privacy and the payload stays gift-wrapped end to end.
GP_NYM=on
# --- API / admin tokens (bearer capabilities; use strong random values) ---
GP_API_TOKEN=change-me-api-token
GP_ADMIN_TOKEN=change-me-admin-token
# --- webhook to your store (optional; the URL requires the secret) ---
#GP_WEBHOOK_URL=https://your-store/hook
#GP_WEBHOOK_SECRET=change-me-webhook-secret
# --- default payment-matching mode: memo | derived | amount ---
GP_MATCH_MODE=derived
+23
View File
@@ -0,0 +1,23 @@
# Caddy reverse proxy for a GoblinPay till, with automatic HTTPS.
#
# Two names on one host (point both A/AAAA records at this server before
# `docker compose up`, so Caddy can obtain certificates):
# {$GP_DOMAIN} -> the GoblinPay checkout pages + REST API (gp-server)
# relay.{$GP_DOMAIN} -> the bundled nostr-rs-relay (payers connect here; it
# is what the checkout nprofile advertises)
#
# The relay gets its OWN subdomain rather than a path on the main domain so
# there is no path rewriting: nostr-rs-relay serves both the WebSocket relay
# protocol and the NIP-11 relay-info document at the root.
#
# GP_DOMAIN is injected from the environment by docker-compose.
{$GP_DOMAIN} {
encode gzip
reverse_proxy gp-server:8080
}
relay.{$GP_DOMAIN} {
# WebSocket upgrades and the NIP-11 document both go straight through.
reverse_proxy relay:7777
}
+67
View File
@@ -0,0 +1,67 @@
# Multi-stage build for the GoblinPay server, run as a non-root user.
#
# IMPORTANT — build context is the WORKSPACE PARENT, not the repo.
# The Nostr/Nym money path depends on two crates that live next to this repo,
# not inside it (see crates/gp-nostr/Cargo.toml):
# nip44 -> ../nip44 (the NIP-44 v3 companion crate)
# smolmix-> ../nym/smolmix/core (the in-process Nym mixnet)
# So the image must be built from the directory that contains GoblinPay/,
# nip44/, and nym/ side by side. docker-compose.yml already sets
# `build.context: ../..` for this; to build by hand:
#
# cd "<workspace parent containing GoblinPay, nip44, nym>"
# docker build -f GoblinPay/deploy/Dockerfile -t goblinpay:latest .
#
# Only `-p gp-server` is built, which EXCLUDES the gp-goblin-sender dev crate
# (it needs the goblin wallet tree, absent on servers). gp-wallet's grin_wallet
# crates are fetched from git during the build.
# ---- builder ----
FROM rust:1-bookworm AS builder
RUN apt-get update \
&& apt-get install -y --no-install-recommends clang cmake pkg-config libssl-dev \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /build
# The three trees the gp-server dependency graph needs, in the same relative
# layout the path deps expect (nip44 and nym are siblings of GoblinPay).
COPY GoblinPay ./GoblinPay
COPY nip44 ./nip44
COPY nym ./nym
WORKDIR /build/GoblinPay
# Build ONLY gp-server (and its deps); never the goblin-tree dev crate.
RUN cargo build --release --locked -p gp-server
# ---- runtime ----
FROM debian:bookworm-slim AS runtime
# ca-certificates for outbound TLS (node reads, CoinGecko, relays); curl for the
# healthcheck.
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl \
&& rm -rf /var/lib/apt/lists/*
# Non-root user; wallet files, seed-at-rest, and the SQLite db live under /data.
RUN useradd --system --uid 10001 --home-dir /data --shell /usr/sbin/nologin goblinpay \
&& mkdir -p /data \
&& chown -R goblinpay:goblinpay /data
COPY --from=builder /build/GoblinPay/target/release/gp-server /usr/local/bin/gp-server
USER goblinpay
WORKDIR /data
VOLUME ["/data"]
# Bind on all interfaces inside the container (Caddy is the only thing in front);
# keep state under the /data volume. Money/identity secrets are injected at run
# time via the *_FILE mounted-secret variants, never baked into the image.
ENV GP_BIND=0.0.0.0:8080 \
GP_DB_PATH=/data/goblinpay.db \
GP_DATA_DIR=/data/gp-data
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD curl -fsS http://127.0.0.1:8080/health || exit 1
ENTRYPOINT ["/usr/local/bin/gp-server"]
+89
View File
@@ -0,0 +1,89 @@
# A full, self-contained GoblinPay till: the server, its BUNDLED relay, and an
# auto-HTTPS reverse proxy.
#
# cd deploy
# cp .env.example .env # then edit it (domain, tokens)
# mkdir -p secrets # drop the mounted-secret files in here
# docker compose up -d
#
# gives you:
# - gp-server : the GoblinPay payment server (this repo)
# - relay : a stock nostr-rs-relay, the bundled relay GP_RELAY_MODE=bundled
# points at (so no third-party relay is needed)
# - caddy : auto-TLS reverse proxy terminating HTTPS for both
#
# Set GP_DOMAIN in .env to your own domain BEFORE bringing it up: Caddy obtains
# a certificate for it, so DNS must already point at this host.
#
# NOTE on the build context: gp-server's Nostr/Nym path depends on the sibling
# crates nip44/ and nym/ (see deploy/Dockerfile), so the build context is the
# workspace parent (`../..`) that holds GoblinPay, nip44, and nym.
services:
gp-server:
build:
context: ../..
dockerfile: GoblinPay/deploy/Dockerfile
image: goblinpay:latest
restart: unless-stopped
env_file: .env
environment:
# Bundled relay (default mode). GP_BUNDLED_RELAY_URL is BOTH dialed by the
# server and advertised to payers in the nprofile, so it must be the
# relay's PUBLIC url (payers connect here); the server reaches it back
# through Caddy.
GP_RELAY_MODE: bundled
GP_BUNDLED_RELAY_URL: ${GP_BUNDLED_RELAY_URL:-wss://relay.${GP_DOMAIN}}
GP_PUBLIC_URL: ${GP_PUBLIC_URL:-https://${GP_DOMAIN}}
GP_BIND: 0.0.0.0:8080
GP_DB_PATH: /data/goblinpay.db
GP_DATA_DIR: /data/gp-data
# Money/identity secrets come from mounted files (never the image/env):
GP_MNEMONIC_FILE: /run/secrets/gp_mnemonic
GP_WALLET_PASSWORD_FILE: /run/secrets/gp_wallet_password
GP_NCRYPTSEC_FILE: /run/secrets/gp_ncryptsec
volumes:
- gp-data:/data
- ./secrets:/run/secrets:ro
expose:
- "8080"
depends_on:
- relay
relay:
image: scsibug/nostr-rs-relay:latest
restart: unless-stopped
volumes:
- ./relay/nostr-rs-relay.toml:/usr/src/app/config.toml:ro
- relay-data:/usr/src/app/db
expose:
- "7777"
# Bound the relay's footprint so an unauthenticated flood cannot starve the
# till or proxy on the same host.
deploy:
resources:
limits:
memory: 512M
cpus: "1.0"
caddy:
image: caddy:2
restart: unless-stopped
depends_on:
- gp-server
- relay
environment:
GP_DOMAIN: ${GP_DOMAIN:-pay.example}
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config
volumes:
gp-data:
relay-data:
caddy-data:
caddy-config:
+80
View File
@@ -0,0 +1,80 @@
# Hardened systemd unit for the GoblinPay server on bare metal.
#
# Install (or just run deploy/install.sh):
# sudo install -m0755 target/release/gp-server /usr/local/bin/
# sudo install -m0640 deploy/.env.example /etc/goblinpay.env # then EDIT it
# sudo install -m0644 deploy/gp-server.service /etc/systemd/system/
# sudo mkdir -p /etc/goblinpay/secrets # 0400 secret files
# sudo systemctl daemon-reload && sudo systemctl enable --now gp-server
#
# Unlike goblin-nip05d, this service holds MONEY secrets (the Grin seed and the
# wallet password) and a wallet data directory. The seed and password are passed
# as systemd credentials (read by PID1 as root, exposed read-only to the dynamic
# service user) rather than left world-readable, and the config supports the
# `*_FILE` mounted-secret variants for exactly this.
[Unit]
Description=GoblinPay — self-hostable, receive-only Grin payment server
After=network-online.target
Wants=network-online.target
[Service]
Type=exec
# Throwaway unprivileged user allocated at runtime. For a stable data owner,
# comment this out and set `User=goblinpay` (create the user first).
DynamicUser=yes
# Non-secret config (domain, node, tokens, webhook, relay URL). Read by systemd
# as root, so a 0640 root:root file is fine even under DynamicUser.
EnvironmentFile=/etc/goblinpay.env
# Money/identity secrets as credentials: the source files stay root-owned 0400;
# systemd exposes copies under $CREDENTIALS_DIRECTORY (%d), readable by the
# dynamic service user. Point the wallet at them via the *_FILE variants.
LoadCredential=gp_mnemonic:/etc/goblinpay/secrets/mnemonic
LoadCredential=gp_wallet_password:/etc/goblinpay/secrets/wallet_password
Environment=GP_MNEMONIC_FILE=%d/gp_mnemonic
Environment=GP_WALLET_PASSWORD_FILE=%d/gp_wallet_password
# Optional: a NIP-49 encrypted Nostr identity (else a random one is generated
# and persisted under the data dir on first start). Uncomment with its file:
#LoadCredential=gp_ncryptsec:/etc/goblinpay/secrets/ncryptsec
#Environment=GP_NCRYPTSEC_FILE=%d/gp_ncryptsec
# Managed state at /var/lib/goblinpay: the SQLite db, the wallet files, and the
# encrypted seed at rest. 0700 — only the service user may read it.
StateDirectory=goblinpay
StateDirectoryMode=0700
Environment=GP_DB_PATH=/var/lib/goblinpay/goblinpay.db
Environment=GP_DATA_DIR=/var/lib/goblinpay/gp-data
ExecStart=/usr/local/bin/gp-server
Restart=on-failure
RestartSec=2
# --- hardening ---
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
RestrictNamespaces=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
LockPersonality=yes
# If the Nym mixnet stack ever fails to start with a W^X error, comment this out.
MemoryDenyWriteExecute=yes
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallFilter=~@privileged @resources
# Only the state directory is writable.
ReadWritePaths=/var/lib/goblinpay
# No raw sockets; only IP + unix.
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
[Install]
WantedBy=multi-user.target
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env bash
# One-command bare-metal bootstrap for the GoblinPay server:
# - builds the release binary (gp-server only; never the goblin-tree dev crate)
# - installs it to /usr/local/bin
# - creates the managed state dir and the 0700 secrets dir
# - installs an env file from deploy/.env.example (if absent)
# - installs and enables the hardened systemd unit
#
# Re-runnable: it never overwrites an existing /etc/goblinpay.env.
# Requires: a Rust toolchain (cargo) and root (sudo) for the install steps.
#
# BUILD PREREQUISITE: gp-server's Nostr/Nym path depends on the sibling crates
# nip44/ and nym/ (see crates/gp-nostr/Cargo.toml). They must sit next to this
# repo, exactly as on the deploy host. `-p gp-server` deliberately excludes the
# gp-goblin-sender dev crate, which needs the (absent) goblin wallet tree.
#
# After it finishes, edit /etc/goblinpay.env and drop the secret files into
# /etc/goblinpay/secrets (mnemonic, wallet_password), then:
# sudo systemctl restart gp-server
set -euo pipefail
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
BIN=/usr/local/bin/gp-server
ENV_FILE=/etc/goblinpay.env
UNIT=/etc/systemd/system/gp-server.service
STATE_DIR=/var/lib/goblinpay
SECRETS_DIR=/etc/goblinpay/secrets
say() { printf '\033[1;33m==>\033[0m %s\n' "$1"; }
if [[ $EUID -ne 0 ]]; then
SUDO=sudo
else
SUDO=""
fi
say "Building release binary (cargo build --release --locked -p gp-server)"
( cd "$REPO_DIR" && cargo build --release --locked -p gp-server )
say "Installing binary to $BIN"
$SUDO install -m0755 "$REPO_DIR/target/release/gp-server" "$BIN"
say "Creating state directory $STATE_DIR (0700)"
$SUDO install -d -m0700 "$STATE_DIR"
say "Creating secrets directory $SECRETS_DIR (0700)"
$SUDO install -d -m0700 "$SECRETS_DIR"
if [[ -f "$ENV_FILE" ]]; then
say "Env file $ENV_FILE already exists — leaving it untouched"
else
say "Installing env file to $ENV_FILE (EDIT IT: domain, node, tokens)"
$SUDO install -m0640 "$REPO_DIR/deploy/.env.example" "$ENV_FILE"
fi
say "Installing systemd unit to $UNIT"
$SUDO install -m0644 "$REPO_DIR/deploy/gp-server.service" "$UNIT"
say "Reloading systemd and enabling the service"
$SUDO systemctl daemon-reload
$SUDO systemctl enable gp-server
cat <<EOF
Done. Next steps:
1. Edit $ENV_FILE — set GP_PUBLIC_URL, GP_NODE_URL, GP_BUNDLED_RELAY_URL,
GP_API_TOKEN, GP_ADMIN_TOKEN (and GP_WEBHOOK_URL/GP_WEBHOOK_SECRET if used).
2. Write the wallet secrets (root-owned, mode 0400):
sudo install -m0400 /dev/stdin $SECRETS_DIR/mnemonic <<<'your 24 words'
sudo install -m0400 /dev/stdin $SECRETS_DIR/wallet_password <<<'your password'
3. Run the bundled relay (deploy/docker-compose.yml) or point
GP_BUNDLED_RELAY_URL at a relay you control, and put a TLS reverse proxy
in front (see deploy/Caddyfile).
4. Start it: $SUDO systemctl start gp-server
5. Check it: curl -s http://127.0.0.1:8080/health
EOF
+39
View File
@@ -0,0 +1,39 @@
# Configuration for the BUNDLED GoblinPay relay: a stock, unmodified
# nostr-rs-relay (https://github.com/scsibug/nostr-rs-relay) run as the `relay`
# service in docker-compose.yml. This is the self-contained relay that
# `GP_RELAY_MODE=bundled` (the default) points at, so a merchant needs no
# third-party relay: GoblinPay dials it, and the checkout `nprofile` advertises
# it to payers, who deliver their gift-wrapped slatepack straight to the
# merchant's own relay.
#
# nostr-rs-relay is a small, SQLite-backed Rust relay: a good fit for a
# single-merchant till, and vendored as-is (config only, no fork).
[info]
# Set this to the relay's PUBLIC wss URL (the same value you put in
# GP_BUNDLED_RELAY_URL). Payers connect here.
relay_url = "wss://pay.example/"
name = "GoblinPay bundled relay"
description = "Co-located Nostr relay for a GoblinPay merchant till."
[database]
data_directory = "/usr/src/app/db"
[network]
# Inside the container. Caddy terminates TLS and proxies wss -> here.
address = "0.0.0.0"
port = 7777
[limits]
# Bound the footprint so an unauthenticated ingest/subscription flood cannot
# starve the till (mirrors the reasoning behind the bundled strfry limits in
# goblin-nip05d). Payers publish NIP-59 gift wraps from random ephemeral keys,
# so a pubkey allowlist is intentionally NOT used (it would block payments).
messages_per_sec = 10
subscriptions_per_min = 60
max_event_bytes = 131072
max_ws_message_bytes = 262144
max_subscriptions = 20
[options]
reject_future_seconds = 1800