Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d36117d7b | |||
| 3fdf4a230c | |||
| bba1dd5cba | |||
| c32ddfa9ff |
@@ -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
|
||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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],
|
||||||
|
})
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
@@ -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:
|
||||||
@@ -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
|
||||||
Executable
+77
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user