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]] [[package]]
name = "nip44" name = "nip44"
version = "0.1.0" version = "0.3.0"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"chacha20 0.9.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 + HTTP webhook (the WooCommerce contract), an authenticated admin dashboard +
JSON API, and NIP-17 DMs to the merchant / payer. JSON API, and NIP-17 DMs to the merchant / payer.
All relay traffic rides an in-process Nym mixnet tunnel (smolmix, auto-selected By default all relay traffic rides an in-process Nym mixnet tunnel (smolmix,
exit, mix-dns; `GP_NYM=off` is a debugging escape hatch only). Encryption auto-selected exit, mix-dns). `GP_NYM=off` is also a supported production
negotiates NIP-44 v3 (the NIP-17 extension, via the companion `nip44` crate) per posture, not just a debugging switch: the server then reaches relays over
recipient, with v2 as the mandatory baseline. 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 ## 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_DATA_DIR` | `./gp-data` | Data directory (wallet files, encrypted seed) |
| `GP_NODE_URL` | `https://main.gri.mw` | External Grin node (read only) | | `GP_NODE_URL` | `https://main.gri.mw` | External Grin node (read only) |
| `GP_CHAIN` | `mainnet` | Grin network: `mainnet` or `testnet` | | `GP_CHAIN` | `mainnet` | Grin network: `mainnet` or `testnet` |
| `GP_RELAY_MODE` | `bundled` | `bundled` or `external` | | `GP_RELAY_MODE` | `bundled` | `bundled` (GoblinPay runs its own co-located relay) or `external` |
| `GP_RELAYS` | unset | Comma-separated relay URLs | | `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_NYM` | `on` | Route Nostr traffic over the Nym mixnet (`on` or `off`) | | `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_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_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_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 ingest on. The connector `POST /invoice` JSON response still returns the
`nprofile` regardless of this setting, which affects only the hosted page. `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) ### Conversion rates (optional)
A store that prices in fiat (for example cryptodrip.com prices in USD) sends 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 ./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 ## Credits
GoblinPay is developed with the help of Claude (Anthropic). 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). /// Nostr gift-wrap layer in gp-nostr).
pub const DEFAULT_NODE_URL: &str = "https://main.gri.mw"; 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. /// TLS mode for the HTTP server.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
@@ -53,7 +61,11 @@ pub enum Chain {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum RelayMode { 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, Bundled,
/// Only external relays from `GP_RELAYS` are used. /// Only external relays from `GP_RELAYS` are used.
External, External,
@@ -130,8 +142,14 @@ pub struct Config {
pub relay_mode: RelayMode, pub relay_mode: RelayMode,
/// External relays (`GP_RELAYS`, comma separated). /// External relays (`GP_RELAYS`, comma separated).
pub relays: Vec<String>, 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`, /// 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, pub nym: bool,
/// Run the Nostr ingest service (`GP_INGEST`: `on` or `off`, default on). /// Run the Nostr ingest service (`GP_INGEST`: `on` or `off`, default on).
/// When on, the wallet and identity secrets are required at boot. /// When on, the wallet and identity secrets are required at boot.
@@ -235,6 +253,7 @@ impl Default for Config {
chain: Chain::Mainnet, chain: Chain::Mainnet,
relay_mode: RelayMode::Bundled, relay_mode: RelayMode::Bundled,
relays: Vec::new(), relays: Vec::new(),
bundled_relay_url: DEFAULT_BUNDLED_RELAY.into(),
nym: true, nym: true,
ingest: true, ingest: true,
checkout_nostr: true, checkout_nostr: true,
@@ -320,6 +339,10 @@ impl Config {
.map(|s| s.trim().to_string()) .map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()) .filter(|s| !s.is_empty())
.collect::<Vec<_>>(); .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") { let nym = match get("GP_NYM").as_deref().unwrap_or("on") {
"on" => true, "on" => true,
@@ -406,6 +429,7 @@ impl Config {
chain, chain,
relay_mode, relay_mode,
relays, relays,
bundled_relay_url,
nym, nym,
ingest, ingest,
checkout_nostr, checkout_nostr,
@@ -473,6 +497,9 @@ impl Config {
if self.relay_mode == RelayMode::External && self.relays.is_empty() { if self.relay_mode == RelayMode::External && self.relays.is_empty() {
return Err("GP_RELAY_MODE=external requires GP_RELAYS".into()); 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() { if self.nsec.is_some() && self.ncryptsec.is_some() {
return Err("set only one of GP_NSEC and GP_NCRYPTSEC".into()); 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" }; let set = |o: bool| if o { "set" } else { "unset" };
format!( format!(
"bind={} tls={} db={} data_dir={} node={} chain={:?} relay_mode={:?} \ "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={} \ wallet_password={} \
nsec={} ncryptsec={} public_url={} api_token={} admin_token={} webhook_url={} \ nsec={} ncryptsec={} public_url={} api_token={} admin_token={} webhook_url={} \
webhook_secret={} qr_logo={} merchant_npub={} notify_merchant_dm={} \ webhook_secret={} qr_logo={} merchant_npub={} notify_merchant_dm={} \
@@ -525,6 +553,7 @@ impl Config {
self.chain, self.chain,
self.relay_mode, self.relay_mode,
self.relays, self.relays,
self.bundled_relay_url,
if self.nym { "on" } else { "off" }, if self.nym { "on" } else { "off" },
if self.ingest { "on" } else { "off" }, if self.ingest { "on" } else { "off" },
self.checkout_methods_str(), self.checkout_methods_str(),
@@ -657,6 +686,7 @@ mod tests {
assert_eq!(cfg.chain, Chain::Mainnet); assert_eq!(cfg.chain, Chain::Mainnet);
assert_eq!(cfg.relay_mode, RelayMode::Bundled); assert_eq!(cfg.relay_mode, RelayMode::Bundled);
assert!(cfg.relays.is_empty()); assert!(cfg.relays.is_empty());
assert_eq!(cfg.bundled_relay_url, DEFAULT_BUNDLED_RELAY);
assert!(cfg.nym); assert!(cfg.nym);
assert!(cfg.ingest); assert!(cfg.ingest);
assert_eq!(cfg.match_mode, MatchMode::Memo); assert_eq!(cfg.match_mode, MatchMode::Memo);
@@ -676,6 +706,7 @@ mod tests {
("GP_CHAIN", "testnet"), ("GP_CHAIN", "testnet"),
("GP_RELAY_MODE", "external"), ("GP_RELAY_MODE", "external"),
("GP_RELAYS", "wss://relay.example, wss://relay2.example ,"), ("GP_RELAYS", "wss://relay.example, wss://relay2.example ,"),
("GP_BUNDLED_RELAY_URL", "wss://relay.mystore.example"),
("GP_NYM", "off"), ("GP_NYM", "off"),
("GP_INGEST", "off"), ("GP_INGEST", "off"),
("GP_MATCH_MODE", "derived"), ("GP_MATCH_MODE", "derived"),
@@ -691,6 +722,7 @@ mod tests {
cfg.relays, cfg.relays,
vec!["wss://relay.example", "wss://relay2.example"] vec!["wss://relay.example", "wss://relay2.example"]
); );
assert_eq!(cfg.bundled_relay_url, "wss://relay.mystore.example");
assert!(!cfg.nym); assert!(!cfg.nym);
assert!(!cfg.ingest); assert!(!cfg.ingest);
assert_eq!(cfg.match_mode, MatchMode::Derived); assert_eq!(cfg.match_mode, MatchMode::Derived);
+3 -3
View File
@@ -1,8 +1,8 @@
//! The store-connector seam. //! The store-connector seam.
//! //!
//! Every store integration (the built-in generic REST connector, the //! Every store integration (the built-in generic REST connector, the shipped
//! WooCommerce and Medusa plugins that arrive in a later milestone, and the //! WooCommerce and Medusa plugins under `connectors/`, and the future pop-up
//! future pop-up Nostr store) drives GoblinPay through one uniform contract: //! Nostr store) drives GoblinPay through one uniform contract:
//! a create-invoice request in, a hosted checkout + signed webhook out. This //! 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 //! 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 //! 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 use gp_core::config::RelayMode;
/// 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",
];
/// Maximum relays published in the kind 10050 DM relay list (NIP-17 /// Maximum relays published in the kind 10050 DM relay list (NIP-17
/// guidance) and read from a payer's list. /// guidance) and read from a payer's list.
pub const MAX_DM_RELAYS: usize = 3; pub const MAX_DM_RELAYS: usize = 3;
/// The relay set to run with: the configured external list, else defaults. /// The relay set to listen on, publish to, and advertise in the `nprofile`.
pub fn resolve(configured: &[String]) -> Vec<String> { ///
if configured.is_empty() { /// In `bundled` mode the co-located `bundled_url` comes first (so it heads the
DEFAULT_RELAYS.iter().map(|s| s.to_string()).collect() /// advertised kind 10050 / `nprofile` hints), followed by any `configured`
} else { /// redundancy relays, de-duplicated. In `external` mode only the `configured`
configured.to_vec() /// 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::*; use super::*;
#[test] #[test]
fn resolves_defaults_and_overrides() { fn bundled_leads_with_the_bundled_relay() {
assert_eq!(resolve(&[]), DEFAULT_RELAYS.to_vec()); // 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()]; 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 { pub struct ServiceOptions {
/// Relay set to listen on and publish to. /// Relay set to listen on and publish to.
pub relays: Vec<String>, pub relays: Vec<String>,
/// Route everything over the Nym mixnet (default on; clearnet is a /// Route everything over the Nym mixnet (default on). `off` is a supported
/// debugging escape hatch only). /// 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, pub nym: bool,
/// Optional NIP-17 payment DMs (milestone 6, all off by default). /// Optional NIP-17 payment DMs (milestone 6, all off by default).
pub notify: NotifyOptions, pub notify: NotifyOptions,
@@ -141,7 +142,10 @@ pub async fn run<R: SlatepackReceiver>(
.websocket_transport(NymWebSocketTransport) .websocket_transport(NymWebSocketTransport)
.build() .build()
} else { } 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() Client::builder().build()
}; };
+3 -2
View File
@@ -105,7 +105,8 @@ async fn dashboard(
match_mode: format!("{:?}", cfg.match_mode).to_lowercase(), match_mode: format!("{:?}", cfg.match_mode).to_lowercase(),
nym: cfg.nym, nym: cfg.nym,
ingest: cfg.ingest, 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(), webhook_configured: cfg.webhook_url.is_some(),
pending_webhooks, pending_webhooks,
rotate_interval: cfg.endpub_rotate_interval, 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 { 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) { let (npub, nprofile, qr) = match PublicKey::from_hex(pubkey) {
Ok(pk) => ( Ok(pk) => (
gp_nostr::npub_of(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), /// caller does not surface the Slatepack option (e.g. the JSON connector API),
/// in which case no Slatepack address or QR is produced. /// in which case no Slatepack address or QR is produced.
pub fn build_info(inv: &Invoice, cfg: &Config, slatepack_addr: Option<&str>) -> CheckoutInfo { 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(); let recipient_pubkey = inv.recipient_pubkey.clone().unwrap_or_default();
// The Nostr (Goblin Wallet) method is only surfaced when the operator has it // The Nostr (Goblin Wallet) method is only surfaced when the operator has it
// enabled (`GP_CHECKOUT_METHODS`). Disabled, the nprofile/npub/QR are left // 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"); eprintln!("warning: GP_NOTIFY_MERCHANT_DM=on but GP_MERCHANT_NPUB is unset/invalid");
} }
let opts = gp_nostr::service::ServiceOptions { 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, nym: cfg.nym,
notify: gp_nostr::service::NotifyOptions { notify: gp_nostr::service::NotifyOptions {
merchant, 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